Updated on 2026-08-14
This commit is contained in:
commit
383d42f6b3
113 changed files with 2380 additions and 649 deletions
|
|
@ -186,6 +186,7 @@ abstract class BaseTestCase : TestCase(
|
|||
"VISA_ONBOARDING_ENABLED" to true,
|
||||
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
|
||||
"AND_15310_ADD_FUNDS_STAGE1" to true,
|
||||
"APP_REDESIGN_ENABLED" to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,5 +115,13 @@ private fun extractText(node: SemanticsNode): String? {
|
|||
|
||||
private fun parseVolume(node: SemanticsNode): Double? {
|
||||
val text = extractText(node) ?: return null
|
||||
return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull()
|
||||
val multiplier = when {
|
||||
text.contains('T', ignoreCase = true) -> 1_000_000_000_000.0
|
||||
text.contains('B', ignoreCase = true) -> 1_000_000_000.0
|
||||
text.contains('M', ignoreCase = true) -> 1_000_000.0
|
||||
text.contains('K', ignoreCase = true) -> 1_000.0
|
||||
else -> 1.0
|
||||
}
|
||||
val number = text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() ?: return null
|
||||
return number * multiplier
|
||||
}
|
||||
|
|
@ -11,6 +11,11 @@ fun KNode.clickWithAssertion() {
|
|||
performClick()
|
||||
}
|
||||
|
||||
fun KNode.clickWhenEnabled() {
|
||||
assertIsEnabled()
|
||||
performClick()
|
||||
}
|
||||
|
||||
fun KNode.assertTextContainsSafe(
|
||||
text: String,
|
||||
substring: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import androidx.test.uiautomator.By
|
|||
import androidx.test.uiautomator.Until
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||
import com.tangem.wallet.R
|
||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||
|
||||
fun BaseTestCase.swipeVertical(
|
||||
direction: SwipeDirection,
|
||||
|
|
@ -31,21 +29,6 @@ fun BaseTestCase.pullToRefresh(steps: Int = 1000) {
|
|||
)
|
||||
}
|
||||
|
||||
fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) {
|
||||
val searchBarText = device.uiDevice
|
||||
.findObject(By.textContains(getResourceString(R.string.markets_search_header_title)))
|
||||
val bounds = searchBarText.visibleBounds
|
||||
|
||||
val centerX = bounds.centerX()
|
||||
val startY = bounds.centerY()
|
||||
val endY = when (direction) {
|
||||
SwipeDirection.UP -> 50
|
||||
SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100
|
||||
}
|
||||
|
||||
device.uiDevice.swipe(centerX, startY, centerX, endY, 100)
|
||||
}
|
||||
|
||||
fun BaseTestCase.openTheAppFromRecents() {
|
||||
device.uiDevice.waitForIdle()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,97 +6,33 @@ import com.tangem.common.extensions.swipeVertical
|
|||
import com.tangem.screens.onMainScreen
|
||||
import io.qameta.allure.kotlin.Allure.step
|
||||
|
||||
fun BaseTestCase.checkSingleCurrencyMainScreen(
|
||||
cardBlockchain: String,
|
||||
cardTitle: String,
|
||||
withTransactions: Boolean = false,
|
||||
withWalletImage: Boolean = true
|
||||
) {
|
||||
fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) {
|
||||
step("Assert card title equal '$cardTitle'") {
|
||||
onMainScreen { walletNameText.assertTextEquals(cardTitle) }
|
||||
}
|
||||
if (withWalletImage) {
|
||||
step("Assert card image is displayed") { //TODO: create assertion method for checking images
|
||||
onMainScreen { walletImage.assertIsDisplayed() }
|
||||
}
|
||||
} else {
|
||||
step("Assert card image is not displayed") {
|
||||
onMainScreen { walletImage.assertIsNotDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Assert 'Receive' button is displayed") {
|
||||
onMainScreen { receiveButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Buy' button is displayed") {
|
||||
onMainScreen { buyButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Send' button is displayed") {
|
||||
onMainScreen { sendButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Sell' button is displayed") {
|
||||
onMainScreen { sellButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Swap' button is not displayed") {
|
||||
onMainScreen { swapButton.assertIsNotDisplayed() }
|
||||
}
|
||||
step("Assert 'Market Price' on single card main screen is displayed") {
|
||||
onMainScreen { marketPriceBlock().assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Market Price' title equals $cardBlockchain Market Price") {
|
||||
onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") }
|
||||
}
|
||||
step("Swipe up") {
|
||||
swipeVertical(SwipeDirection.UP)
|
||||
}
|
||||
if (withTransactions) {
|
||||
step("Assert 'Transactions' block is displayed") {
|
||||
onMainScreen { transactionsExplorerText.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Transactions' title is displayed") {
|
||||
onMainScreen { transactionsTitle.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Explorer' icon is displayed") {
|
||||
onMainScreen { transactionsExplorerIcon.assertIsDisplayed() }
|
||||
}
|
||||
} else {
|
||||
step("Assert empty 'Transactions' block is displayed") {
|
||||
onMainScreen { emptyTransactionBlock.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert empty 'Transactions' block icon is displayed") {
|
||||
onMainScreen { emptyTransactionBlockIcon.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert empty 'Transactions' block text is displayed") {
|
||||
onMainScreen { emptyTransactionBlockText.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert empty 'Transactions' block 'Explore' button is displayed") {
|
||||
onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Assert 'Add & Manage' button is not displayed") {
|
||||
onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() }
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseTestCase.checkMultiCurrencyMainScreen(
|
||||
devicesCount: String,
|
||||
cardTitle: String,
|
||||
withWalletImage: Boolean = true
|
||||
) {
|
||||
step("Assert card title equal '$cardTitle'") {
|
||||
onMainScreen { walletNameText.assertTextEquals(cardTitle) }
|
||||
}
|
||||
if (withWalletImage) {
|
||||
step("Assert card image is displayed") {
|
||||
onMainScreen { walletImage.assertIsDisplayed() }
|
||||
}
|
||||
} else {
|
||||
step("Assert card image is not displayed") {
|
||||
onMainScreen { walletImage.assertIsNotDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Assert devices count equal to '$devicesCount'") {
|
||||
onMainScreen { walletDevicesCount.assertTextContains(devicesCount) }
|
||||
}
|
||||
step("Assert 'Buy' button is displayed") {
|
||||
onMainScreen { buyButton.assertIsDisplayed() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
package com.tangem.scenarios
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.extensions.SwipeDirection
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
import com.tangem.common.extensions.swipeVertical
|
||||
import com.tangem.screens.onMainScreen
|
||||
import com.tangem.screens.onMarketsExchangesScreen
|
||||
import com.tangem.screens.onMarketsScreen
|
||||
import com.tangem.screens.onMarketsTokenDetailsScreen
|
||||
import io.qameta.allure.kotlin.Allure.step
|
||||
|
||||
fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: String) {
|
||||
fun BaseTestCase.openTokenDetailsFromMarketsScreen(blockchainName: String, tokenName: String) {
|
||||
step("Open 'Markets' screen") {
|
||||
onMainScreen { searchThroughMarketPlaceholder.performClick() }
|
||||
waitForIdle()
|
||||
}
|
||||
step("Click on 'Search' placeholder") {
|
||||
onMarketsScreen { searchThroughMarketPlaceholder.performClick() }
|
||||
}
|
||||
step("Click on $blockchainName blockchain") {
|
||||
waitForIdle()
|
||||
onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() }
|
||||
}
|
||||
step("Click on $tokenName token") {
|
||||
step("Click on 'In your portfolio' block") {
|
||||
waitForIdle()
|
||||
onMarketsTokenDetailsScreen { inYourPortfolioBlock.clickWithAssertion() }
|
||||
}
|
||||
step("Click on $tokenName token in 'Your portfolio' bottom sheet") {
|
||||
waitForIdle()
|
||||
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ fun BaseTestCase.openMarketsScreen() {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) {
|
||||
openMarketsScreen()
|
||||
if (shouldClickSeeAllButton)
|
||||
|
|
@ -69,9 +70,8 @@ fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAll
|
|||
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
|
||||
waitForIdle()
|
||||
}
|
||||
step("Scroll down") {
|
||||
swipeVertical(SwipeDirection.UP)
|
||||
swipeVertical(SwipeDirection.UP)
|
||||
step("Scroll to 'Listed on exchanges' block") {
|
||||
onMarketsScreen { scrollToListedOnBlock() }
|
||||
}
|
||||
step("Click on 'Listed on exchanges' block") {
|
||||
onMarketsScreen { listedOnBlockContainer.performClick() }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
|
|||
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
|
||||
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||
import com.tangem.common.extensions.assertIsDimmed
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
import com.tangem.common.utils.setWireMockScenarioState
|
||||
import com.tangem.screens.*
|
||||
|
|
@ -34,8 +33,11 @@ fun BaseTestCase.openSendScreen(
|
|||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button") {
|
||||
onTokenDetailsScreen { sendButton().performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button in bottom sheet") {
|
||||
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,11 +93,11 @@ fun BaseTestCase.openSendAddressScreen(
|
|||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Send' button is not dimmed") {
|
||||
onTokenDetailsScreen { sendButton().assertIsDimmed(false) }
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button") {
|
||||
onTokenDetailsScreen { sendButton().performClick() }
|
||||
step("Click on 'Send' button in bottom sheet") {
|
||||
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
||||
}
|
||||
step("Type '$inputAmount' in input text field") {
|
||||
onSendScreen {
|
||||
|
|
@ -109,6 +111,13 @@ fun BaseTestCase.openSendAddressScreen(
|
|||
step("Assert 'Send Address' container is displayed") {
|
||||
onSendAddressScreen { container.assertIsDisplayed() }
|
||||
}
|
||||
step("Wait for recipient list to load") {
|
||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
runCatching {
|
||||
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
|
||||
}.isSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) {
|
||||
|
|
@ -244,8 +253,11 @@ fun BaseTestCase.selectTokenToSendViaSwap(
|
|||
networkName: String,
|
||||
networkType: String? = null,
|
||||
) {
|
||||
step("Click on 'Send' button") {
|
||||
onTokenDetailsScreen { sendButton().performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button in bottom sheet") {
|
||||
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Swap to another token' button") {
|
||||
onSendScreen { swapToAnotherTokenButton.performClick() }
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.tangem.common.BaseTestCase
|
|||
import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||
import com.tangem.common.extensions.assertVisibility
|
||||
import com.tangem.common.extensions.clickWhenEnabled
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
import com.tangem.common.extensions.isDisplayedSafely
|
||||
import com.tangem.core.ui.R as CoreUiR
|
||||
|
|
@ -43,8 +44,8 @@ fun BaseTestCase.openSwapScreen(
|
|||
}
|
||||
|
||||
SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") {
|
||||
onTokenDetailsScreen { swapButton().performClick() }
|
||||
}
|
||||
onTokenDetailsScreen { swapButton.clickWhenEnabled() }
|
||||
}
|
||||
|
||||
SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") {
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.screens
|
||||
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.test.BaseBottomSheetTestTags
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.KNode
|
||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||
import androidx.compose.ui.test.hasText as withText
|
||||
|
||||
class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||
ComposeScreen<AddFundsBottomSheetPageObject>(
|
||||
semanticsProvider = semanticsProvider,
|
||||
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
|
||||
) {
|
||||
|
||||
val buyButton: KNode = child {
|
||||
hasAnyChild(withText(getResourceString(R.string.common_buy)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val swapButton: KNode = child {
|
||||
hasAnyChild(withText(getResourceString(R.string.common_swap)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val receiveButton: KNode = child {
|
||||
hasAnyChild(withText(getResourceString(R.string.common_receive)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val closeButton: KNode = child {
|
||||
hasAnyChild(withText(getResourceString(R.string.common_close)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) =
|
||||
onComposeScreen(composeTestRule, function)
|
||||
|
|
@ -11,7 +11,10 @@ import io.github.kakaocup.compose.node.element.KNode
|
|||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||
|
||||
class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||
ComposeScreen<AddTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
|
||||
ComposeScreen<AddTokenBottomSheetPageObject>(
|
||||
semanticsProvider = semanticsProvider,
|
||||
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
|
||||
) {
|
||||
|
||||
val title: KNode = child {
|
||||
hasTestTag(BaseBottomSheetTestTags.TITLE)
|
||||
|
|
@ -23,6 +26,12 @@ class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions
|
|||
hasText(getResourceString(R.string.common_add))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val laterButton: KNode = child {
|
||||
hasTestTag(BaseButtonTestTags.TEXT)
|
||||
hasText(getResourceString(R.string.common_later))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) =
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.test.ExperimentalTestApi
|
|||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.extensions.getQuantityString
|
||||
import com.tangem.common.extensions.hasLazyListItemPosition
|
||||
|
|
@ -49,27 +50,32 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
|
||||
val buyButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_buy))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_buy)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val sendButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_send))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_send)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val receiveButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_receive))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_receive)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val sellButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_sell))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_sell)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val swapButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_swap))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val walletNameText: KNode = child {
|
||||
|
|
@ -82,13 +88,21 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val walletDevicesCount: KNode = child {
|
||||
hasTestTag(MainScreenTestTags.DEVICES_COUNT)
|
||||
useUnmergedTree = true
|
||||
/**
|
||||
* Collapses the collapsing header via a touch-based swipe so that items near the bottom
|
||||
* of the lazy list fall within screen bounds before programmatic childWith scroll.
|
||||
* Required because TangemCollapsingTopBar places the body at y=collapsingHeight, which
|
||||
* pushes lower list items off-screen when the header is expanded.
|
||||
*/
|
||||
private fun collapseHeader() {
|
||||
screenContainer {
|
||||
performTouchInput { swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f) }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun marketPriceBlock(): LazyListItemNode {
|
||||
collapseHeader()
|
||||
return lazyList.childWith<LazyListItemNode> {
|
||||
hasTestTag(MarketPriceBlockTestTags.BLOCK)
|
||||
useUnmergedTree = true
|
||||
|
|
@ -231,6 +245,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun accountWithName(name: String): LazyListItemNode {
|
||||
collapseHeader()
|
||||
return lazyList.childWith<LazyListItemNode> {
|
||||
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
|
||||
hasAnyDescendant(withText(name))
|
||||
|
|
@ -243,6 +258,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun tokenWithTitleAndAddress(tokenTitle: String): KNode {
|
||||
collapseHeader()
|
||||
return lazyList.childWith<LazyListItemNode> {
|
||||
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
|
||||
hasText(tokenTitle)
|
||||
|
|
@ -255,6 +271,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode {
|
||||
collapseHeader()
|
||||
return lazyList.childWith<LazyListItemNode> {
|
||||
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
|
||||
hasText(tokenTitle)
|
||||
|
|
@ -267,6 +284,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun addAndManageButton(): KNode {
|
||||
collapseHeader()
|
||||
return lazyList.childWith<LazyListItemNode> {
|
||||
hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON)
|
||||
}.child<KNode> {
|
||||
|
|
@ -282,11 +300,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
}
|
||||
|
||||
val searchThroughMarketPlaceholder: KNode = child {
|
||||
hasText(getResourceString(R.string.markets_search_header_title))
|
||||
hasText(getResourceString(R.string.markets_search_title_placeholder))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
|
||||
collapseHeader()
|
||||
return lazyList.child {
|
||||
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
|
||||
hasAnyChild(withText(tokenNetwork))
|
||||
|
|
@ -296,6 +315,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode {
|
||||
collapseHeader()
|
||||
return lazyList.childWith<LazyListItemNode> {
|
||||
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
|
||||
hasText(tokenTitle)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ package com.tangem.screens
|
|||
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import androidx.compose.ui.test.hasParent
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.core.ui.test.TokenElementsTestTags
|
||||
import com.tangem.core.ui.test.TopAppBarTestTags
|
||||
import com.tangem.features.onramp.impl.R
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
|
||||
|
|
@ -23,16 +21,15 @@ class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractions
|
|||
|
||||
fun allExchangeTypeNodes(): List<SemanticsNode> =
|
||||
provider
|
||||
.onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))))
|
||||
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))
|
||||
.fetchSemanticsNodes()
|
||||
|
||||
fun allTrustScoreNodes(): List<SemanticsNode> =
|
||||
provider
|
||||
.onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)))
|
||||
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))
|
||||
.fetchSemanticsNodes()
|
||||
|
||||
val exchangesTitle: KNode = child {
|
||||
hasTestTag(TopAppBarTestTags.TITLE)
|
||||
hasText(getResourceString(R.string.markets_token_details_exchanges_title))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.tangem.screens
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX
|
||||
import com.tangem.core.ui.test.BaseButtonTestTags
|
||||
|
|
@ -15,9 +17,9 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString
|
|||
class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||
ComposeScreen<MarketsPageObject>(semanticsProvider = semanticsProvider) {
|
||||
|
||||
val addToPortfolioButton: KNode = child {
|
||||
val addButton: KNode = child {
|
||||
hasTestTag(BaseButtonTestTags.TEXT)
|
||||
hasText(getResourceString(R.string.common_add_to_portfolio))
|
||||
hasText(getResourceString(R.string.common_add))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +33,12 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
|||
}
|
||||
|
||||
val searchThroughMarketPlaceholder: KNode = child {
|
||||
hasText(getResourceString(R.string.markets_search_header_title))
|
||||
hasText(getResourceString(R.string.markets_search_title_placeholder))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val tokenDetailsContent: KNode = child {
|
||||
hasTestTag(MarketsTestTags.TOKEN_DETAILS_CONTENT)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +48,8 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
|||
}
|
||||
|
||||
val listedOnBlockContainer: KNode = child {
|
||||
hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true)
|
||||
hasTestTag(MarketsTestTags.LISTED_ON_BLOCK)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val listedOnEmptyText: KNode = child {
|
||||
|
|
@ -60,6 +68,13 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
|||
hasText(title)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTestApi
|
||||
fun scrollToListedOnBlock() {
|
||||
tokenDetailsContent {
|
||||
performScrollToNode(hasTestTag(MarketsTestTags.LISTED_ON_BLOCK))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) =
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ package com.tangem.screens
|
|||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
|
||||
import com.tangem.core.ui.test.TokenElementsTestTags
|
||||
import com.tangem.features.onramp.impl.R
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.KNode
|
||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||
import androidx.compose.ui.test.hasTestTag as withTestTag
|
||||
import androidx.compose.ui.test.hasText as withText
|
||||
import com.tangem.core.ui.R as CoreUiR
|
||||
|
||||
class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||
ComposeScreen<MarketsTokenDetailsPageObject>(semanticsProvider = semanticsProvider) {
|
||||
|
|
@ -20,11 +19,14 @@ class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractions
|
|||
hasText(getResourceString(R.string.common_swap), substring = true)
|
||||
}
|
||||
|
||||
val inYourPortfolioBlock: KNode = child {
|
||||
hasText(getResourceString(CoreUiR.string.markets_portfolio_block_subtitle), substring = true)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
fun tokenWithTitle(title: String): KNode = child {
|
||||
hasAnyAncestor(withTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM))
|
||||
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
|
||||
hasAnySibling(withTestTag(TokenElementsTestTags.TOKEN_ICON))
|
||||
hasAnyChild(withText(title))
|
||||
hasClickAction()
|
||||
useUnmergedTree = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,23 +28,18 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
private val topBarGroupButton: KNode = child {
|
||||
hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON)
|
||||
val organizeMenuButton: KNode = child {
|
||||
hasTestTag(OrganizeTokensScreenTestTags.MENU_BUTTON)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val groupButton: KNode = topBarGroupButton.child {
|
||||
val groupButton: KNode = child {
|
||||
hasText(getResourceString(R.string.organize_tokens_group))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val ungroupButton: KNode = topBarGroupButton.child {
|
||||
hasText(getResourceString(R.string.organize_tokens_ungroup))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val sortByBalanceButton: KNode = child {
|
||||
hasTestTag(OrganizeTokensScreenTestTags.SORT_BY_BALANCE_BUTTON)
|
||||
hasText(getResourceString(R.string.organize_tokens_sort_by_balance))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
// endregion TopBar
|
||||
|
|
@ -84,7 +79,7 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
|
|||
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
|
||||
return lazyList.child {
|
||||
hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM)
|
||||
hasAnyChild(withText(tokenNetwork))
|
||||
hasAnyDescendant(withText(tokenNetwork))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
|
|||
): KNode = child {
|
||||
hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM)
|
||||
hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON))
|
||||
hasAnyDescendant(withText(recipientAddress))
|
||||
hasAnyDescendant(withText(recipientAddress, substring = true))
|
||||
hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT))
|
||||
useUnmergedTree = true
|
||||
if (description != null) {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
package com.tangem.screens
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.utils.LazyListItemNode
|
||||
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
|
||||
import com.tangem.core.ui.test.BaseButtonTestTags
|
||||
import com.tangem.core.ui.test.NotificationTestTags
|
||||
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
|
||||
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
|
||||
import com.tangem.features.tokendetails.impl.R
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.KNode
|
||||
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
|
||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||
import androidx.compose.ui.test.hasTestTag as withTestTag
|
||||
import androidx.compose.ui.test.hasText as withText
|
||||
|
|
@ -36,18 +31,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val availableStakingBlockTitle: KNode = child {
|
||||
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val availableStakingBlockText: KNode = child {
|
||||
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TEXT)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val availableStakingBlockCurrencyIcon: KNode = child {
|
||||
hasTestTag(TokenDetailsScreenTestTags.STAKING_CURRENCY_ICON)
|
||||
fun availableStakingBlockText(apy: String): KNode = child {
|
||||
hasText(getResourceString(R.string.token_details_earn_staking_subtitle, apy))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
|
|
@ -62,23 +47,17 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val stakingDot: KNode = child {
|
||||
hasTestTag(TokenDetailsScreenTestTags.STAKING_DOT)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val stakingTokenAmount: KNode = child {
|
||||
hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val stakingChevronIcon: KNode = child {
|
||||
hasTestTag(TokenDetailsScreenTestTags.STAKING_CHEVRON_ICON)
|
||||
useUnmergedTree = true
|
||||
val stakingTitle: KNode = child {
|
||||
hasText(getResourceString(R.string.common_staking))
|
||||
}
|
||||
|
||||
val stakingTitle: KNode = child {
|
||||
hasText(getResourceString(R.string.staking_native))
|
||||
val stakingEnabledTitle: KNode = child {
|
||||
hasText(getResourceString(R.string.staking_enabled))
|
||||
}
|
||||
|
||||
val title: KNode = child {
|
||||
|
|
@ -90,46 +69,22 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
private val horizontalActionChips = KLazyListNode(
|
||||
semanticsProvider = semanticsProvider,
|
||||
viewBuilderAction = { hasTestTag(BaseActionButtonsBlockTestTags.HORIZONTAL_ACTION_CHIPS) },
|
||||
itemTypeBuilder = { itemType(::LazyListItemNode) },
|
||||
positionMatcher = { position ->
|
||||
SemanticsMatcher.expectValue(
|
||||
LazyListItemPositionSemantics,
|
||||
position
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
|
||||
val addFundsButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_receive))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.tangempay_card_details_add_funds)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun swapButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
|
||||
val swapButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_swap))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun sellButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
|
||||
val transferButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_sell))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun buyButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_buy))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun sendButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasText(getResourceString(R.string.common_send))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_transfer)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child {
|
||||
|
|
@ -209,7 +164,6 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
|
|||
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON))
|
||||
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON))
|
||||
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT))
|
||||
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.screens
|
||||
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.test.BaseBottomSheetTestTags
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.KNode
|
||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||
import androidx.compose.ui.test.hasText as withText
|
||||
|
||||
class TransferBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||
ComposeScreen<TransferBottomSheetPageObject>(
|
||||
semanticsProvider = semanticsProvider,
|
||||
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
|
||||
) {
|
||||
|
||||
val sendButton: KNode = child {
|
||||
hasAnyChild(withText(getResourceString(R.string.common_send)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val swapButton: KNode = child {
|
||||
hasAnyChild(withText(getResourceString(R.string.common_swap)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val sellButton: KNode = child {
|
||||
hasAnyChild(withText(getResourceString(R.string.common_sell)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val closeButton: KNode = child {
|
||||
hasAnyChild(withText(getResourceString(R.string.common_close)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BaseTestCase.onTransferBottomSheet(function: TransferBottomSheetPageObject.() -> Unit) =
|
||||
onComposeScreen(composeTestRule, function)
|
||||
|
|
@ -27,6 +27,7 @@ import com.tangem.screens.onSendScreen
|
|||
import com.tangem.screens.onStoriesScreen
|
||||
import com.tangem.screens.onTokenDetailsScreen
|
||||
import com.tangem.screens.onMainScreenTopBar
|
||||
import com.tangem.screens.onTransferBottomSheet
|
||||
import com.tangem.tap.domain.sdk.mocks.MockProvider
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.qameta.allure.kotlin.AllureId
|
||||
|
|
@ -94,8 +95,11 @@ class FeedbackTest : BaseTestCase() {
|
|||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
|
||||
}
|
||||
step("Click 'Send' button") {
|
||||
onTokenDetailsScreen { sendButton().performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button in bottom sheet") {
|
||||
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
||||
}
|
||||
step("Type '$sendAmount' in input text field") {
|
||||
onSendScreen {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ class OrganizeTokensTest : BaseTestCase() {
|
|||
fun groupTokensTest() {
|
||||
setupHooks().run {
|
||||
val tokenTitle = "Ethereum"
|
||||
val tokenNetwork = "Ethereum network"
|
||||
val networkTitleOrganize = "Ethereum"
|
||||
val networkTitleMain = "Ethereum network"
|
||||
|
||||
step("Open 'Main Screen'") {
|
||||
openMainScreen()
|
||||
|
|
@ -39,17 +40,20 @@ class OrganizeTokensTest : BaseTestCase() {
|
|||
tokenWithTitle(tokenTitle).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
step("Open organize menu") {
|
||||
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click 'Group' button") {
|
||||
onOrganizeTokensScreen { groupButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert tokens were grouped on 'Organize tokens' screen") {
|
||||
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() }
|
||||
onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsDisplayed() }
|
||||
}
|
||||
step("Click 'Apply' button") {
|
||||
onOrganizeTokensScreen { applyButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert tokens were grouped on 'Main screen'") {
|
||||
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() }
|
||||
onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsDisplayed() }
|
||||
}
|
||||
step("Open 'Organize tokens' screen") {
|
||||
openOrganizeTokensScreen()
|
||||
|
|
@ -60,17 +64,20 @@ class OrganizeTokensTest : BaseTestCase() {
|
|||
tokenWithTitle(tokenTitle).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
step("Click 'Ungroup' button") {
|
||||
onOrganizeTokensScreen { ungroupButton.clickWithAssertion() }
|
||||
step("Open organize menu") {
|
||||
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click 'Group' checkbox again to ungroup") {
|
||||
onOrganizeTokensScreen { groupButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert tokens were ungrouped on 'Organize tokens' screen") {
|
||||
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() }
|
||||
onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsNotDisplayed() }
|
||||
}
|
||||
step("Click 'Apply' button") {
|
||||
onOrganizeTokensScreen { applyButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert tokens were ungrouped on 'Main screen'") {
|
||||
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() }
|
||||
onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsNotDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -185,6 +192,9 @@ class OrganizeTokensTest : BaseTestCase() {
|
|||
tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
step("Open organize menu") {
|
||||
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click 'By Balance' button") {
|
||||
onOrganizeTokensScreen {
|
||||
sortByBalanceButton.clickWithAssertion()
|
||||
|
|
|
|||
|
|
@ -35,11 +35,7 @@ class ScanCardTest : BaseTestCase() {
|
|||
openMainScreen(cardType)
|
||||
}
|
||||
step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") {
|
||||
checkSingleCurrencyMainScreen(
|
||||
cardBlockchain = cardBlockchain,
|
||||
cardTitle = cardType.name,
|
||||
withTransactions = true
|
||||
)
|
||||
checkSingleCurrencyMainScreen(cardTitle = cardType.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +53,7 @@ class ScanCardTest : BaseTestCase() {
|
|||
openMainScreen(mockContent = cardType, isTwinsCard = true)
|
||||
}
|
||||
step("Check 'Main' screen for '$cardName' $cardBlockchain card") {
|
||||
checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardName)
|
||||
checkSingleCurrencyMainScreen(cardTitle = cardName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +62,6 @@ class ScanCardTest : BaseTestCase() {
|
|||
@DisplayName("Scan: Card with Secp256k1 curve")
|
||||
@Test
|
||||
fun secpk1CurveCardScanTest() {
|
||||
val devicesCount = "1 device"
|
||||
val cardType: MockContent = Secpk1CurveMockContent
|
||||
val cardName = "Wallet"
|
||||
val card = "card with Secp256k1 curve"
|
||||
|
|
@ -75,12 +70,8 @@ class ScanCardTest : BaseTestCase() {
|
|||
step("Open 'Main Screen' on $card") {
|
||||
openMainScreen(mockContent = cardType)
|
||||
}
|
||||
step("Check 'Main' screen for $card curve with devices count = '$devicesCount'") {
|
||||
checkMultiCurrencyMainScreen(
|
||||
devicesCount = devicesCount,
|
||||
cardTitle = cardName,
|
||||
withWalletImage = false
|
||||
)
|
||||
step("Check 'Main' screen for $card curve") {
|
||||
checkMultiCurrencyMainScreen(cardTitle = cardName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,11 +90,7 @@ class ScanCardTest : BaseTestCase() {
|
|||
openMainScreen(mockContent = cardType)
|
||||
}
|
||||
step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") {
|
||||
checkSingleCurrencyMainScreen(
|
||||
cardBlockchain = cardBlockchain,
|
||||
cardTitle = cardName,
|
||||
withWalletImage = false
|
||||
)
|
||||
checkSingleCurrencyMainScreen(cardTitle = cardName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +99,6 @@ class ScanCardTest : BaseTestCase() {
|
|||
@DisplayName("Scan: 'Shiba' card")
|
||||
@Test
|
||||
fun shibaCardScanTest() {
|
||||
val devicesCount = "2 devices"
|
||||
val cardType: MockContent = ShibaMockContent
|
||||
val cardName = "Wallet"
|
||||
val card = "Shiba"
|
||||
|
|
@ -121,8 +107,8 @@ class ScanCardTest : BaseTestCase() {
|
|||
step("Open 'Main Screen' on '$card' card") {
|
||||
openMainScreen(mockContent = cardType)
|
||||
}
|
||||
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") {
|
||||
checkMultiCurrencyMainScreen(devicesCount, cardName)
|
||||
step("Check 'Main' screen for '$card' card") {
|
||||
checkMultiCurrencyMainScreen(cardName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +117,6 @@ class ScanCardTest : BaseTestCase() {
|
|||
@DisplayName("Scan: 'Ring'")
|
||||
@Test
|
||||
fun ringScanTest() {
|
||||
val devicesCount = "3 devices"
|
||||
val cardType: ProductType = ProductType.Ring
|
||||
val cardName = "Wallet"
|
||||
val ring = "Ring"
|
||||
|
|
@ -140,8 +125,8 @@ class ScanCardTest : BaseTestCase() {
|
|||
step("Open 'Main Screen' on '$ring'") {
|
||||
openMainScreen(productType = cardType)
|
||||
}
|
||||
step("Check 'Main' screen for '$ring' with devices count = '$devicesCount'") {
|
||||
checkMultiCurrencyMainScreen(devicesCount, cardName)
|
||||
step("Check 'Main' screen for '$ring'") {
|
||||
checkMultiCurrencyMainScreen(cardName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -150,7 +135,6 @@ class ScanCardTest : BaseTestCase() {
|
|||
@DisplayName("Scan: 'Wallet' card")
|
||||
@Test
|
||||
fun walletCardScanTest() {
|
||||
val devicesCount = "1 device"
|
||||
val cardType: ProductType = ProductType.Wallet
|
||||
val cardName = "Wallet"
|
||||
|
||||
|
|
@ -158,8 +142,8 @@ class ScanCardTest : BaseTestCase() {
|
|||
step("Open 'Main Screen' on '$cardName' card") {
|
||||
openMainScreen(productType = cardType)
|
||||
}
|
||||
step("Check 'Main' screen for '$cardName' card with devices count = '$devicesCount'") {
|
||||
checkMultiCurrencyMainScreen(devicesCount, cardName)
|
||||
step("Check 'Main' screen for '$cardName' card") {
|
||||
checkMultiCurrencyMainScreen(cardName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,7 +152,6 @@ class ScanCardTest : BaseTestCase() {
|
|||
@DisplayName("Scan: 'Wallet 2' card")
|
||||
@Test
|
||||
fun wallet2ScanTest() {
|
||||
val devicesCount = "2 devices"
|
||||
val cardType: MockContent = Wallet2MockContent
|
||||
val cardName = "Wallet"
|
||||
val card = "Wallet 2"
|
||||
|
|
@ -177,8 +160,8 @@ class ScanCardTest : BaseTestCase() {
|
|||
step("Open 'Main Screen' on '$card' card") {
|
||||
openMainScreen(mockContent = cardType)
|
||||
}
|
||||
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") {
|
||||
checkMultiCurrencyMainScreen(devicesCount, cardName)
|
||||
step("Check 'Main' screen for '$card' card") {
|
||||
checkMultiCurrencyMainScreen(cardName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -187,7 +170,6 @@ class ScanCardTest : BaseTestCase() {
|
|||
@DisplayName("Scan: Card with 4.12 firmware")
|
||||
@Test
|
||||
fun firmware412CardScanTest() {
|
||||
val devicesCount = "1 device"
|
||||
val cardType: MockContent = Firmware412MockContent
|
||||
val cardName = "Tangem card"
|
||||
val card = "card with 4.12 firmware"
|
||||
|
|
@ -196,8 +178,8 @@ class ScanCardTest : BaseTestCase() {
|
|||
step("Open 'Main Screen' on '$card'") {
|
||||
openMainScreen(mockContent = cardType)
|
||||
}
|
||||
step("Check 'Main' screen for '$card' with devices count = '$devicesCount'") {
|
||||
checkMultiCurrencyMainScreen(devicesCount, cardName)
|
||||
step("Check 'Main' screen for '$card'") {
|
||||
checkMultiCurrencyMainScreen(cardName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,20 +56,14 @@ class StakingTest : BaseTestCase() {
|
|||
onTokenDetailsScreen { stakingBlock.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Staking title' is displayed") {
|
||||
onTokenDetailsScreen { stakingTitle.assertIsDisplayed() }
|
||||
onTokenDetailsScreen { stakingEnabledTitle.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Staking fiat amount' is displayed") {
|
||||
onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Staking dot' is displayed") {
|
||||
onTokenDetailsScreen { stakingDot.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Staking token amount' is displayed") {
|
||||
onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Staking block chevron icon' is displayed") {
|
||||
onTokenDetailsScreen { stakingChevronIcon.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +133,7 @@ class StakingTest : BaseTestCase() {
|
|||
val scenarioName = "staking_eth_pol_balances_android"
|
||||
val scenarioState = "Started"
|
||||
val stakingAmount = "1"
|
||||
val stakingApy = "2.84%"
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
|
|
@ -172,13 +167,10 @@ class StakingTest : BaseTestCase() {
|
|||
onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Available staking block' title is displayed") {
|
||||
onTokenDetailsScreen { availableStakingBlockTitle.assertIsDisplayed() }
|
||||
onTokenDetailsScreen { stakingTitle.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Available staking block' text is displayed") {
|
||||
onTokenDetailsScreen { availableStakingBlockText.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Available staking block' currency icon is displayed") {
|
||||
onTokenDetailsScreen { availableStakingBlockCurrencyIcon.assertIsDisplayed() }
|
||||
onTokenDetailsScreen { availableStakingBlockText(stakingApy).assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Stake' button") {
|
||||
onTokenDetailsScreen { stakeButton.clickWithAssertion() }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
package com.tangem.tests
|
||||
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.utils.resetWireMockScenarioState
|
||||
import com.tangem.common.utils.setWireMockScenarioState
|
||||
import com.tangem.scenarios.openMainScreen
|
||||
import com.tangem.screens.onMainScreen
|
||||
import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent
|
||||
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.qameta.allure.kotlin.Allure.step
|
||||
import io.qameta.allure.kotlin.AllureId
|
||||
import io.qameta.allure.kotlin.junit4.DisplayName
|
||||
import org.junit.Test
|
||||
|
|
|
|||
|
|
@ -389,6 +389,9 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
step("Click on 'Buy' button") {
|
||||
onMainScreen { buyButton.performClick() }
|
||||
}
|
||||
step("Click on token: '$tokenTitle'") {
|
||||
onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).performClick() }
|
||||
}
|
||||
step("Click on 'Confirm' button in 'Dialog'") {
|
||||
waitForIdle()
|
||||
onDialog { confirmButton.clickWithAssertion() }
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@ package com.tangem.tests.actionButtons
|
|||
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
|
||||
import com.tangem.common.extensions.assertIsDimmed
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
import com.tangem.scenarios.checkQrCodeBottomSheetScenario
|
||||
import com.tangem.scenarios.goToQrCodeBottomSheet
|
||||
import com.tangem.scenarios.openMainScreen
|
||||
import com.tangem.scenarios.synchronizeAddresses
|
||||
import com.tangem.screens.onAddFundsBottomSheet
|
||||
import com.tangem.screens.onMainScreen
|
||||
import com.tangem.screens.onSwapStoriesScreen
|
||||
import com.tangem.screens.onSwapTokenScreen
|
||||
import com.tangem.screens.onTokenDetailsScreen
|
||||
import com.tangem.screens.onTransferBottomSheet
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.qameta.allure.kotlin.AllureId
|
||||
import io.qameta.allure.kotlin.junit4.DisplayName
|
||||
|
|
@ -37,20 +38,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
|
|||
waitForIdle()
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
|
||||
}
|
||||
step("Assert 'Receive' button is displayed") {
|
||||
onTokenDetailsScreen { receiveButton().assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Buy' button is displayed") {
|
||||
onTokenDetailsScreen { buyButton().assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Send' button is displayed") {
|
||||
onTokenDetailsScreen { sendButton().assertIsDisplayed() }
|
||||
step("Assert 'Add funds' button is displayed") {
|
||||
onTokenDetailsScreen { addFundsButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Swap' button is displayed") {
|
||||
onTokenDetailsScreen { swapButton().assertIsDisplayed() }
|
||||
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Sell' button is displayed") {
|
||||
onTokenDetailsScreen { sellButton().assertIsDisplayed() }
|
||||
step("Assert 'Transfer' button is displayed") {
|
||||
onTokenDetailsScreen { transferButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Add funds' button") {
|
||||
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Buy' button in bottom sheet is displayed") {
|
||||
onAddFundsBottomSheet { buyButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Swap' button in bottom sheet is displayed") {
|
||||
onAddFundsBottomSheet { swapButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Receive' button in bottom sheet is displayed") {
|
||||
onAddFundsBottomSheet { receiveButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Close' button in bottom sheet") {
|
||||
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Send' button in bottom sheet is displayed") {
|
||||
onTransferBottomSheet { sendButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Swap' button in bottom sheet is displayed") {
|
||||
onTransferBottomSheet { swapButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Sell' button in bottom sheet is displayed") {
|
||||
onTransferBottomSheet { sellButton.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,20 +94,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
|
|||
waitForIdle()
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
|
||||
}
|
||||
step("Assert 'Receive' button is not dimmed") {
|
||||
onTokenDetailsScreen { receiveButton().assertIsDimmed(false) }
|
||||
step("Assert 'Add funds' button is enabled") {
|
||||
onTokenDetailsScreen { addFundsButton.assertIsEnabled() }
|
||||
}
|
||||
step("Assert 'Buy' button is not dimmed") {
|
||||
onTokenDetailsScreen { buyButton().assertIsDimmed(false) }
|
||||
step("Assert 'Swap' button is disabled") {
|
||||
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
|
||||
}
|
||||
step("Assert 'Send' button is not dimmed") {
|
||||
onTokenDetailsScreen { sendButton().assertIsDimmed(false) }
|
||||
step("Assert 'Transfer' button is enabled") {
|
||||
onTokenDetailsScreen { transferButton.assertIsEnabled() }
|
||||
}
|
||||
step("Assert 'Swap' button is dimmed") {
|
||||
onTokenDetailsScreen { swapButton().assertIsDimmed() }
|
||||
step("Click on 'Add funds' button") {
|
||||
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Sell' button is dimmed") {
|
||||
onTokenDetailsScreen { sellButton().assertIsDimmed() }
|
||||
step("Assert 'Buy' button in bottom sheet is enabled") {
|
||||
onAddFundsBottomSheet { buyButton.assertIsEnabled() }
|
||||
}
|
||||
step("Assert 'Swap' button in bottom sheet is disabled") {
|
||||
onAddFundsBottomSheet { swapButton.assertIsNotEnabled() }
|
||||
}
|
||||
step("Assert 'Receive' button in bottom sheet is enabled") {
|
||||
onAddFundsBottomSheet { receiveButton.assertIsEnabled() }
|
||||
}
|
||||
step("Click on 'Close' button in bottom sheet") {
|
||||
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Send' button in bottom sheet is enabled") {
|
||||
onTransferBottomSheet { sendButton.assertIsEnabled() }
|
||||
}
|
||||
step("Assert 'Swap' button in bottom sheet is disabled") {
|
||||
onTransferBottomSheet { swapButton.assertIsNotEnabled() }
|
||||
}
|
||||
step("Assert 'Sell' button in bottom sheet is disabled") {
|
||||
onTransferBottomSheet { sellButton.assertIsNotEnabled() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,7 +152,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
|
|||
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
|
||||
}
|
||||
step("Click on 'Swap' button") {
|
||||
onTokenDetailsScreen { swapButton().performClick() }
|
||||
onTokenDetailsScreen { swapButton.performClick() }
|
||||
}
|
||||
step("Close 'Stories' screen") {
|
||||
onSwapStoriesScreen { closeButton.clickWithAssertion() }
|
||||
|
|
@ -140,8 +183,11 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
|
|||
waitForIdle()
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
|
||||
}
|
||||
step("Click on 'Receive' button") {
|
||||
onTokenDetailsScreen { receiveButton().performClick() }
|
||||
step("Click on 'Add funds' button") {
|
||||
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Receive' button in bottom sheet") {
|
||||
onAddFundsBottomSheet { receiveButton.clickWithAssertion() }
|
||||
}
|
||||
step("Go to QR code bottom sheet") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT) {
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
package com.tangem.tests.balance
|
||||
|
||||
import androidx.compose.ui.test.longClick
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.scenarios.openMainScreen
|
||||
import com.tangem.scenarios.synchronizeAddresses
|
||||
import com.tangem.screens.onMainScreen
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.qameta.allure.kotlin.AllureId
|
||||
import io.qameta.allure.kotlin.junit4.DisplayName
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class TotalBalanceLongTapTest : BaseTestCase() {
|
||||
|
||||
@Test
|
||||
@AllureId("3965")
|
||||
@DisplayName("Total balance: check long tap on block without biometry")
|
||||
fun whenBiometryIsOffTest() {
|
||||
setupHooks().run {
|
||||
step("Open 'Main Screen'") {
|
||||
openMainScreen()
|
||||
}
|
||||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
step("Long tap on total balance block") {
|
||||
onMainScreen {
|
||||
totalBalanceContainer.performTouchInput {
|
||||
longClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("Assert 'Rename' button is displayed") {
|
||||
onMainScreen { totalBalanceMenuRenameWallet.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Delete' button is not displayed") {
|
||||
onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.tangem.tests.balance
|
|||
import androidx.compose.ui.test.longClick
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.TOTAL_BALANCE
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
|
||||
import com.tangem.common.extensions.*
|
||||
import com.tangem.common.utils.resetWireMockScenarioState
|
||||
import com.tangem.common.utils.setWireMockScenarioState
|
||||
|
|
@ -82,28 +83,27 @@ class TotalBalanceUpdateTest : BaseTestCase() {
|
|||
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
|
||||
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
|
||||
}
|
||||
step("Click on 'Add to portfolio' button") {
|
||||
onMarketsScreen { addToPortfolioButton.clickWithAssertion() }
|
||||
step("Click on 'Add' button in 'Markets' bottom sheet") {
|
||||
onMarketsScreen { addButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on main network") {
|
||||
onMarketsScreen { mainNetworkSuffix.performClick() }
|
||||
}
|
||||
step("Click on 'Add' button") {
|
||||
onDialog { addButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Continue' is not displayed") {
|
||||
onDialog { addButton.assertIsNotDisplayed() }
|
||||
step("Click on 'Add' button in 'Add token' bottom sheet") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT) {
|
||||
onAddTokenBottomSheet {
|
||||
addButton.performClick()
|
||||
}
|
||||
onAddTokenBottomSheet { laterButton.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Click on 'Later' button") {
|
||||
onDialog { laterButton.clickWithAssertion() }
|
||||
onAddTokenBottomSheet { laterButton.performClick() }
|
||||
}
|
||||
step("Go back to 'Markets: tokens list'") {
|
||||
step("Press 'Back' button") {
|
||||
waitForIdle()
|
||||
onMarketsScreen { topBarBackButton.clickWithAssertion() }
|
||||
device.uiDevice.pressBack()
|
||||
}
|
||||
step("Close 'Markets screen'") {
|
||||
onSearchBar { searchField.assertIsDisplayed() }
|
||||
swipeMarketsBlock(SwipeDirection.DOWN)
|
||||
step("Press 'Back' button") {
|
||||
waitForIdle()
|
||||
device.uiDevice.pressBack()
|
||||
}
|
||||
step("Assert $updatedBalance is displayed in total balance") {
|
||||
onMainScreen { totalBalanceText.assertTextContains(updatedBalance) }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.tangem.tests.main
|
|||
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
|
||||
import com.tangem.common.extensions.SwipeDirection
|
||||
import com.tangem.common.extensions.swipeVertical
|
||||
import com.tangem.common.utils.resetWireMockScenarioState
|
||||
import com.tangem.common.utils.setWireMockScenarioState
|
||||
import com.tangem.scenarios.openMainScreen
|
||||
|
|
@ -35,7 +37,7 @@ class MainScreenTest : BaseTestCase() {
|
|||
}
|
||||
|
||||
@AllureId("8748")
|
||||
@DisplayName("Main: check 'Organize tokens' button with single token no accounts")
|
||||
@DisplayName("Main: check 'Add & Manage' button with single token no accounts")
|
||||
@Test
|
||||
fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() {
|
||||
val scenarioState = "Cardano"
|
||||
|
|
@ -56,14 +58,14 @@ class MainScreenTest : BaseTestCase() {
|
|||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
step("Assert 'Add & Manage' button is not displayed") {
|
||||
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()}
|
||||
step("Assert 'Add & Manage' button is displayed") {
|
||||
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("8749")
|
||||
@DisplayName("Main: check 'Organize tokens' button with single token two accounts")
|
||||
@DisplayName("Main: check 'Add & Manage' button with single token two accounts")
|
||||
@Test
|
||||
fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() {
|
||||
val scenarioState = "TwoAccountsSingleTokenEach"
|
||||
|
|
@ -81,14 +83,14 @@ class MainScreenTest : BaseTestCase() {
|
|||
step("Open 'Main Screen'") {
|
||||
openMainScreen()
|
||||
}
|
||||
step("Assert 'Add & Manage' button is not displayed") {
|
||||
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()}
|
||||
step("Assert 'Add & Manage' button is displayed") {
|
||||
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("8750")
|
||||
@DisplayName("Main: check 'Organize tokens' button with multiple tokens two accounts")
|
||||
@DisplayName("Main: check 'Add & Manage' button with multiple tokens two accounts")
|
||||
@Test
|
||||
fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() {
|
||||
val scenarioState = "TwoAccountsMixed"
|
||||
|
|
@ -106,8 +108,11 @@ class MainScreenTest : BaseTestCase() {
|
|||
step("Open 'Main Screen'") {
|
||||
openMainScreen()
|
||||
}
|
||||
step("Swipe up") {
|
||||
swipeVertical(SwipeDirection.UP, startHeightRatio = 0.6f, endHeightRatio = 0.1f)
|
||||
}
|
||||
step("Assert 'Add & Manage' button is displayed") {
|
||||
onMainScreen { addAndManageButtonNode.assertIsDisplayed()}
|
||||
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import org.junit.Test
|
|||
class WarningsTest : BaseTestCase() {
|
||||
|
||||
@AllureId("184")
|
||||
@DisplayName("Token list: hide token by long tap")
|
||||
@DisplayName("Warnings: missing address warning")
|
||||
@Test
|
||||
fun checkUnavailableNetworksWarningTest() {
|
||||
val scenarioState = "MissingDerivation"
|
||||
|
|
@ -38,9 +38,6 @@ class WarningsTest : BaseTestCase() {
|
|||
step("Synchronize addresses") {
|
||||
synchronizeAddresses(isBalanceAvailable = false)
|
||||
}
|
||||
step("Assert 'Missing addresses' notification icon is displayed") {
|
||||
onMainScreen { missingAddressNotificationIcon.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Missing addresses' notification title is displayed") {
|
||||
onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.tests.markets
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.annotations.ApiEnv
|
||||
import com.tangem.common.annotations.ApiEnvConfig
|
||||
|
|
@ -39,6 +40,7 @@ class MarketsExchangesTest : BaseTestCase() {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
@AllureId("56")
|
||||
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
|
||||
|
|
@ -60,9 +62,8 @@ class MarketsExchangesTest : BaseTestCase() {
|
|||
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
|
||||
waitForIdle()
|
||||
}
|
||||
step("Scroll down") {
|
||||
swipeVertical(SwipeDirection.UP)
|
||||
swipeVertical(SwipeDirection.UP)
|
||||
step("Scroll to 'Listed on exchanges' block") {
|
||||
onMarketsScreen { scrollToListedOnBlock() }
|
||||
}
|
||||
step("Assert 'Listed on exchanges' block has title") {
|
||||
onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() }
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ class RecentBlockTest : BaseTestCase() {
|
|||
val sendAmount = "1"
|
||||
val txHistoryScenarioState = "11OutgoingTransactions"
|
||||
val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq"
|
||||
val shortenedRecipientAddress = "DJ2TaZ5vvp3mBLugU...Li4uYaq123456789b"
|
||||
val longRecipientAddress = recipientAddressBase + "123456789b"
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
|
|
@ -261,7 +261,7 @@ class RecentBlockTest : BaseTestCase() {
|
|||
checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1)
|
||||
}
|
||||
step("Check recent address item №2") {
|
||||
checkRecentAddressItem(address = shortenedRecipientAddress, description = recentTransactionAmount2)
|
||||
checkRecentAddressItem(address = longRecipientAddress, description = recentTransactionAmount2)
|
||||
}
|
||||
step("Check recent address item №3") {
|
||||
checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2)
|
||||
|
|
|
|||
|
|
@ -246,8 +246,11 @@ class SendAddressScreenTest : BaseTestCase() {
|
|||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
|
||||
}
|
||||
step("Click on 'Send' button") {
|
||||
onTokenDetailsScreen { sendButton().performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button in bottom sheet") {
|
||||
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
||||
}
|
||||
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
|
||||
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
|
||||
|
|
|
|||
|
|
@ -46,8 +46,11 @@ class SendConfirmScreenTest : BaseTestCase() {
|
|||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button") {
|
||||
onTokenDetailsScreen { sendButton().performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button in bottom sheet") {
|
||||
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
||||
}
|
||||
step("Type '$inputAmount' in input text field") {
|
||||
onSendScreen {
|
||||
|
|
@ -123,8 +126,11 @@ class SendConfirmScreenTest : BaseTestCase() {
|
|||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button") {
|
||||
onTokenDetailsScreen { sendButton().performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onTokenDetailsScreen { transferButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Send' button in bottom sheet") {
|
||||
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
||||
}
|
||||
step("Type '$inputAmount' in input text field") {
|
||||
onSendScreen {
|
||||
|
|
|
|||
|
|
@ -281,13 +281,13 @@ class SendFeeScreenTest : BaseTestCase() {
|
|||
fun checkNetworkFeeBottomSheetForBitcoinTest() {
|
||||
val tokenName = "Bitcoin"
|
||||
val tokenAmount = "0.00000001"
|
||||
val feeAmount = "$2.86"
|
||||
val feeAmount = "$0.48"
|
||||
val fiatFeeAmount = "$0.24"
|
||||
val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market)
|
||||
val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast)
|
||||
val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow)
|
||||
val feeUpTo = getResourceString(R.string.send_max_fee)
|
||||
val feeUpToValue = "0.0000264 BTC"
|
||||
val feeUpToValue = "0.0000044 BTC"
|
||||
val newFeeUpToValue = "0.0000022 BTC"
|
||||
val satoshi = getResourceString(R.string.send_satoshi_per_byte_title)
|
||||
val satoshiValue = "2"
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase
|
|||
import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS
|
||||
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
|
||||
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
import com.tangem.common.utils.resetWireMockScenarioState
|
||||
import com.tangem.common.utils.setWireMockScenarioState
|
||||
import com.tangem.scenarios.checkSendWarning
|
||||
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
|
||||
import com.tangem.scenarios.openSendScreen
|
||||
import com.tangem.screens.onSendAddressScreen
|
||||
import com.tangem.screens.onSendScreen
|
||||
|
|
@ -145,8 +147,10 @@ class KaspaWarningsTest : BaseTestCase() {
|
|||
step("Type address in input text field") {
|
||||
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
|
||||
}
|
||||
step("Click on 'Next' button") {
|
||||
onSendAddressScreen { nextButton.clickWithAssertion() }
|
||||
step("Click 'Next' button until 'Send Confirm' screen opens") {
|
||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
runCatching { openSendConfirmScreenViaNextButton() }.isSuccess
|
||||
}
|
||||
}
|
||||
step("Assert 'UTXO limit warning' is displayed") {
|
||||
checkSendWarning(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ package com.tangem.tests.swap
|
|||
import androidx.compose.ui.test.longClick
|
||||
import androidx.test.InstrumentationRegistry.getTargetContext
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
|
||||
import com.tangem.common.extensions.assertHasBadge
|
||||
import com.tangem.common.extensions.restartApp
|
||||
import com.tangem.common.utils.resetWireMockScenarioState
|
||||
import com.tangem.common.utils.setWireMockScenarioState
|
||||
|
|
@ -18,101 +16,6 @@ import org.junit.Test
|
|||
@HiltAndroidTest
|
||||
class SwapStoriesTest : BaseTestCase() {
|
||||
|
||||
@AllureId("5453")
|
||||
@DisplayName("Check 'Swap' button badge on 'Main' screen")
|
||||
@Test
|
||||
fun checkMainScreenSwapButtonBadgeTest() {
|
||||
|
||||
setupHooks().run {
|
||||
|
||||
step("Open 'Main Screen'") {
|
||||
openMainScreen()
|
||||
}
|
||||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
step("Assert 'Swap' button has badge") {
|
||||
onMainScreen { swapButton.assertHasBadge() }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.MainScreen)
|
||||
}
|
||||
step("Click on 'Close' button") {
|
||||
onSwapTokenScreen { closeButton.performClick() }
|
||||
}
|
||||
step("Assert 'Swap' button has not badge") {
|
||||
onMainScreen { swapButton.assertHasBadge(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5454")
|
||||
@DisplayName("Check 'Swap' button badge on token details screen")
|
||||
@Test
|
||||
fun checkTokenDetailsScreenSwapButtonTest() {
|
||||
val tokenName = "Ethereum"
|
||||
|
||||
setupHooks().run {
|
||||
|
||||
step("Open 'Main Screen'") {
|
||||
openMainScreen()
|
||||
}
|
||||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
|
||||
}
|
||||
step("Assert 'Swap' button has badge") {
|
||||
onTokenDetailsScreen { swapButton().assertHasBadge() }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.TokenDetails)
|
||||
}
|
||||
step("Click on 'Close' button") {
|
||||
onSwapTokenScreen { closeButton.performClick() }
|
||||
}
|
||||
step("Assert 'Swap' button has not badge") {
|
||||
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5455")
|
||||
@DisplayName("Check 'Swap' button badge on token details in 'Market' screen")
|
||||
@Test
|
||||
fun checkMarketTokenDetailsScreenSwapButtonTest() {
|
||||
val tokenName = "Ethereum"
|
||||
val badgeShown = "Badge shown"
|
||||
val badgeHidden = "Badge hidden"
|
||||
|
||||
setupHooks().run {
|
||||
|
||||
step("Open 'Main Screen'") {
|
||||
openMainScreen()
|
||||
}
|
||||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
step("Open 'Markets' token details screen for token '$tokenName'") {
|
||||
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
|
||||
}
|
||||
step("Assert 'Swap' button has badge") {
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails)
|
||||
}
|
||||
step("Click on 'Close' button") {
|
||||
onSwapTokenScreen { closeButton.performClick() }
|
||||
}
|
||||
step("Assert 'Swap' button has not badge") {
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5469")
|
||||
@DisplayName("Check unavailable swap stories on 'Main' screen")
|
||||
@Test
|
||||
|
|
@ -136,9 +39,6 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
step("Assert 'Swap' button has not badge") {
|
||||
onMainScreen { swapButton.assertHasBadge(false) }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false)
|
||||
}
|
||||
|
|
@ -155,9 +55,6 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
waitForIdle()
|
||||
onMainScreen { swapButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Swap' button has badge") {
|
||||
onMainScreen { swapButton.assertHasBadge() }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true)
|
||||
}
|
||||
|
|
@ -192,9 +89,6 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
|
||||
}
|
||||
step("Assert 'Swap' button has not badge") {
|
||||
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
|
||||
}
|
||||
|
|
@ -207,13 +101,6 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
step("Restart app") {
|
||||
restartApp(packageName)
|
||||
}
|
||||
step("Assert 'Swap' button has badge") {
|
||||
waitForIdle()
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT) {
|
||||
composeTestRule.mainClock.advanceTimeBy(500)
|
||||
onMainScreen { swapButton.assertHasBadge() }
|
||||
}
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
|
||||
}
|
||||
|
|
@ -228,8 +115,6 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
val scenarioErrorState = "Error"
|
||||
val packageName = getTargetContext().packageName
|
||||
val tokenName = "Ethereum"
|
||||
val badgeShown = "Badge shown"
|
||||
val badgeHidden = "Badge hidden"
|
||||
|
||||
setupHooks(
|
||||
additionalBeforeAppLaunchSection = {
|
||||
|
|
@ -246,16 +131,15 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
step("Open 'Markets' token details screen for token '$tokenName'") {
|
||||
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
|
||||
step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
|
||||
openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
|
||||
}
|
||||
step("Assert 'Swap' button has not badge") {
|
||||
step("Assert 'Swap' button is displayed") {
|
||||
waitForIdle()
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
|
||||
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false)
|
||||
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
|
||||
}
|
||||
step("Click on 'Close' button") {
|
||||
onSwapTokenScreen { closeButton.performClick() }
|
||||
|
|
@ -266,16 +150,12 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
step("Restart app") {
|
||||
restartApp(packageName)
|
||||
}
|
||||
step("Open 'Markets' token details screen for token '$tokenName'") {
|
||||
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
|
||||
}
|
||||
step("Assert 'Swap' button has badge") {
|
||||
step("Assert 'Swap' button is displayed") {
|
||||
waitForIdle()
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
|
||||
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = true)
|
||||
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -331,11 +211,8 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
step("Click on token with name: '$tokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
|
||||
}
|
||||
step("Assert 'Swap' button has badge") {
|
||||
onTokenDetailsScreen { swapButton().assertHasBadge() }
|
||||
}
|
||||
step("Click on 'Swap' button on 'Token details' screen") {
|
||||
onTokenDetailsScreen { swapButton().performClick() }
|
||||
onTokenDetailsScreen { swapButton.performClick() }
|
||||
}
|
||||
step("Check stories changes") {
|
||||
checkStoriesChanges()
|
||||
|
|
@ -369,11 +246,11 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
step("Open 'Markets' token details screen for token '$tokenName'") {
|
||||
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
|
||||
step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
|
||||
openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
|
||||
}
|
||||
step("Click on 'Swap' button on 'Markets' token details screen") {
|
||||
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }
|
||||
onTokenDetailsScreen { swapButton.performClick() }
|
||||
}
|
||||
step("Check stories changes") {
|
||||
checkStoriesChanges()
|
||||
|
|
@ -388,7 +265,7 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
onSwapTokenScreen { closeButton.performClick() }
|
||||
}
|
||||
step("Open 'Swap' screen without stories") {
|
||||
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false)
|
||||
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -433,6 +310,17 @@ class SwapStoriesTest : BaseTestCase() {
|
|||
step("Click on 'Close' button") {
|
||||
onSwapTokenScreen { closeButton.performClick() }
|
||||
}
|
||||
step("Long click on token with name: '$tokenName' again to reopen actions menu") {
|
||||
waitForIdle()
|
||||
onMainScreen {
|
||||
tokenWithTitleAndAddress(tokenName).performTouchInput {
|
||||
longClick(
|
||||
position = center,
|
||||
durationMillis = 1000L,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
step("Open 'Swap' screen without stories") {
|
||||
openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
onTokenDetailsScreen { title.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Swap' button") {
|
||||
onTokenDetailsScreen { swapButton().performClick() }
|
||||
onTokenDetailsScreen { swapButton.performClick() }
|
||||
}
|
||||
step("Close 'Stories' screen") {
|
||||
onSwapStoriesScreen { closeButton.clickWithAssertion() }
|
||||
|
|
@ -147,7 +147,7 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
disableMobileData()
|
||||
}
|
||||
step("Click on 'Swap' button") {
|
||||
onTokenDetailsScreen { swapButton().performClick() }
|
||||
onTokenDetailsScreen { swapButton.performClick() }
|
||||
}
|
||||
step("Close 'Stories' screen") {
|
||||
onSwapStoriesScreen { closeButton.clickWithAssertion() }
|
||||
|
|
@ -201,7 +201,7 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
onTokenDetailsScreen { title.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Swap' button") {
|
||||
onTokenDetailsScreen { swapButton().performClick() }
|
||||
onTokenDetailsScreen { swapButton.performClick() }
|
||||
}
|
||||
step("Close 'Stories' screen") {
|
||||
onSwapStoriesScreen { closeButton.clickWithAssertion() }
|
||||
|
|
@ -304,7 +304,7 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
onTokenDetailsScreen { title.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Swap' button") {
|
||||
onTokenDetailsScreen { swapButton().performClick() }
|
||||
onTokenDetailsScreen { swapButton.performClick() }
|
||||
}
|
||||
step("Close 'Stories' screen") {
|
||||
onSwapStoriesScreen { closeButton.clickWithAssertion() }
|
||||
|
|
@ -510,7 +510,7 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Swap' button is not dimmed. Swap available") {
|
||||
onTokenDetailsScreen { swapButton().assertIsDimmed(false) }
|
||||
onTokenDetailsScreen { swapButton.assertIsEnabled() }
|
||||
}
|
||||
step("Press 'Back' button") {
|
||||
device.uiDevice.pressBack()
|
||||
|
|
@ -519,7 +519,7 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Swap' button is dimmed") {
|
||||
onTokenDetailsScreen { swapButton().assertIsDimmed(true) }
|
||||
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
|
||||
}
|
||||
step("Press 'Back' button") {
|
||||
device.uiDevice.pressBack()
|
||||
|
|
@ -528,7 +528,7 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Swap' button is dimmed") {
|
||||
onTokenDetailsScreen { swapButton().assertIsDimmed(true) }
|
||||
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,16 @@ internal object YieldSupplyDomainModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWrapYieldSwapCallDataWithUpgradeUseCase(
|
||||
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
|
||||
): WrapYieldSwapCallDataWithUpgradeUseCase {
|
||||
return WrapYieldSwapCallDataWithUpgradeUseCase(
|
||||
yieldSupplyTransactionRepository = yieldSupplyTransactionRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideYieldSupplyGetProtocolBalanceUseCase(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.draw.BlurredEdgeTreatment
|
|||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.innerShadow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
|
|
@ -38,6 +39,7 @@ import com.tangem.core.ui.ds.row.TangemRowLayoutId
|
|||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
|
||||
import com.tangem.core.res.R as CoreResR
|
||||
|
||||
private const val TINTED_BACKGROUND_ALPHA = 0.1f
|
||||
|
|
@ -179,17 +181,23 @@ private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, o
|
|||
}
|
||||
is EarnBlockUM.TrailingUM.Balance -> {
|
||||
if (!trailingUM.isBalanceHidden) {
|
||||
val fiatModifier = Modifier.layoutId(TangemRowLayoutId.END_TOP).let {
|
||||
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_FIAT_AMOUNT) else it
|
||||
}
|
||||
val cryptoModifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM).let {
|
||||
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) else it
|
||||
}
|
||||
Text(
|
||||
text = trailingUM.fiatValue.resolveAnnotatedReference(),
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP),
|
||||
modifier = fiatModifier,
|
||||
)
|
||||
Text(
|
||||
text = trailingUM.cryptoValue.resolveReference(),
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
color = TangemTheme.colors2.text.neutral.secondary,
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM),
|
||||
modifier = cryptoModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.common.ui.R
|
||||
|
|
@ -35,6 +36,7 @@ import com.tangem.core.ui.extensions.resolveReference
|
|||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -78,7 +80,8 @@ private fun ExpressTransactionItem(
|
|||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors2.surface.level3)
|
||||
.clickable(onClick = info.onClick)
|
||||
.padding(TangemTheme.dimens2.x4),
|
||||
.padding(TangemTheme.dimens2.x4)
|
||||
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM),
|
||||
) {
|
||||
TitleRow(
|
||||
title = info.title.resolveReference(),
|
||||
|
|
@ -104,7 +107,9 @@ private fun TitleRow(title: String, infoIconRes: Int?, infoIconTint: Color?) {
|
|||
text = title,
|
||||
style = TangemTheme.typography2.bodyMedium16,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE),
|
||||
)
|
||||
if (infoIconRes != null && infoIconTint != null) {
|
||||
Icon(
|
||||
|
|
@ -126,32 +131,42 @@ private fun AmountsRow(info: ExpressTransactionStateInfoUM) {
|
|||
CurrencyIcon(
|
||||
state = info.fromCurrencyIcon,
|
||||
shouldDisplayNetwork = false,
|
||||
modifier = Modifier.size(TangemTheme.dimens.size18),
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size18)
|
||||
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON),
|
||||
)
|
||||
EllipsisText(
|
||||
text = info.fromAmount.resolveReference(),
|
||||
style = TangemTheme.typography2.bodyMedium16,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
ellipsis = TextEllipsis.OffsetEnd(info.fromAmountSymbol.length),
|
||||
modifier = Modifier.weight(weight = 1f, fill = false),
|
||||
modifier = Modifier
|
||||
.weight(weight = 1f, fill = false)
|
||||
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_AMOUNT),
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_forward_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
modifier = Modifier.size(TangemTheme.dimens.size18),
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size18)
|
||||
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON),
|
||||
)
|
||||
CurrencyIcon(
|
||||
state = info.toCurrencyIcon,
|
||||
shouldDisplayNetwork = false,
|
||||
modifier = Modifier.size(TangemTheme.dimens.size18),
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size18)
|
||||
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON),
|
||||
)
|
||||
EllipsisText(
|
||||
text = info.toAmount.resolveReference(),
|
||||
style = TangemTheme.typography2.bodyMedium16,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
ellipsis = TextEllipsis.OffsetEnd(info.toAmountSymbol.length),
|
||||
modifier = Modifier.weight(weight = 1f, fill = false),
|
||||
modifier = Modifier
|
||||
.weight(weight = 1f, fill = false)
|
||||
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.row.TangemRowContainer
|
||||
|
|
@ -68,7 +70,8 @@ fun TokenActionRow(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
hapticManager = hapticManager,
|
||||
),
|
||||
)
|
||||
.semantics { if (!isEnabled) disabled() },
|
||||
) {
|
||||
LeadingIcon(iconRes = iconRes, accentColor = accentColor)
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@
|
|||
"name": "WALLET_CONNECT_BITCOIN_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1326_YIELD_MODE_SWAP_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "ADDRESS_SYNC_ENABLED",
|
||||
"version": "undefined"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
package com.tangem.datasource.api.auth
|
||||
|
||||
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
|
||||
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
|
||||
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
|
||||
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
|
||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Tangem Auth Service API (JWT session tokens / DPoP interceptor / refresh rotation)
|
||||
*/
|
||||
interface AuthApi {
|
||||
|
||||
/**
|
||||
* Request authentication nonce.
|
||||
*
|
||||
* Generates a nonce bound to the device public key for the authentication flow.
|
||||
*/
|
||||
@POST("api/v1/auth/nonce/auth")
|
||||
suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
|
||||
|
||||
/**
|
||||
* Authenticate device.
|
||||
*
|
||||
* Authenticates a previously registered device using a device-key signature. Issues a new
|
||||
* JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after
|
||||
* registration uses this endpoint.
|
||||
*/
|
||||
@POST("api/v1/auth/authenticate")
|
||||
suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse>
|
||||
|
||||
/**
|
||||
* Refresh tokens.
|
||||
*
|
||||
* Rotates the refresh token and issues a new access token. Uses refresh-token rotation
|
||||
* with family-based reuse detection — replaying a consumed token revokes the entire token
|
||||
* family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`).
|
||||
*/
|
||||
@POST("api/v1/auth/refresh")
|
||||
@RequiresSessionAuth
|
||||
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.datasource.api.auth
|
||||
|
||||
/**
|
||||
* Marks a Retrofit endpoint as requiring an authenticated session (DPoP, see
|
||||
* [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)).
|
||||
*
|
||||
* Read at runtime by the session-auth interceptor: only methods
|
||||
* carrying this annotation receive `Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`
|
||||
* headers; unannotated methods (e.g. public nonce endpoints) pass through unchanged.
|
||||
*
|
||||
* Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the
|
||||
* same on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`.
|
||||
*/
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class RequiresSessionAuth
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.tangem.datasource.api.auth.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/** Authentication request — authenticates a previously registered device. */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AuthApiRequest(
|
||||
/** Signed authentication payload. */
|
||||
@Json(name = "payload") val payload: AuthenticationPayload,
|
||||
/** EC signature over the authentication payload, signed by the device private key (Base64). */
|
||||
@Json(name = "signature") val signature: String,
|
||||
)
|
||||
|
||||
/** Signed authentication payload — the data that is signed by the device private key. */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AuthenticationPayload(
|
||||
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
|
||||
@Json(name = "devicePublicKey") val devicePublicKey: String,
|
||||
/** Deciphered nonce value from the nonce endpoint. */
|
||||
@Json(name = "nonce") val nonce: String,
|
||||
/** Platform attestation token (Play Integrity / App Attest). */
|
||||
@Json(name = "attestationToken") val attestationToken: String?,
|
||||
/** Client-reported device metadata. */
|
||||
@Json(name = "metadata") val metadata: DeviceMetadata,
|
||||
) {
|
||||
|
||||
/** Device metadata collection. */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DeviceMetadata(
|
||||
/** Device hardware model (e.g. `iPhone 15 Pro`). */
|
||||
@Json(name = "deviceModel") val deviceModel: String?,
|
||||
/** Operating system (`android` / `ios`). */
|
||||
@Json(name = "os") val os: String,
|
||||
/** OS version string (e.g. `17.4.1`). */
|
||||
@Json(name = "osVersion") val osVersion: String?,
|
||||
/** Application version (e.g. `5.8.0`). */
|
||||
@Json(name = "appVersion") val appVersion: String?,
|
||||
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
|
||||
@Json(name = "userAgent") val userAgent: String?,
|
||||
/** Client locale (e.g. `en-US`). */
|
||||
@Json(name = "locale") val locale: String?,
|
||||
/** Client timezone (e.g. `Europe/Moscow`). */
|
||||
@Json(name = "timezone") val timezone: String?,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.tangem.datasource.api.auth.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/** Request body for nonce generation (auth, upgrade, wallet flows). */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NonceApiRequest(
|
||||
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
|
||||
@Json(name = "devicePublicKey") val devicePublicKey: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.tangem.datasource.api.auth.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/** Token refresh request. */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RefreshApiRequest(
|
||||
/** Refresh token from a previous token response. */
|
||||
@Json(name = "refreshToken") val refreshToken: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.datasource.api.auth.models.response
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/** Ciphered nonce response. */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NonceApiResponse(
|
||||
/** RSA-OAEP ciphered nonce value (Base64). */
|
||||
@Json(name = "cipheredNonce") val cipheredNonce: String,
|
||||
/** Nonce expiration timestamp (ISO-8601). */
|
||||
@Json(name = "expiresAt") val expiresAt: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.datasource.api.auth.models.response
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with
|
||||
* `Content-Type: application/problem+json` on every 4xx / 5xx response.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ProblemDetailResponse(
|
||||
/** URI identifying the problem type. */
|
||||
@Json(name = "type") val type: String,
|
||||
/** Short human-readable summary (e.g. `"Too Many Requests"`). */
|
||||
@Json(name = "title") val title: String,
|
||||
/** HTTP status code. */
|
||||
@Json(name = "status") val status: Int,
|
||||
/** Human-readable explanation. */
|
||||
@Json(name = "detail") val detail: String?,
|
||||
/** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */
|
||||
@Json(name = "instance") val instance: String?,
|
||||
/** Application-specific error code. */
|
||||
@Json(name = "code") val code: String?,
|
||||
/** Retry delay for rate limiting (`429`). */
|
||||
@Json(name = "retryAfterSeconds") val retryAfterSeconds: Int?,
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.datasource.api.auth.models.response
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/** Token response — contains JWT access token and optional refresh token. */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TokenApiResponse(
|
||||
/** JWT access token (HMAC-SHA256 signed). */
|
||||
@Json(name = "accessToken") val accessToken: String,
|
||||
/** Access token expiration timestamp (ISO-8601). */
|
||||
@Json(name = "accessTokenExpiresAt") val accessTokenExpiresAt: String,
|
||||
/** Refresh token for token rotation. `null` for ORANGE tier (requires device challenge each time). */
|
||||
@Json(name = "refreshToken") val refreshToken: String?,
|
||||
/** Refresh token expiration timestamp (ISO-8601). `null` iff [refreshToken] is `null`. */
|
||||
@Json(name = "refreshTokenExpiresAt") val refreshTokenExpiresAt: String?,
|
||||
/** List of wallet IDs bound to this device. */
|
||||
@Json(name = "walletIds") val walletIds: List<String>,
|
||||
)
|
||||
|
|
@ -33,6 +33,7 @@ sealed class ApiConfig {
|
|||
News,
|
||||
GaslessTxService,
|
||||
SurveySparrow,
|
||||
Auth,
|
||||
}
|
||||
|
||||
private fun initializeId(): ID {
|
||||
|
|
@ -49,6 +50,7 @@ sealed class ApiConfig {
|
|||
is News -> ID.News
|
||||
is GaslessTxService -> ID.GaslessTxService
|
||||
is SurveySparrow -> ID.SurveySparrow
|
||||
is Auth -> ID.Auth
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.tangem.datasource.api.common.config
|
||||
|
||||
import com.tangem.datasource.BuildConfig
|
||||
|
||||
/**
|
||||
* Tangem Auth Service [ApiConfig] — endpoints for device registration, authentication,
|
||||
* nonce issuance, refresh token rotation, and JWKS publication.
|
||||
*/
|
||||
internal class Auth : ApiConfig() {
|
||||
|
||||
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
|
||||
|
||||
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
|
||||
createDevEnvironment(),
|
||||
createMockedEnvironment(),
|
||||
createProdEnvironment(),
|
||||
)
|
||||
|
||||
private fun getInitialEnvironment(): ApiEnvironment {
|
||||
return when (BuildConfig.BUILD_TYPE) {
|
||||
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
|
||||
DEBUG_BUILD_TYPE,
|
||||
INTERNAL_BUILD_TYPE,
|
||||
-> ApiEnvironment.DEV
|
||||
EXTERNAL_BUILD_TYPE,
|
||||
RELEASE_BUILD_TYPE,
|
||||
-> ApiEnvironment.PROD
|
||||
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
|
||||
environment = ApiEnvironment.DEV,
|
||||
baseUrl = DEV_BASE_URL,
|
||||
headers = emptyMap(),
|
||||
)
|
||||
|
||||
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
|
||||
environment = ApiEnvironment.MOCK,
|
||||
baseUrl = MOCK_BASE_URL,
|
||||
headers = emptyMap(),
|
||||
)
|
||||
|
||||
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
|
||||
environment = ApiEnvironment.PROD,
|
||||
baseUrl = PROD_BASE_URL,
|
||||
headers = emptyMap(),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
// TODO Replace with real Auth Service hosts once the backend team confirms deployment.
|
||||
// Swagger currently only declares `http://localhost:8080` for local development.
|
||||
// [REDACTED_JIRA]
|
||||
private const val DEV_BASE_URL = "http://localhost:8080/"
|
||||
private const val MOCK_BASE_URL = "http://localhost:8080/"
|
||||
private const val PROD_BASE_URL = "http://localhost:8080/"
|
||||
}
|
||||
}
|
||||
|
|
@ -113,4 +113,10 @@ internal object ApiConfigsModule {
|
|||
fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig {
|
||||
return SurveySparrow(environmentConfig)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@IntoSet
|
||||
fun provideAuthConfig(): ApiConfig {
|
||||
return Auth()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.datasource.di
|
||||
|
||||
import com.tangem.datasource.BuildConfig
|
||||
import com.tangem.datasource.api.auth.AuthApi
|
||||
import com.tangem.datasource.api.common.blockaid.BlockAidApi
|
||||
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
|
||||
import com.tangem.datasource.api.common.config.ApiConfig
|
||||
|
|
@ -208,6 +209,15 @@ internal object NetworkModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi {
|
||||
return retrofitApiBuilder.build(
|
||||
apiConfigId = ApiConfig.ID.Auth,
|
||||
applyTimeoutAnnotations = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ class ApiConfigTest {
|
|||
appInfoProvider = mockk(),
|
||||
)
|
||||
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
|
||||
ApiConfig.ID.Auth -> Auth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ internal class ProdApiConfigsManagerTest {
|
|||
appInfoProvider = appInfoProvider,
|
||||
)
|
||||
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
|
||||
ApiConfig.ID.Auth -> Auth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -148,9 +149,32 @@ internal class ProdApiConfigsManagerTest {
|
|||
ApiConfig.ID.News -> createNewsModel()
|
||||
ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel()
|
||||
ApiConfig.ID.SurveySparrow -> createSurveySparrowModel()
|
||||
ApiConfig.ID.Auth -> createAuthModel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAuthModel(): TestModel {
|
||||
val environment = when (BuildConfig.BUILD_TYPE) {
|
||||
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
|
||||
DEBUG_BUILD_TYPE,
|
||||
INTERNAL_BUILD_TYPE,
|
||||
-> ApiEnvironment.DEV
|
||||
EXTERNAL_BUILD_TYPE,
|
||||
RELEASE_BUILD_TYPE,
|
||||
-> ApiEnvironment.PROD
|
||||
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
|
||||
}
|
||||
|
||||
return TestModel(
|
||||
id = ApiConfig.ID.Auth,
|
||||
expected = ApiEnvironmentConfig(
|
||||
environment = environment,
|
||||
baseUrl = "http://localhost:8080/",
|
||||
headers = emptyMap(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createExpressModel(): TestModel {
|
||||
val environment = when (BuildConfig.BUILD_TYPE) {
|
||||
DEBUG_BUILD_TYPE,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -39,6 +40,7 @@ import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible
|
|||
import com.tangem.core.ui.res.LocalWindowSize
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.test.BaseBottomSheetTestTags
|
||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||
|
||||
/**
|
||||
|
|
@ -253,7 +255,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicBottomSheet(
|
|||
Column(
|
||||
modifier = contentModifier
|
||||
.background(containerColor)
|
||||
.heightIn(max = maxHeight),
|
||||
.heightIn(max = maxHeight)
|
||||
.testTag(BaseBottomSheetTestTags.CONTAINER),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
title(model)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
|||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -40,6 +41,7 @@ import com.tangem.core.ui.components.sheetscaffold.TangemSheetState
|
|||
import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue
|
||||
import com.tangem.core.ui.components.sheetscaffold.rememberSheetState
|
||||
import com.tangem.core.ui.res.*
|
||||
import com.tangem.core.ui.test.BaseBottomSheetTestTags
|
||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||
|
||||
const val MODAL_SHEET_MAX_HEIGHT = 0.8f
|
||||
|
|
@ -205,7 +207,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> BsContent(
|
|||
.background(containerColor)
|
||||
.heightIn(max = maxHeight.dp)
|
||||
.fillMaxWidth()
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.testTag(BaseBottomSheetTestTags.CONTAINER),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
title(model)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
|
@ -25,6 +28,7 @@ import com.tangem.core.ui.extensions.resolveReference
|
|||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -51,7 +55,10 @@ fun ActionButtons(buttons: ImmutableList<TangemButtonUM>, modifier: Modifier = M
|
|||
TangemTheme.colors2.text.status.disabled
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = TangemTheme.dimens2.x2_5)
|
||||
.testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
.semantics { if (!button.isEnabled) disabled() },
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.test.NotificationTestTags
|
||||
import com.tangem.core.ui.components.notifications.NotificationConfig
|
||||
import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState
|
||||
import com.tangem.core.ui.ds.button.*
|
||||
|
|
@ -66,7 +68,8 @@ fun TangemMessage(
|
|||
Alignment.Top
|
||||
},
|
||||
)
|
||||
.size(messageUM.iconSize),
|
||||
.size(messageUM.iconSize)
|
||||
.testTag(NotificationTestTags.ICON),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -115,14 +118,14 @@ fun TangemMessage(
|
|||
Image(
|
||||
painter = painterResource(config.iconResId),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(config.iconSize),
|
||||
modifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(config.iconResId),
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(config.iconSize),
|
||||
modifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -166,7 +169,7 @@ fun TangemMessage(
|
|||
} else {
|
||||
Alignment.Start
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
Box(modifier = modifier.testTag(NotificationTestTags.CONTAINER)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
|
|
@ -244,6 +247,7 @@ private fun TangemMessageContent(
|
|||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
maxLines = 1,
|
||||
textAlign = textAlign,
|
||||
modifier = Modifier.testTag(NotificationTestTags.TITLE),
|
||||
)
|
||||
}
|
||||
if (subtitle != null) {
|
||||
|
|
@ -252,6 +256,7 @@ private fun TangemMessageContent(
|
|||
text = subtitle.resolveAnnotatedReference(),
|
||||
style = TangemTheme.typography2.captionSemibold12,
|
||||
color = TangemTheme.colors2.text.neutral.secondary,
|
||||
modifier = Modifier.testTag(NotificationTestTags.MESSAGE),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.core.ui.test
|
||||
|
||||
object BaseBottomSheetTestTags {
|
||||
const val CONTAINER = "BASE_BOTTOM_SHEET_CONTAINER"
|
||||
const val ACTION_BUTTON = "BASE_BOTTOM_SHEET_ACTION_BUTTON"
|
||||
const val ACTION_ICON = "BASE_BOTTOM_SHEET_ACTION_ICON"
|
||||
const val TITLE = "BASE_BOTTOM_SHEET_TITLE"
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@ object MarketsTestTags {
|
|||
const val TOKENS_LIST = "MARKETS_TOKENS_LIST"
|
||||
const val TOKENS_LIST_ITEM = "MARKETS_TOKENS_LIST_ITEM"
|
||||
const val LISTED_ON_EXCHANGES_COUNT = "MARKETS_LISTED_ON_EXCHANGES_COUNT"
|
||||
const val LISTED_ON_BLOCK = "MARKETS_LISTED_ON_BLOCK"
|
||||
const val TOKEN_DETAILS_CONTENT = "MARKETS_TOKEN_DETAILS_CONTENT"
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.core.ui.test
|
|||
|
||||
object OrganizeTokensScreenTestTags {
|
||||
// region OrganizeTokensTopBar
|
||||
const val MENU_BUTTON = "ORGANIZE_TOKENS_MENU_BUTTON"
|
||||
const val GROUP_BUTTON = "ORGANIZE_TOKENS_GROUP_BUTTON"
|
||||
const val SORT_BY_BALANCE_BUTTON = "SORT_BY_BALANCE_BUTTON"
|
||||
// endregion OrganizeTokensTopBar
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ dependencies {
|
|||
exclude(module = "joda-time")
|
||||
}
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.configToggles)
|
||||
|
||||
/** Libs */
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import arrow.core.toOption
|
|||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.swap.converter.SwapDataConverter
|
||||
import com.tangem.data.swap.converter.SwapStatusConverter
|
||||
|
|
@ -57,6 +59,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
private val dataSignatureVerifier: DataSignatureVerifier,
|
||||
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
|
||||
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
) : SwapRepositoryV2 {
|
||||
|
||||
|
|
@ -544,16 +547,21 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString()
|
||||
}
|
||||
|
||||
private fun List<ExpressProvider>.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) =
|
||||
filter { provider ->
|
||||
// !!!WARNING!!! Filter out dex provider if yield supply is active
|
||||
val yieldSupplyStatus = cryptoCurrencyStatus?.value?.yieldSupplyStatus
|
||||
if (yieldSupplyStatus != null && yieldSupplyStatus.isActive) {
|
||||
provider.type == ExpressProviderType.CEX
|
||||
} else {
|
||||
true
|
||||
private fun List<ExpressProvider>.filterYieldSupplyProvider(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
): List<ExpressProvider> {
|
||||
return if (featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED)) {
|
||||
this
|
||||
} else {
|
||||
filter { provider ->
|
||||
if (cryptoCurrencyStatus?.value?.yieldSupplyStatus?.isActive == true) {
|
||||
provider.type == ExpressProviderType.CEX
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MEMO_RESTRICTED_NETWORKS = setOf(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
|
|||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
import com.tangem.domain.swap.SwapErrorResolver
|
||||
import com.tangem.domain.swap.SwapRepositoryV2
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.domain.swap.SwapTransactionRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
|
|
@ -49,6 +50,7 @@ internal object SwapDataModule {
|
|||
dataSignatureVerifier: DataSignatureVerifier,
|
||||
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
|
||||
singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
|
||||
featureTogglesManager: FeatureTogglesManager,
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
): SwapRepositoryV2 {
|
||||
return DefaultSwapRepositoryV2(
|
||||
|
|
@ -60,6 +62,7 @@ internal object SwapDataModule {
|
|||
moshi = moshi,
|
||||
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
|
||||
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
|
||||
featureTogglesManager = featureTogglesManager,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import com.tangem.domain.swap.models.SwapAmountType
|
|||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapStatus
|
||||
import com.tangem.domain.swap.models.SwapTxType
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -43,6 +45,9 @@ internal class DefaultSwapRepositoryV2Test {
|
|||
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk()
|
||||
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk()
|
||||
private val moshi: Moshi = Moshi.Builder().build()
|
||||
private val featureTogglesManager: FeatureTogglesManager = mockk {
|
||||
every { isFeatureEnabled(any()) } returns false
|
||||
}
|
||||
|
||||
private val repository = DefaultSwapRepositoryV2(
|
||||
tangemExpressApi = tangemExpressApi,
|
||||
|
|
@ -52,6 +57,7 @@ internal class DefaultSwapRepositoryV2Test {
|
|||
dataSignatureVerifier = dataSignatureVerifier,
|
||||
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
|
||||
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
|
||||
featureTogglesManager = featureTogglesManager,
|
||||
moshi = moshi,
|
||||
)
|
||||
|
||||
|
|
@ -64,7 +70,9 @@ internal class DefaultSwapRepositoryV2Test {
|
|||
dataSignatureVerifier,
|
||||
singleQuoteStatusSupplier,
|
||||
singleQuoteStatusFetcher,
|
||||
featureTogglesManager,
|
||||
)
|
||||
every { featureTogglesManager.isFeatureEnabled(any()) } returns false
|
||||
}
|
||||
|
||||
// region getPairs(SwapCurrencyStatus, SwapCurrencyStatus)
|
||||
|
|
@ -511,8 +519,9 @@ internal class DefaultSwapRepositoryV2Test {
|
|||
// region filterYieldSupplyProvider
|
||||
|
||||
@Test
|
||||
fun `getPairs filters out DEX providers when yield supply is active`() = runTest {
|
||||
fun `getPairs filters out DEX providers when yield supply is active and flag is off`() = runTest {
|
||||
// Arrange
|
||||
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns false
|
||||
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
|
||||
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
|
||||
val primarySwapCurrencyStatus = SwapCurrencyStatus(
|
||||
|
|
@ -558,6 +567,54 @@ internal class DefaultSwapRepositoryV2Test {
|
|||
assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPairs keeps DEX providers when yield supply is active and flag is on`() = runTest {
|
||||
// Arrange
|
||||
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns true
|
||||
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
|
||||
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
|
||||
val primarySwapCurrencyStatus = SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
status = primaryStatus,
|
||||
account = mockk(),
|
||||
)
|
||||
val secondarySwapCurrencyStatus = SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
status = secondaryStatus,
|
||||
account = mockk(),
|
||||
)
|
||||
|
||||
val swapPair = SwapPair(
|
||||
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
|
||||
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
|
||||
providers = listOf(
|
||||
SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
|
||||
SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
|
||||
),
|
||||
)
|
||||
|
||||
coEvery {
|
||||
tangemExpressApi.getPairs(any(), any(), any())
|
||||
} returns ApiResponse.Success(listOf(swapPair))
|
||||
|
||||
coEvery {
|
||||
expressRepository.getProviders(any(), any())
|
||||
} returns listOf(dexProvider, cexProvider)
|
||||
|
||||
// Act
|
||||
val result = repository.getPairs(
|
||||
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
|
||||
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
|
||||
filterProviderTypes = emptyList(),
|
||||
swapTxType = SwapTxType.Swap,
|
||||
)
|
||||
|
||||
// Assert — both providers should remain
|
||||
assertThat(result).hasSize(2)
|
||||
val providers = result.first().providers
|
||||
assertThat(providers).hasSize(2)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region getSwapData
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import com.tangem.datasource.api.common.config.ApiConfig
|
|||
import com.tangem.datasource.api.common.config.ApiEnvironment
|
||||
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
|
||||
import com.tangem.domain.models.account.CardDisplayName
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.SetPinResult
|
||||
import com.tangem.domain.pay.model.TangemPayCardBalance
|
||||
import com.tangem.domain.pay.model.TangemPayCardDetails
|
||||
import com.tangem.domain.pay.model.TangemPayOrderInfo
|
||||
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
package com.tangem.data.yield.supply
|
||||
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
internal class DefaultYieldModuleAddressProvider(
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : YieldModuleAddressProvider {
|
||||
|
||||
private data class Key(val userWalletId: UserWalletId, val networkId: Network.ID)
|
||||
|
||||
private val cache = ConcurrentHashMap<Key, String>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
override suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String? {
|
||||
val key = Key(userWalletId, network.id)
|
||||
cache[key]?.let { return it }
|
||||
return withContext(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
cache[key]?.let { return@withLock it }
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = network.toBlockchain(),
|
||||
derivationPath = network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found for $network")
|
||||
// SDK returns ZERO_ADDRESS on internal failure (e.g. RPC error). Treat that as
|
||||
// "unavailable" so callers are forced by the type system to fall back instead
|
||||
// of using it as a destination.
|
||||
val address = walletManager.getYieldModuleAddress()
|
||||
.takeIf { it != EthereumUtils.ZERO_ADDRESS }
|
||||
if (address != null) cache[key] = address
|
||||
address
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidate(userWalletId: UserWalletId?) {
|
||||
if (userWalletId == null) {
|
||||
cache.clear()
|
||||
} else {
|
||||
cache.keys.removeAll { it.userWalletId == userWalletId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFact
|
|||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||
import com.tangem.domain.utils.convertToSdkAmount
|
||||
|
|
@ -127,6 +128,11 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
val amount = getEnterAmount(cryptoCurrency, yieldSupplyStatus)
|
||||
|
||||
val emptyContractAddress = existingYieldAddress == null || existingYieldAddress == EthereumUtils.ZERO_ADDRESS
|
||||
val activeYieldContractAddress = if (emptyContractAddress) {
|
||||
calculatedYieldContractAddress
|
||||
} else {
|
||||
existingYieldAddress
|
||||
}
|
||||
|
||||
when {
|
||||
yieldSupplyStatus == null || emptyContractAddress -> {
|
||||
|
|
@ -143,7 +149,7 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
createInitTokenTransaction(
|
||||
walletManager = walletManager,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
yieldContractAddress = calculatedYieldContractAddress,
|
||||
yieldContractAddress = activeYieldContractAddress,
|
||||
amount = amount,
|
||||
maxNetworkFee = maxNetworkFee,
|
||||
),
|
||||
|
|
@ -152,7 +158,7 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
createReactivateTokenTransaction(
|
||||
walletManager = walletManager,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
yieldContractAddress = calculatedYieldContractAddress,
|
||||
yieldContractAddress = activeYieldContractAddress,
|
||||
amount = amount,
|
||||
maxNetworkFee = maxNetworkFee,
|
||||
),
|
||||
|
|
@ -166,7 +172,7 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
walletManager = walletManager,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
callData = ApprovalERC20TokenCallData(
|
||||
spenderAddress = calculatedYieldContractAddress,
|
||||
spenderAddress = activeYieldContractAddress,
|
||||
amount = null,
|
||||
),
|
||||
destinationAddress = cryptoCurrency.contractAddress,
|
||||
|
|
@ -182,7 +188,7 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
walletManager = walletManager,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
amount = amount,
|
||||
yieldContractAddress = calculatedYieldContractAddress,
|
||||
yieldContractAddress = activeYieldContractAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -218,6 +224,20 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
|
||||
}
|
||||
|
||||
override suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
callData: SmartContractCallData,
|
||||
): SmartContractCallData = withContext(dispatchers.io) {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = network.toBlockchain(),
|
||||
derivationPath = network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found for $network")
|
||||
val versionStatus = walletManager.checkModuleVersionStatus()
|
||||
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData)
|
||||
}
|
||||
|
||||
private suspend fun getYieldTokenStatus(
|
||||
walletManager: WalletManager,
|
||||
cryptoCurrency: CryptoCurrency.Token,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.yield.supply.di
|
||||
|
||||
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
|
||||
import com.tangem.data.yield.supply.DefaultYieldModuleAddressProvider
|
||||
import com.tangem.data.yield.supply.DefaultYieldSupplyRepository
|
||||
import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver
|
||||
import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository
|
||||
|
|
@ -12,6 +13,7 @@ import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
|
|||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
|
||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
|
||||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
|
||||
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
|
||||
|
|
@ -65,6 +67,18 @@ internal object YieldSupplyDataModule {
|
|||
return DefaultYieldSupplyErrorResolver
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideYieldModuleAddressProvider(
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): YieldModuleAddressProvider {
|
||||
return DefaultYieldModuleAddressProvider(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideYieldPromoRepository(
|
||||
|
|
|
|||
|
|
@ -30,4 +30,6 @@ data class SwapCurrencyStatus(
|
|||
get() = status.currency
|
||||
val userWalletId: UserWalletId
|
||||
get() = userWallet.walletId
|
||||
val isYieldSupplyActive: Boolean
|
||||
get() = status.value.yieldSupplyStatus?.isActive == true
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ class GetEthSpecificFeeUseCase(
|
|||
val minimalFee = getEthLegacyFee(
|
||||
gasPrice = gasPriceResult,
|
||||
gasLimit = gasLimit,
|
||||
decimals = cryptoCurrency.decimals,
|
||||
decimals = blockchain.decimals(),
|
||||
blockchain = blockchain,
|
||||
)
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ class GetEthSpecificFeeUseCase(
|
|||
val normalFee = getEthLegacyFee(
|
||||
gasPrice = normalGasPrice,
|
||||
gasLimit = gasLimit,
|
||||
decimals = cryptoCurrency.decimals,
|
||||
decimals = blockchain.decimals(),
|
||||
blockchain = blockchain,
|
||||
)
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ class GetEthSpecificFeeUseCase(
|
|||
val priorityFee = getEthLegacyFee(
|
||||
gasPrice = priorityGasPrice,
|
||||
gasLimit = gasLimit,
|
||||
decimals = cryptoCurrency.decimals,
|
||||
decimals = blockchain.decimals(),
|
||||
blockchain = blockchain,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.domain.yield.supply
|
||||
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Resolves the yield-module proxy address for a `(wallet, network)` pair and caches the result.
|
||||
*
|
||||
* The address is derived from on-chain state (factory contract + user's wallet) and is stable
|
||||
* for the lifetime of the wallet, so caching the result avoids redundant blockchain calls.
|
||||
*
|
||||
* Call [invalidate] when the wallet's yield-module state may have changed (e.g. after a
|
||||
* successful upgrade or removal of yield-supply).
|
||||
*/
|
||||
interface YieldModuleAddressProvider {
|
||||
|
||||
/**
|
||||
* Returns the yield-module proxy address, or `null` if the address is currently unavailable
|
||||
* (e.g. RPC failure inside the SDK).
|
||||
*/
|
||||
suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String?
|
||||
|
||||
/** Drops cached entries for [userWalletId], or the entire cache when [userWalletId] is `null`. */
|
||||
fun invalidate(userWalletId: UserWalletId? = null)
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
package com.tangem.domain.yield.supply
|
||||
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import java.math.BigDecimal
|
||||
|
||||
|
|
@ -24,4 +26,14 @@ interface YieldSupplyTransactionRepository {
|
|||
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
|
||||
|
||||
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
|
||||
|
||||
/**
|
||||
* Checks the version status of the user's yield-module contract and wraps [callData] with an
|
||||
* upgrade transaction if the deployed version is out of date.
|
||||
*/
|
||||
suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
callData: SmartContractCallData,
|
||||
): SmartContractCallData
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.domain.yield.supply.usecase
|
||||
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
|
||||
|
||||
/**
|
||||
* Wraps a yield-swap call data with a yield-module upgrade transaction when the user's deployed
|
||||
* yield-module contract version is out of date.
|
||||
*/
|
||||
class WrapYieldSwapCallDataWithUpgradeUseCase(
|
||||
private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
callData: SmartContractCallData,
|
||||
): SmartContractCallData = yieldSupplyTransactionRepository.wrapYieldSwapCallDataWithUpgradeIfNeeded(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
callData = callData,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.tangem.features.approval.api
|
||||
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
|
||||
/**
|
||||
* Entry component that wraps the two approval-flow variants:
|
||||
*
|
||||
* - [Mode.FullApproval] — original [GiveApprovalComponent] which renders the approval-type
|
||||
* selector together with the fee selector and submits the approval transaction.
|
||||
* - [Mode.SelectOnly] — [SelectApprovalTypeComponent] which only collects the approval-type
|
||||
* choice and returns it to the caller via its own [SelectApprovalTypeComponent.Callback].
|
||||
*
|
||||
* Callers depend only on this single factory and pass the appropriate [Mode]; the entry
|
||||
* component internally creates the corresponding child and delegates the bottom sheet
|
||||
* rendering and dismissal to it.
|
||||
*/
|
||||
interface GiveApprovalEntryComponent : ComposableBottomSheetComponent {
|
||||
|
||||
data class Params(
|
||||
val mode: Mode,
|
||||
)
|
||||
|
||||
sealed interface Mode {
|
||||
|
||||
/** Full flow: approval-type selector + fee selector + transaction submission. */
|
||||
data class FullApproval(
|
||||
val params: GiveApprovalComponent.Params,
|
||||
) : Mode
|
||||
|
||||
/** Selection-only flow: returns the chosen approval type without sending anything. */
|
||||
data class SelectOnly(
|
||||
val params: SelectApprovalTypeComponent.Params,
|
||||
) : Mode
|
||||
}
|
||||
|
||||
interface Factory {
|
||||
fun create(context: AppComponentContext, params: Params): GiveApprovalEntryComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.features.approval.api
|
||||
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Selection-only variant of [GiveApprovalComponent].
|
||||
*
|
||||
* Shows the same approval-type selector UI (LIMITED vs UNLIMITED) but does NOT submit the
|
||||
* approval transaction. Instead, the chosen [ApproveType] is returned to the caller via
|
||||
* [Callback.onApproveTypeSelected] when the user confirms. The caller is responsible for any
|
||||
* downstream action (e.g. building the transaction, sending it, navigation).
|
||||
*
|
||||
* Intended for flows where the approval-type choice has to be collected separately from the
|
||||
* actual fee selection / transaction submission step.
|
||||
*/
|
||||
interface SelectApprovalTypeComponent : ComposableBottomSheetComponent {
|
||||
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
val amountFooter: TextReference,
|
||||
val initialApproveType: ApproveType = ApproveType.LIMITED,
|
||||
val callback: Callback,
|
||||
)
|
||||
|
||||
interface Callback {
|
||||
fun onApproveTypeSelected(approveType: ApproveType)
|
||||
fun onCancelClick()
|
||||
}
|
||||
|
||||
interface Factory {
|
||||
fun create(context: AppComponentContext, params: Params): SelectApprovalTypeComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.tangem.features.approval.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
||||
import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
/**
|
||||
* Default implementation of [GiveApprovalEntryComponent].
|
||||
*
|
||||
* Picks the concrete child component (full [GiveApprovalComponent] or selection-only
|
||||
* [SelectApprovalTypeComponent]) at construction time based on
|
||||
* [GiveApprovalEntryComponent.Params.mode] and delegates [BottomSheet] and [dismiss] to it.
|
||||
*
|
||||
* Callers only need to depend on [GiveApprovalEntryComponent.Factory] regardless of the
|
||||
* underlying mode.
|
||||
*/
|
||||
internal class DefaultGiveApprovalEntryComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: GiveApprovalEntryComponent.Params,
|
||||
giveApprovalComponentFactory: GiveApprovalComponent.Factory,
|
||||
selectApprovalTypeComponentFactory: SelectApprovalTypeComponent.Factory,
|
||||
) : GiveApprovalEntryComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val delegate: ComposableBottomSheetComponent = when (val mode = params.mode) {
|
||||
is GiveApprovalEntryComponent.Mode.FullApproval -> giveApprovalComponentFactory.create(
|
||||
context = child("giveApprovalEntry_full"),
|
||||
params = mode.params,
|
||||
)
|
||||
is GiveApprovalEntryComponent.Mode.SelectOnly -> selectApprovalTypeComponentFactory.create(
|
||||
context = child("giveApprovalEntry_select"),
|
||||
params = mode.params,
|
||||
)
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
delegate.dismiss()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
delegate.BottomSheet()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : GiveApprovalEntryComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: GiveApprovalEntryComponent.Params,
|
||||
): DefaultGiveApprovalEntryComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package com.tangem.features.approval.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
|
||||
import com.tangem.features.approval.impl.ui.SelectApprovalTypeContent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
/**
|
||||
* Default implementation of [SelectApprovalTypeComponent].
|
||||
*
|
||||
* Renders the same selection UI as the full [com.tangem.features.approval.api.GiveApprovalComponent]
|
||||
* but without the fee selector block and without dispatching the on-chain approval transaction.
|
||||
* Dismissing the bottom sheet (close button or external dismiss) is treated as a cancel.
|
||||
*/
|
||||
internal class DefaultSelectApprovalTypeComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: SelectApprovalTypeComponent.Params,
|
||||
) : SelectApprovalTypeComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: SelectApprovalTypeModel = getOrCreateModel(params = params)
|
||||
|
||||
private val currency: String = params.cryptoCurrencyStatus.currency.symbol
|
||||
|
||||
override fun dismiss() {
|
||||
params.callback.onCancelClick()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
val uiState by model.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val config = remember {
|
||||
TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = ::dismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
)
|
||||
}
|
||||
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
config = config,
|
||||
containerColor = TangemTheme.colors.background.tertiary,
|
||||
titleText = resourceReference(R.string.give_permission_title),
|
||||
titleAction = TopAppBarButtonUM.Icon(
|
||||
iconRes = R.drawable.ic_close_new_20,
|
||||
onClicked = model::onCancelClick,
|
||||
),
|
||||
) {
|
||||
SelectApprovalTypeContent(
|
||||
currency = currency,
|
||||
uiState = uiState,
|
||||
onChangeApproveType = model::onChangeApproveType,
|
||||
onConfirmClick = model::onConfirmClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SelectApprovalTypeComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: SelectApprovalTypeComponent.Params,
|
||||
): DefaultSelectApprovalTypeComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,15 @@ package com.tangem.features.approval.impl.di
|
|||
import com.tangem.core.decompose.di.ModelComponent
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
|
||||
import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||
import com.tangem.features.approval.impl.DefaultGiveApprovalComponent
|
||||
import com.tangem.features.approval.impl.DefaultGiveApprovalEntryComponent
|
||||
import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles
|
||||
import com.tangem.features.approval.impl.DefaultSelectApprovalTypeComponent
|
||||
import com.tangem.features.approval.impl.model.GiveApprovalModel
|
||||
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -26,6 +31,18 @@ internal interface GiveApprovalFeatureModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSelectApprovalTypeComponentFactory(
|
||||
factory: DefaultSelectApprovalTypeComponent.Factory,
|
||||
): SelectApprovalTypeComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindGiveApprovalEntryComponentFactory(
|
||||
factory: DefaultGiveApprovalEntryComponent.Factory,
|
||||
): GiveApprovalEntryComponent.Factory
|
||||
}
|
||||
|
||||
@Module
|
||||
|
|
@ -36,4 +53,9 @@ internal interface GiveApprovalModelModule {
|
|||
@IntoMap
|
||||
@ClassKey(GiveApprovalModel::class)
|
||||
fun bindModel(model: GiveApprovalModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(SelectApprovalTypeModel::class)
|
||||
fun bindSelectApprovalTypeModel(model: SelectApprovalTypeModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.tangem.features.approval.impl.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Model for [SelectApprovalTypeComponent].
|
||||
*
|
||||
* Keeps the currently selected [ApproveType] and exposes intents to change it, open the
|
||||
* learn-more URL, confirm the selection, and cancel. Unlike [GiveApprovalModel] this model
|
||||
* does NOT load fees or submit any transaction — confirmation simply notifies the caller
|
||||
* via the params callback with the selected [ApproveType].
|
||||
*/
|
||||
@Stable
|
||||
@ModelScoped
|
||||
internal class SelectApprovalTypeModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
private val params: SelectApprovalTypeComponent.Params = paramsContainer.require()
|
||||
|
||||
val uiState: StateFlow<SelectApprovalTypeUM>
|
||||
field = MutableStateFlow(
|
||||
SelectApprovalTypeUM(
|
||||
approveType = params.initialApproveType,
|
||||
subtitle = params.amountFooter,
|
||||
),
|
||||
)
|
||||
|
||||
fun onChangeApproveType(approveType: ApproveType) {
|
||||
if (uiState.value.approveType == approveType) return
|
||||
uiState.update { it.copy(approveType = approveType) }
|
||||
}
|
||||
|
||||
fun onConfirmClick() {
|
||||
params.callback.onApproveTypeSelected(uiState.value.approveType)
|
||||
}
|
||||
|
||||
fun onCancelClick() {
|
||||
params.callback.onCancelClick()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.features.approval.impl.model
|
||||
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal data class SelectApprovalTypeUM(
|
||||
val subtitle: TextReference,
|
||||
val approveType: ApproveType,
|
||||
val approveItems: ImmutableList<ApproveType> = ApproveType.entries.toImmutableList(),
|
||||
)
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package com.tangem.features.approval.impl.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.compose.material3.Text as M3Text
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerWMax
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Reusable row that shows "Amount for {currency}" on the left and the currently selected
|
||||
* [ApproveType] on the right, with a dropdown to switch between the available types.
|
||||
*
|
||||
* Used by both [GiveApprovalContent] (full approval flow) and [SelectApprovalTypeContent]
|
||||
* (selection-only flow).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ApprovalTypeSelectorRow(
|
||||
currency: String,
|
||||
approveType: ApproveType,
|
||||
approveItems: ImmutableList<ApproveType>,
|
||||
onChangeApproveType: (ApproveType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isExpandSelector by remember { mutableStateOf(false) }
|
||||
var amountSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(),
|
||||
onClick = { isExpandSelector = true },
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { amountSize = it }
|
||||
.padding(vertical = 12.dp, horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
M3Text(
|
||||
text = stringResourceSafe(id = R.string.give_permission_rows_amount, currency),
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
maxLines = 1,
|
||||
)
|
||||
SpacerWMax()
|
||||
M3Text(
|
||||
text = approveType.text.resolveReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body1,
|
||||
maxLines = 1,
|
||||
)
|
||||
Icon(
|
||||
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
|
||||
)
|
||||
}
|
||||
ApprovalTypeDropdown(
|
||||
isExpanded = isExpandSelector,
|
||||
onDismiss = { isExpandSelector = false },
|
||||
onItemClick = { type ->
|
||||
isExpandSelector = false
|
||||
onChangeApproveType(type)
|
||||
},
|
||||
items = approveItems,
|
||||
selectedType = approveType,
|
||||
amountSize = amountSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun ApprovalTypeDropdown(
|
||||
isExpanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onItemClick: (ApproveType) -> Unit,
|
||||
items: ImmutableList<ApproveType>,
|
||||
selectedType: ApproveType,
|
||||
amountSize: IntSize,
|
||||
) {
|
||||
var dropDownWidth by remember { mutableStateOf(IntSize.Zero) }
|
||||
val offsetY = amountSize.height.times(-1)
|
||||
val offsetX = amountSize.width - dropDownWidth.width
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(surface = TangemTheme.colors.background.action),
|
||||
shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(TangemTheme.dimens.radius16)),
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(clippingEnabled = false),
|
||||
offset = with(LocalDensity.current) {
|
||||
DpOffset(x = offsetX.toDp(), y = offsetY.toDp())
|
||||
},
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.onSizeChanged { dropDownWidth = it },
|
||||
) {
|
||||
items.forEach { item ->
|
||||
val color = if (item == selectedType) TangemTheme.colors.icon.accent else Color.Transparent
|
||||
|
||||
DropdownMenuItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = {
|
||||
Row {
|
||||
M3Text(
|
||||
text = when (item) {
|
||||
ApproveType.LIMITED -> stringResourceSafe(
|
||||
id = R.string.give_permission_current_transaction,
|
||||
)
|
||||
ApproveType.UNLIMITED -> stringResourceSafe(
|
||||
id = R.string.give_permission_unlimited,
|
||||
)
|
||||
},
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.body1,
|
||||
maxLines = 1,
|
||||
)
|
||||
SpacerWMax()
|
||||
Icon(
|
||||
painter = rememberVectorPainter(
|
||||
image = ImageVector.vectorResource(id = R.drawable.ic_check_24),
|
||||
),
|
||||
tint = color,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.size20),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onItemClick.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package com.tangem.features.approval.impl.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.SpacerH16
|
||||
import com.tangem.core.ui.components.SpacerH18
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.features.approval.impl.model.SelectApprovalTypeUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* UI for the selection-only approval variant. Reuses [ApprovalTypeSelectorRow] for the
|
||||
* approval-type picker. The primary button calls [onConfirmClick] which is wired to a
|
||||
* callback that returns the chosen [ApproveType]
|
||||
* to the caller (instead of submitting an on-chain transaction).
|
||||
*/
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
internal fun SelectApprovalTypeContent(
|
||||
currency: String,
|
||||
uiState: SelectApprovalTypeUM,
|
||||
onChangeApproveType: (ApproveType) -> Unit,
|
||||
onConfirmClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = uiState.subtitle.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
style = TangemTheme.typography.body2,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(
|
||||
top = 2.dp,
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
),
|
||||
)
|
||||
|
||||
SpacerH18()
|
||||
|
||||
ApprovalTypeSelectorRow(
|
||||
currency = currency,
|
||||
approveType = uiState.approveType,
|
||||
approveItems = uiState.approveItems,
|
||||
onChangeApproveType = onChangeApproveType,
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
|
||||
)
|
||||
|
||||
SpacerH(height = TangemTheme.dimens.spacing20)
|
||||
|
||||
PrimaryButton(
|
||||
text = stringResourceSafe(id = R.string.common_continue),
|
||||
onClick = onConfirmClick,
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = TangemTheme.dimens.spacing16),
|
||||
)
|
||||
|
||||
SpacerH16()
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun SelectApprovalTypeContentPreview(
|
||||
@PreviewParameter(SelectApprovalTypeContentPreviewProvider::class) params: SelectApprovalTypePreviewParams,
|
||||
) {
|
||||
TangemThemePreview {
|
||||
SelectApprovalTypeContent(
|
||||
currency = params.currency,
|
||||
uiState = params.uiState,
|
||||
onChangeApproveType = {},
|
||||
onConfirmClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class SelectApprovalTypePreviewParams(
|
||||
val currency: String,
|
||||
val uiState: SelectApprovalTypeUM,
|
||||
)
|
||||
|
||||
private class SelectApprovalTypeContentPreviewProvider : PreviewParameterProvider<SelectApprovalTypePreviewParams> {
|
||||
override val values: Sequence<SelectApprovalTypePreviewParams>
|
||||
get() = sequenceOf(
|
||||
SelectApprovalTypePreviewParams(
|
||||
currency = "USDT",
|
||||
uiState = SelectApprovalTypeUM(
|
||||
subtitle = combinedReference(
|
||||
resourceReference(
|
||||
id = R.string.give_permission_swap_subtitle_v2,
|
||||
// Arg is only used in iOS
|
||||
formatArgs = wrappedList(""),
|
||||
),
|
||||
styledResourceReference(
|
||||
id = R.string.common_learn_more,
|
||||
spanStyleReference = {
|
||||
TangemTheme.typography.caption2
|
||||
.copy(color = TangemTheme.colors.text.accent)
|
||||
.toSpanStyle()
|
||||
},
|
||||
onClick = { },
|
||||
),
|
||||
),
|
||||
approveType = ApproveType.LIMITED,
|
||||
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
|
||||
),
|
||||
),
|
||||
SelectApprovalTypePreviewParams(
|
||||
currency = "USDC",
|
||||
uiState = SelectApprovalTypeUM(
|
||||
subtitle = combinedReference(
|
||||
resourceReference(
|
||||
id = com.tangem.common.ui.R.string.give_permission_swap_subtitle_v2,
|
||||
// Arg is only used in iOS
|
||||
formatArgs = wrappedList(""),
|
||||
),
|
||||
styledResourceReference(
|
||||
id = com.tangem.common.ui.R.string.common_learn_more,
|
||||
spanStyleReference = {
|
||||
TangemTheme.typography.caption2
|
||||
.copy(color = TangemTheme.colors.text.accent)
|
||||
.toSpanStyle()
|
||||
},
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
approveType = ApproveType.UNLIMITED,
|
||||
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -17,6 +17,8 @@ import androidx.compose.ui.draw.drawBehind
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.tangem.core.ui.test.MarketsTestTags
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
|
@ -116,6 +118,7 @@ private fun Content(
|
|||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()),
|
||||
modifier = Modifier.testTag(MarketsTestTags.TOKEN_DETAILS_CONTENT),
|
||||
) {
|
||||
item("header") {
|
||||
Header(state = state)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
@ -48,6 +49,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBar
|
|||
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||
import com.tangem.core.ui.test.TokenElementsTestTags
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
|
@ -314,13 +316,15 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
|
|||
tangemIconUM = exchangeItemUM.icon,
|
||||
modifier = Modifier
|
||||
.layoutId(layoutId = TangemRowLayoutId.HEAD)
|
||||
.size(TangemTheme.dimens2.x10),
|
||||
.size(TangemTheme.dimens2.x10)
|
||||
.testTag(TokenElementsTestTags.TOKEN_ICON),
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = TangemTheme.dimens2.x2)
|
||||
.layoutId(TangemRowLayoutId.START_TOP),
|
||||
.layoutId(TangemRowLayoutId.START_TOP)
|
||||
.testTag(TokenElementsTestTags.TOKEN_TITLE),
|
||||
text = exchangeItemUM.title.resolveReference(),
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
|
|
@ -329,7 +333,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
|
|||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = TangemTheme.dimens2.x2)
|
||||
.layoutId(TangemRowLayoutId.START_BOTTOM),
|
||||
.layoutId(TangemRowLayoutId.START_BOTTOM)
|
||||
.testTag(TokenElementsTestTags.TOKEN_PRICE),
|
||||
text = exchangeItemUM.subTitle.resolveReference(),
|
||||
style = TangemTheme.typography2.captionSemibold12,
|
||||
color = TangemTheme.colors2.text.neutral.secondary,
|
||||
|
|
@ -338,7 +343,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
|
|||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = TangemTheme.dimens2.x2)
|
||||
.layoutId(TangemRowLayoutId.END_TOP),
|
||||
.layoutId(TangemRowLayoutId.END_TOP)
|
||||
.testTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT),
|
||||
text = exchangeItemUM.volumeInUsd.resolveReference(),
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
|
|
@ -351,7 +357,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
|
|||
shape = CircleShape,
|
||||
)
|
||||
.padding(vertical = 2.dp, horizontal = 6.dp)
|
||||
.layoutId(TangemRowLayoutId.END_BOTTOM),
|
||||
.layoutId(TangemRowLayoutId.END_BOTTOM)
|
||||
.testTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT),
|
||||
text = exchangeItemUM.auditLabel.text.resolveReference(),
|
||||
style = TangemTheme.typography2.captionSemibold11,
|
||||
color = getColorByTrustValue(exchangeItemUM.auditLabel.type),
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
|
|
@ -65,7 +64,8 @@ private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) {
|
|||
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
|
||||
.clickable(enabled = state is ListedOnUM.Content) {
|
||||
(state as? ListedOnUM.Content)?.onClick?.invoke()
|
||||
},
|
||||
}
|
||||
.testTag(MarketsTestTags.LISTED_ON_BLOCK),
|
||||
) {
|
||||
Description(
|
||||
state = state,
|
||||
|
|
@ -89,9 +89,11 @@ private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) {
|
|||
@Composable
|
||||
private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) {
|
||||
TokenMarketInformationBlock(
|
||||
modifier = modifier.clickable(enabled = state is ListedOnUM.Content) {
|
||||
(state as? ListedOnUM.Content)?.onClick?.invoke()
|
||||
},
|
||||
modifier = modifier
|
||||
.clickable(enabled = state is ListedOnUM.Content) {
|
||||
(state as? ListedOnUM.Content)?.onClick?.invoke()
|
||||
}
|
||||
.testTag(MarketsTestTags.LISTED_ON_BLOCK),
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) {
|
||||
|
|
@ -103,6 +105,7 @@ private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) {
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.testTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT),
|
||||
text = state.description.resolveReference(),
|
||||
style = TangemTheme.typography2.headingSemibold20,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
|
|
@ -178,7 +181,7 @@ internal fun ListedOnBlockPlaceholderV2(modifier: Modifier = Modifier) {
|
|||
private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = state.description.resolveReference(),
|
||||
modifier = modifier.semantics { testTag = MarketsTestTags.LISTED_ON_EXCHANGES_COUNT },
|
||||
modifier = modifier.testTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.features.swap
|
||||
|
||||
interface SwapFeatureToggles {
|
||||
val isYieldSwapEnabled: Boolean
|
||||
val isSwapSwitchToTransferEnabled: Boolean
|
||||
val isSwapIntegratedApproveEnabled: Boolean
|
||||
val isSwapAbEnabled: Boolean
|
||||
|
|
|
|||
|
|
@ -51,8 +51,10 @@ dependencies {
|
|||
implementation(projects.domain.visa)
|
||||
implementation(projects.domain.visa.models)
|
||||
implementation(projects.domain.balanceHiding)
|
||||
implementation(projects.domain.yieldSupply)
|
||||
|
||||
/** Core modules */
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.datasource)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import com.tangem.domain.transaction.usecase.*
|
|||
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
|
||||
import com.tangem.domain.utils.convertToSdkAmount
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
|
||||
import com.tangem.feature.swap.domain.api.SwapRepository
|
||||
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
|
||||
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
|
||||
|
|
@ -61,6 +62,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
|
|||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
|
@ -99,12 +101,17 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
private val getSwapPairUseCase: GetSwapPairUseCase,
|
||||
private val dexSwapFeeCalculator: DexSwapFeeCalculator,
|
||||
private val cexSwapFeeCalculator: CexSwapFeeCalculator,
|
||||
private val swapFeatureToggles: SwapFeatureToggles,
|
||||
private val yieldModuleAddressProvider: YieldModuleAddressProvider,
|
||||
) : SwapInteractor {
|
||||
|
||||
private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) {
|
||||
GetSelectedAppCurrencyUseCase(appCurrencyRepository)
|
||||
}
|
||||
|
||||
private val SwapCurrencyStatus.isYieldSwapActive: Boolean
|
||||
get() = swapFeatureToggles.isYieldSwapEnabled && isYieldSupplyActive
|
||||
|
||||
override suspend fun getPair(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -254,7 +261,9 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
reduceBalanceBy: BigDecimal,
|
||||
expressOperationType: ExpressOperationType,
|
||||
): Pair<SwapProvider, SwapState> {
|
||||
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) {
|
||||
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
|
||||
!swapFeatureToggles.isYieldSwapEnabled
|
||||
) {
|
||||
return provider to produceDexSwapDataError(
|
||||
error = ExpressDataError.DexActiveSupplyError(),
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
|
|
@ -286,19 +295,26 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
|
||||
val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency)
|
||||
val isAllowedToSpend = maybeQuotes.fold(
|
||||
ifRight = { quotes ->
|
||||
quotes.allowanceContract?.let { allowanceContract ->
|
||||
getAllowanceInfoUseCase(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
cryptoCurrency = fromSwapCurrencyStatus.currency,
|
||||
spenderAddress = allowanceContract,
|
||||
requiredAmount = amount.value,
|
||||
).getOrNull() is AllowanceInfo.Enough
|
||||
} != false
|
||||
},
|
||||
ifLeft = { false },
|
||||
)
|
||||
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive &&
|
||||
fromSwapCurrencyStatus.currency is CryptoCurrency.Token
|
||||
val isAllowedToSpend = if (isYieldSwap) {
|
||||
maybeQuotes.isRight() &&
|
||||
fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isAllowedToSpend == true
|
||||
} else {
|
||||
maybeQuotes.fold(
|
||||
ifRight = { quotes ->
|
||||
quotes.allowanceContract?.let { allowanceContract ->
|
||||
getAllowanceInfoUseCase(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
cryptoCurrency = fromSwapCurrencyStatus.currency,
|
||||
spenderAddress = allowanceContract,
|
||||
requiredAmount = amount.value,
|
||||
).getOrNull() is AllowanceInfo.Enough
|
||||
} != false
|
||||
},
|
||||
ifLeft = { false },
|
||||
)
|
||||
}
|
||||
|
||||
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
|
||||
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
|
||||
|
|
@ -308,6 +324,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null)
|
||||
val quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract
|
||||
return if (isAllowedToSpend && isBalanceWithoutFeeEnough) {
|
||||
provider to loadDexSwapDataNoFee(
|
||||
provider = provider,
|
||||
|
|
@ -315,6 +332,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
expressOperationType = expressOperationType,
|
||||
quoteAllowanceContract = quoteAllowanceContract,
|
||||
)
|
||||
} else {
|
||||
val quoteBalanceStatus = if (isBalanceWithoutFeeEnough) {
|
||||
|
|
@ -377,6 +395,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
expressOperationType = expressOperationType,
|
||||
quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract,
|
||||
)
|
||||
} else {
|
||||
provider to getQuotesState(
|
||||
|
|
@ -635,28 +654,54 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
swapFee: SwapFee,
|
||||
): SwapTransactionState {
|
||||
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
|
||||
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
|
||||
val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
|
||||
val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX
|
||||
val dataToSign = dexTransaction.txData
|
||||
val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network)
|
||||
val txData = createTransactionUseCase(
|
||||
amount = amountToSend,
|
||||
fee = swapFee.fee,
|
||||
memo = null,
|
||||
destination = swapData.transaction.txTo,
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
network = toSwapCurrencyStatus.currency.network,
|
||||
txExtras = createDexTxExtras(
|
||||
dataToSign,
|
||||
fromSwapCurrencyStatus.currency.network,
|
||||
swapFee.fee.getGasLimit(),
|
||||
),
|
||||
).getOrElse { error ->
|
||||
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive
|
||||
val fromCurrency = fromSwapCurrencyStatus.currency
|
||||
|
||||
val txDataResult = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
|
||||
val spenderAddress = dexTransaction.allowanceContract
|
||||
?: return SwapTransactionState.Error.UnknownError
|
||||
createYieldSwapDexTransaction(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
swapData = swapData,
|
||||
dexCallData = dataToSign,
|
||||
amount = amountDecimal,
|
||||
fee = swapFee.fee,
|
||||
spenderAddress = spenderAddress,
|
||||
)
|
||||
} else {
|
||||
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
|
||||
val amountToSend = createNativeAmountForDex(txValue, fromCurrency.network)
|
||||
createTransactionUseCase(
|
||||
amount = amountToSend,
|
||||
fee = swapFee.fee,
|
||||
memo = null,
|
||||
destination = swapData.transaction.txTo,
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
network = fromCurrency.network,
|
||||
txExtras = createDexTxExtras(
|
||||
dataToSign,
|
||||
fromCurrency.network,
|
||||
swapFee.fee.getGasLimit(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val txData = txDataResult.getOrElse { error ->
|
||||
TangemLogger.e("Failed to create swap dex tx data", error)
|
||||
return SwapTransactionState.Error.UnknownError
|
||||
}
|
||||
|
||||
val payInAddress = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
|
||||
swapData.transaction.txTo
|
||||
} else if (txData is TransactionData.Uncompiled) {
|
||||
getPayoutAddress(txData)
|
||||
} else {
|
||||
swapData.transaction.txTo
|
||||
}
|
||||
|
||||
return handleSwapResult(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
|
|
@ -664,7 +709,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
swapData = swapData,
|
||||
amount = amount,
|
||||
txData = txData,
|
||||
payInAddress = getPayoutAddress(txData),
|
||||
payInAddress = payInAddress,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1001,11 +1046,23 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
val transaction = swapData?.transaction as? ExpressTransactionModel.DEX
|
||||
?: return GetFeeError.UnknownError.left()
|
||||
|
||||
return dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = selectedFeeToken,
|
||||
).fold(
|
||||
val dexFeeResultEither = if (fromStatus.isYieldSwapActive && fromStatus.currency is CryptoCurrency.Token) {
|
||||
val network = (fromStatus.currency as CryptoCurrency.Token).network
|
||||
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromStatus.userWalletId, network)
|
||||
dexSwapFeeCalculator.calculateYield(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
yieldModuleAddress = yieldModuleAddress,
|
||||
)
|
||||
} else {
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = selectedFeeToken,
|
||||
)
|
||||
}
|
||||
|
||||
return dexFeeResultEither.fold(
|
||||
ifLeft = { error -> GetFeeError.DataError(error).left() },
|
||||
ifRight = { dexFeeResult ->
|
||||
val feeToken = selectedFeeToken
|
||||
|
|
@ -1021,6 +1078,42 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun createYieldSwapDexTransaction(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
swapData: SwapDataModel,
|
||||
dexCallData: String,
|
||||
amount: BigDecimal,
|
||||
fee: Fee,
|
||||
spenderAddress: String,
|
||||
): Either<Throwable, TransactionData> {
|
||||
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
|
||||
val network = fromCurrency.network
|
||||
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, network)
|
||||
?: return Either.Left(IllegalStateException("Yield module address is not available for ${network.id}"))
|
||||
val wrappedCallData = dexSwapFeeCalculator.buildYieldSwapCallData(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
txTo = swapData.transaction.txTo,
|
||||
dexCallData = dexCallData,
|
||||
amount = amount,
|
||||
spenderAddress = spenderAddress,
|
||||
)
|
||||
val txExtras = createTransactionExtrasUseCase(
|
||||
callData = wrappedCallData,
|
||||
network = network,
|
||||
gasLimit = fee.getGasLimit()?.toBigInteger(),
|
||||
).getOrNull() ?: error("Failed to create yield swap extras")
|
||||
|
||||
return createTransactionUseCase(
|
||||
amount = createNativeAmountForDex("0", network),
|
||||
fee = fee,
|
||||
memo = null,
|
||||
destination = yieldModuleAddress,
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
network = network,
|
||||
txExtras = txExtras,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
|
||||
* [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator])
|
||||
|
|
@ -1501,6 +1594,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amount: SwapAmount,
|
||||
expressOperationType: ExpressOperationType,
|
||||
quoteAllowanceContract: String? = null,
|
||||
): SwapState {
|
||||
val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress
|
||||
val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty()
|
||||
|
|
@ -1521,7 +1615,14 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toAddress = dexToAddress,
|
||||
refundAddress = fromNetworkAddress?.defaultAddress?.value,
|
||||
expressOperationType = expressOperationType,
|
||||
).fold(
|
||||
).map { swapData ->
|
||||
val dexTx = swapData.transaction as? ExpressTransactionModel.DEX
|
||||
if (dexTx != null && quoteAllowanceContract != null && dexTx.allowanceContract == null) {
|
||||
swapData.copy(transaction = dexTx.copy(allowanceContract = quoteAllowanceContract))
|
||||
} else {
|
||||
swapData
|
||||
}
|
||||
}.fold(
|
||||
ifRight = { swapData ->
|
||||
val preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.Pending,
|
||||
|
|
@ -1640,17 +1741,31 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive && fromToken is CryptoCurrency.Token
|
||||
val spenderAddress = if (isYieldSwap) {
|
||||
yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, fromToken.network)
|
||||
?: run {
|
||||
TangemLogger.e(
|
||||
"Yield-swap approval skipped: yield-module address unresolved for " +
|
||||
"walletId=${fromSwapCurrencyStatus.userWalletId} network=${fromToken.network.rawId}",
|
||||
)
|
||||
return quotesLoadedState.copy(permissionState = PermissionDataState.Empty)
|
||||
}
|
||||
} else {
|
||||
requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }
|
||||
}
|
||||
|
||||
val allowanceInfo = getAllowanceInfoUseCase(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
cryptoCurrency = fromToken,
|
||||
spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" },
|
||||
spenderAddress = spenderAddress,
|
||||
requiredAmount = swapAmount.value,
|
||||
).getOrNull()
|
||||
|
||||
return quotesLoadedState.copy(
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded,
|
||||
spenderAddress = quoteModel.allowanceContract,
|
||||
spenderAddress = spenderAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseC
|
|||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
|
||||
import com.tangem.feature.swap.domain.*
|
||||
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
|
||||
import com.tangem.feature.swap.domain.api.SwapRepository
|
||||
|
|
@ -75,6 +76,7 @@ internal class SwapDomainModule {
|
|||
createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
@SwapDexGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
|
||||
wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
|
||||
): DexSwapFeeCalculator = DexSwapFeeCalculator(
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
|
||||
|
|
@ -82,6 +84,7 @@ internal class SwapDomainModule {
|
|||
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
patchEthGasLimitForSwap = patchEthGasLimitForSwap,
|
||||
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
|
|||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
|
||||
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySwapCallData
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.common.extensions.hexToBytes
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -19,12 +24,14 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
|
|||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Calculates the on-chain transaction fee for a DEX swap.
|
||||
|
|
@ -52,6 +59,7 @@ class DexSwapFeeCalculator(
|
|||
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
|
||||
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
|
||||
) {
|
||||
|
||||
suspend fun calculate(
|
||||
|
|
@ -115,6 +123,134 @@ class DexSwapFeeCalculator(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield-mode DEX fee path: routes the swap through the user's yield module proxy.
|
||||
*
|
||||
* Native fee is computed for a [TransactionData.Uncompiled] addressed to [yieldModuleAddress],
|
||||
* carrying the wrapped call data produced by [buildYieldSwapCallData]. The 12% gas-limit bump
|
||||
* is applied to match the non-yield DEX flow.
|
||||
*
|
||||
* Fallback to [GetEthSpecificFeeUseCase] (with the gas limit carried by the Express transaction
|
||||
* model) is applied in two cases:
|
||||
* - [yieldModuleAddress] is `null` — yield module address could not be resolved upstream;
|
||||
* - the fee estimation call throws `IllegalStateException` (e.g. payload too large).
|
||||
*
|
||||
* Yield-module errors ([YieldModuleUpgradeUnavailableException],
|
||||
* [YieldModuleVersionIndeterminateException]) are mapped to [ExpressDataError.UnknownError]
|
||||
* to keep the unified error surface a single type.
|
||||
*/
|
||||
suspend fun calculateYield(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
yieldModuleAddress: String?,
|
||||
): Either<ExpressDataError, DexFeeResult> = either {
|
||||
val fromCurrency = fromSwapCurrencyStatus.currency as? CryptoCurrency.Token
|
||||
?: raise(ExpressDataError.UnknownError())
|
||||
val network = fromCurrency.network
|
||||
|
||||
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
networkId = network.rawId,
|
||||
derivationPath = network.derivationPath.value,
|
||||
)
|
||||
if (nativeBalance.signum() == 0) raise(ExpressDataError.UnknownError())
|
||||
|
||||
if (yieldModuleAddress == null) {
|
||||
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
|
||||
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
|
||||
}
|
||||
|
||||
val spenderAddress = transaction.allowanceContract
|
||||
?: raise(ExpressDataError.UnknownError())
|
||||
|
||||
val rawFee = try {
|
||||
val wrappedCallData = buildYieldSwapCallData(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
txTo = transaction.txTo,
|
||||
dexCallData = transaction.txData,
|
||||
amount = transaction.fromAmount.value,
|
||||
spenderAddress = spenderAddress,
|
||||
)
|
||||
val extras = createTransactionExtrasUseCase(
|
||||
callData = wrappedCallData,
|
||||
network = network,
|
||||
).getOrNull() ?: raise(ExpressDataError.UnknownError())
|
||||
|
||||
val transactionData = TransactionData.Uncompiled(
|
||||
amount = createNativeAmountForDex("0", network),
|
||||
destinationAddress = yieldModuleAddress,
|
||||
fee = null,
|
||||
sourceAddress = transaction.txFrom,
|
||||
extras = extras,
|
||||
)
|
||||
getFeeUseCase(
|
||||
transactionData = transactionData,
|
||||
network = network,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
).getOrNull() ?: raise(ExpressDataError.UnknownError())
|
||||
} catch (_: YieldModuleUpgradeUnavailableException) {
|
||||
raise(ExpressDataError.UnknownError())
|
||||
} catch (_: YieldModuleVersionIndeterminateException) {
|
||||
raise(ExpressDataError.UnknownError())
|
||||
} catch (_: IllegalStateException) {
|
||||
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
|
||||
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
|
||||
}
|
||||
|
||||
val patched = patchEthGasLimitForSwap(rawFee)
|
||||
DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(patched),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
gas = transaction.gas,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a DEX call data into a yield-supply swap call data, ready to be sent through the
|
||||
* user's yield module. Shared with [SwapInteractorImpl.createYieldSwapDexTransaction], which
|
||||
* is why this helper is exposed at the calculator level rather than kept private.
|
||||
*/
|
||||
suspend fun buildYieldSwapCallData(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
txTo: String,
|
||||
dexCallData: String,
|
||||
amount: BigDecimal,
|
||||
spenderAddress: String,
|
||||
): SmartContractCallData {
|
||||
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
|
||||
val amountInWei = amount.movePointRight(fromCurrency.decimals).toBigInteger()
|
||||
val dexCallDataBytes = dexCallData.removePrefix("0x").hexToBytes()
|
||||
val swapCallData = EthereumYieldSupplySwapCallData(
|
||||
tokenIn = fromCurrency.contractAddress,
|
||||
amountIn = amountInWei,
|
||||
target = txTo,
|
||||
spender = spenderAddress,
|
||||
swapData = dexCallDataBytes,
|
||||
)
|
||||
return wrapYieldSwapCallDataWithUpgradeUseCase(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
network = fromCurrency.network,
|
||||
callData = swapCallData,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ethSpecificFeeFallback(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
gasLimit: BigInteger,
|
||||
): Either<ExpressDataError, DexFeeResult> = either {
|
||||
val fee = getEthSpecificFeeUseCase(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
cryptoCurrency = fromSwapCurrencyStatus.currency,
|
||||
gasLimit = gasLimit,
|
||||
).getOrNull() ?: raise(ExpressDataError.UnknownError())
|
||||
val patched = patchEthGasLimitForSwap(fee)
|
||||
DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(patched),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
gas = gasLimit,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private suspend fun getFeeDataForDexSwap(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ sealed class ExpressTransactionModel {
|
|||
val txData: String,
|
||||
val otherNativeFeeWei: BigDecimal?,
|
||||
val gas: BigInteger?,
|
||||
val allowanceContract: String?,
|
||||
val allowanceContract: String? = null,
|
||||
) : ExpressTransactionModel()
|
||||
|
||||
data class CEX(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
|
|
@ -872,6 +873,210 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class YieldSwapApprovalPath {
|
||||
|
||||
private val yieldProxyAddress = "0xYieldModuleProxy"
|
||||
private val yieldTokenContract = "0xTokenContract"
|
||||
|
||||
@BeforeEach
|
||||
fun enableYieldSwap() {
|
||||
every { swapFeatureToggles.isYieldSwapEnabled } returns true
|
||||
coEvery {
|
||||
yieldModuleAddressProvider.getOrFetch(any(), any())
|
||||
} returns yieldProxyAddress
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should proceed to QuotesLoadedState when yield-supply is active and isAllowedToSpend is true`() = runTest {
|
||||
// Given — yield active, approve to proxy in place → swap proceeds via loadDexSwapDataNoFee
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = yieldTokenContract,
|
||||
isCoin = false,
|
||||
amount = BigDecimal("10"),
|
||||
yieldSupplyActive = true,
|
||||
yieldSupplyAllowedToSpend = true,
|
||||
)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
val quoteModel = buildQuoteModel()
|
||||
val swapData = buildSwapDataModelDex()
|
||||
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
|
||||
fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
coEvery {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
|
||||
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(), toAddress = any(),
|
||||
expressOperationType = any(), refundAddress = any(),
|
||||
)
|
||||
} returns swapData.right()
|
||||
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
// Then — proceeds (no PermissionRequired), permissionState is Empty
|
||||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
val loaded = state as SwapState.QuotesLoadedState
|
||||
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should request approval to yield-module proxy when isAllowedToSpend is false`() = runTest {
|
||||
// Given — yield active, approve to proxy revoked → flow must surface PermissionRequired
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = yieldTokenContract,
|
||||
isCoin = false,
|
||||
amount = BigDecimal("10"),
|
||||
yieldSupplyActive = true,
|
||||
yieldSupplyAllowedToSpend = false,
|
||||
)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterShouldNotBeUsed")
|
||||
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
|
||||
fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
// Then — PermissionRequired with spender = yield-module proxy (not DEX router)
|
||||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
val loaded = state as SwapState.QuotesLoadedState
|
||||
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java)
|
||||
val required = loaded.permissionState as PermissionDataState.PermissionRequired
|
||||
assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set isResetApproval=true when yield-token allowance requires reset before re-approval`() = runTest {
|
||||
// Given — Tether-like token: any non-zero allowance must be reset to zero before re-approve.
|
||||
// Yield approve to proxy was revoked → onchain allowance is partial → ResetNeeded.
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = yieldTokenContract,
|
||||
isCoin = false,
|
||||
amount = BigDecimal("10"),
|
||||
yieldSupplyActive = true,
|
||||
yieldSupplyAllowedToSpend = false,
|
||||
)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterIgnoredForYield")
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
|
||||
fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
// Override default Enough stub: simulate partial-allowance state for yield-proxy spender.
|
||||
coEvery {
|
||||
getAllowanceInfoUseCase.invoke(
|
||||
userWalletId = any(),
|
||||
cryptoCurrency = any(),
|
||||
spenderAddress = yieldProxyAddress,
|
||||
requiredAmount = any(),
|
||||
)
|
||||
} returns (
|
||||
AllowanceInfo.ResetNeeded(
|
||||
allowance = BigDecimal("0.5"),
|
||||
requiredAmount = BigDecimal("1"),
|
||||
) as AllowanceInfo
|
||||
).right()
|
||||
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
// Then — PermissionRequired with isResetApproval=true and spender = yield-module proxy
|
||||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
val loaded = state as SwapState.QuotesLoadedState
|
||||
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java)
|
||||
val required = loaded.permissionState as PermissionDataState.PermissionRequired
|
||||
assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress)
|
||||
assertThat(required.isResetApproval).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should fallback to no-permission state when yield-module proxy address is unresolvable`() = runTest {
|
||||
// Given — yield store returns null (e.g. network unreachable on first resolve)
|
||||
coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns null
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = yieldTokenContract,
|
||||
isCoin = false,
|
||||
amount = BigDecimal("10"),
|
||||
yieldSupplyActive = true,
|
||||
yieldSupplyAllowedToSpend = false,
|
||||
)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouter")
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
|
||||
fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
// Then — falls back to PermissionDataState.Empty (no approval UI shown to avoid bogus DEX-router approve)
|
||||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
val loaded = state as SwapState.QuotesLoadedState
|
||||
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region — test-local helpers
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
|||
import com.tangem.domain.transaction.usecase.*
|
||||
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
|
||||
import com.tangem.feature.swap.domain.api.SwapRepository
|
||||
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
|
||||
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
|
||||
|
|
@ -41,6 +42,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
|
|||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapFee
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
|
@ -84,6 +86,8 @@ internal open class SwapInteractorImplTestBase {
|
|||
protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true)
|
||||
protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true)
|
||||
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true)
|
||||
protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
|
||||
protected val yieldModuleAddressProvider: YieldModuleAddressProvider = mockk(relaxed = true)
|
||||
|
||||
// endregion
|
||||
|
||||
|
|
@ -115,6 +119,8 @@ internal open class SwapInteractorImplTestBase {
|
|||
getSwapPairUseCase = getSwapPairUseCase,
|
||||
dexSwapFeeCalculator = dexSwapFeeCalculator,
|
||||
cexSwapFeeCalculator = cexSwapFeeCalculator,
|
||||
swapFeatureToggles = swapFeatureToggles,
|
||||
yieldModuleAddressProvider = yieldModuleAddressProvider,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +164,7 @@ internal fun buildSwapCurrencyStatus(
|
|||
decimals: Int = 18,
|
||||
userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"),
|
||||
yieldSupplyActive: Boolean = false,
|
||||
yieldSupplyAllowedToSpend: Boolean = true,
|
||||
): SwapCurrencyStatus {
|
||||
val networkId = mockk<Network.ID>(relaxed = true) {
|
||||
every { rawId } returns Network.RawID(networkRawId)
|
||||
|
|
@ -196,6 +203,7 @@ internal fun buildSwapCurrencyStatus(
|
|||
val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) {
|
||||
mockk<YieldSupplyStatus>(relaxed = true) {
|
||||
every { isActive } returns true
|
||||
every { isAllowedToSpend } returns yieldSupplyAllowedToSpend
|
||||
}
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
|
|||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
|
||||
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
|
|
@ -59,6 +60,7 @@ internal class DexSwapFeeCalculatorTest {
|
|||
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
|
||||
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
|
||||
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase = mockk(relaxed = true)
|
||||
|
||||
private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
|
||||
|
||||
|
|
@ -70,6 +72,7 @@ internal class DexSwapFeeCalculatorTest {
|
|||
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
patchEthGasLimitForSwap = dexBump,
|
||||
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
|
|||
featureTogglesManager: FeatureTogglesManager,
|
||||
) : SwapFeatureToggles {
|
||||
|
||||
override val isYieldSwapEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ sealed class ProviderState {
|
|||
val additionalBadge: AdditionalBadge,
|
||||
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
|
||||
val namePrefix: PrefixType,
|
||||
val approvalSettings: ApprovalSettings = ApprovalSettings.Empty,
|
||||
override val onProviderClick: (String) -> Unit,
|
||||
) : ProviderState()
|
||||
|
||||
|
|
@ -61,6 +62,14 @@ sealed class ProviderState {
|
|||
enum class PrefixType {
|
||||
NONE, PROVIDED_BY
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class ApprovalSettings {
|
||||
data object Empty : ApprovalSettings()
|
||||
data class Content(
|
||||
val onApprovalSelectClick: () -> Unit,
|
||||
) : ApprovalSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -4,26 +4,32 @@ import android.content.res.Configuration
|
|||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.SpacerWMax
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
|
|
@ -194,6 +200,23 @@ private fun ProviderContentState(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.approvalSettings is ProviderState.ApprovalSettings.Content) {
|
||||
SpacerWMax()
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_filter_default_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier
|
||||
.padding(end = 14.dp)
|
||||
.size(20.dp)
|
||||
.clickable(
|
||||
indication = ripple(false),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = state.approvalSettings.onApprovalSelectClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
|
||||
|
|
@ -442,10 +465,9 @@ private fun ProviderItemPreview(
|
|||
@PreviewParameter(ProviderItemParameterProvider::class) state: Pair<ProviderState, Boolean>,
|
||||
) {
|
||||
TangemThemePreview {
|
||||
ProviderItem(
|
||||
ProviderItemBlock(
|
||||
modifier = Modifier.background(TangemTheme.colors.background.action),
|
||||
state = state.first,
|
||||
isSelected = state.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -460,22 +482,28 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
|
|||
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
selectionType = ProviderState.SelectionType.NONE,
|
||||
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = {},
|
||||
)
|
||||
val contentState2 = contentState.copy(
|
||||
val contentStatePermissionRequired = contentState.copy(
|
||||
subtitle = stringReference(value = "1 132,46 MATIC"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
|
||||
percentLowerThenBest = PercentDifference.Value(value = 5f),
|
||||
)
|
||||
val contentStatePermissionIntegrated = contentState.copy(
|
||||
subtitle = stringReference(value = "1 132,46 MATIC"),
|
||||
percentLowerThenBest = PercentDifference.Value(value = 5f),
|
||||
approvalSettings = ProviderState.ApprovalSettings.Content({}),
|
||||
)
|
||||
val unavailableState = ProviderState.Unavailable(
|
||||
id = "1",
|
||||
name = "1inch",
|
||||
type = "DEX",
|
||||
iconUrl = "",
|
||||
alertText = stringReference(value = "Not available"),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
selectionType = ProviderState.SelectionType.NONE,
|
||||
onProviderClick = {},
|
||||
)
|
||||
val loadingState = ProviderState.Loading()
|
||||
|
|
@ -483,8 +511,11 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
|
|||
add(contentState to true)
|
||||
add(contentState to false)
|
||||
|
||||
add(contentState2 to true)
|
||||
add(contentState2 to false)
|
||||
add(contentStatePermissionRequired to true)
|
||||
add(contentStatePermissionRequired to false)
|
||||
|
||||
add(contentStatePermissionIntegrated to true)
|
||||
add(contentStatePermissionIntegrated to false)
|
||||
|
||||
add(unavailableState to true)
|
||||
add(unavailableState to false)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue