From b1e46496b71d8471bb5ebf5e43b952e9939ea44a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 17:38:32 +0200 Subject: [PATCH 01/22] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 9 +- .../write-ui-test/reference/compose-traps.md | 57 ++ .../reference/running-and-debugging.md | 40 ++ .../reference/swap-transfer-accounts.md | 80 +++ .../com/tangem/scenarios/SwapScenarios.kt | 52 ++ .../tangem/screens/MainScreenPageObject.kt | 9 + .../com/tangem/screens/SwapTokenPageObject.kt | 23 + .../tangem/tests/transfer/AppTransfersTest.kt | 495 ++++++++++++++++++ .../sdk/mocks/content/WalletMockContent.kt | 18 +- 9 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/write-ui-test/reference/swap-transfer-accounts.md create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index acb6f6d5da..c0254bd437 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -145,4 +145,11 @@ Delete anything explaining WHAT a step does. look like passing tests. - **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using - `@Ignore`, or driving WireMock scenarios. \ No newline at end of file + `@Ignore`, or driving WireMock scenarios. Includes **how to read the app's own log (`files/log.txt`, + not logcat)** and using the WireMock request journal to tell "mock missing" from "app never asked" — + the two techniques that find app-side root causes when the UI fails silently. +- **`reference/swap-transfer-accounts.md`** — read for any **swap, transfer-mode (same-token swap), or + multi-account ("accounts mode")** test. Covers how the accounts main screen / receive selector are + navigated, and the four mock prerequisites a token needs to be swappable/transferable (native coin for + the fee, a price quote so fiat≠0, exchange-enabled assets, and a V3 derivation card for segwit `m/84'` + coins) — each missing one fails like a locator bug but is really missing mock data. \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index 0458caa898..f5469d551e 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -154,6 +154,63 @@ Non-obvious points that bite: Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`). +## Main-screen Markets bottom sheet swallows touch-based auto-scroll + +The main screen hosts a Material3 Markets bottom sheet (nested scroll, like `PullToRefreshBox` above). +Kakao's **touch-based** auto-scroll — fired when a target is below the fold (an account card, the +"Generate addresses" button) — is handed to the sheet via nested scroll and **expands it over the +list**; the next click then lands on a market token (you end up on an unrelated token's details, e.g. +TRON). Symptoms: `autoscroll did not help` / "3 click attempts", or the test navigates somewhere random. + +- **Scroll with semantics, not touch:** `onNode(SCREEN_CONTAINER).performScrollToNode(matcher)` issues a + `ScrollToIndex` semantics action that does NOT engage the sheet's nested scroll. (See + `MainScreenPageObject.scrollToAccount`.) +- **Do NOT `device.pressBack()` to collapse the expanded sheet** on the root main screen — back exits the + app. The sheet's `BackHandler` only collapses when its `currentValue == Expanded`, which races the + press, so back frequently falls through to the activity and quits to the launcher. +- **Best: avoid the trigger** — keep total fiat > 0 (a price quote for the token, see + `swap-transfer-accounts.md`) so the empty-wallet banner doesn't push the list under the sheet's peek in + the first place. + +## A screen with a perpetual animation keeps Compose non-idle → idle-synced actions flake + +**General principle.** Espresso/Kakao/Compose-test actions block on Compose reaching *idle* before they +act. A screen that animates forever — an auto-advancing stories/onboarding carousel, a looping shimmer, a +spinner that never stops — never goes idle, so `clickWithAssertion()`, `assertIsDisplayed()`, and Kakao +waits on it fail intermittently (`… is not displayed`, or `ComposeNotIdleException`). The fix is to **get +rid of the non-idle screen**, not to out-wait it. + +**Polling the animated node does NOT fix it** — two attempts that look right but aren't: +- `composeTestRule.waitUntilAtLeastOneExists(hasTestTag(TAG), …)` polls the **merged** tree (it has no + `useUnmergedTree` option). If the target is a `clickable` element inside a `mergeDescendants` container + (common for tap-to-advance surfaces), its tag lives **only in the unmerged tree** → the wait never + matches → full-timeout on every run. +- `waitUntil { runCatching { onScreen { node.assertIsDisplayed() } }.isSuccess }` reads the unmerged tree + (good) but Kakao's `assertIsDisplayed` *itself* blocks on idle, and the screen never idles → each probe + hangs → the outer `waitUntil` times out too. + +**Fix: remove the animated screen at the source.** Most such screens are gated by a feature toggle or a +mock response — flip it off so the screen never renders, instead of interacting with it. When the toggle +is server-driven, set it **before app launch** (`additionalBeforeAppLaunchSection`, which runs before +`ActivityScenario.launch`) since the config is usually fetched at startup; setting it mid-test is too late. + +**Concrete instance (verify against current source — names drift):** the first-time *swap stories* are +controlled by the WireMock scenario `stories_first_time_swap_v2`. Its `Error` state returns 500, so the +screen never shows, and `openSwapScreen(…, storiesExist = false)` skips the close entirely: + +```kotlin +setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState("stories_first_time_swap_v2", "Error") }, + additionalAfterSection = { resetWireMockScenarioState("stories_first_time_swap_v2") }, +).run { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) +} +``` + +`SwapStoriesTest` uses this for every flow that isn't specifically testing stories. Only keep +`storiesExist = true` + `clickWithAssertion()` when the animated screen itself is the subject under test +(then accept that you're synchronizing against an animation and budget a longer, existence-based wait). + ## Decompose model lifecycle vs. data refresh Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning diff --git a/.claude/skills/write-ui-test/reference/running-and-debugging.md b/.claude/skills/write-ui-test/reference/running-and-debugging.md index 5698b4e87f..d8bc28f591 100644 --- a/.claude/skills/write-ui-test/reference/running-and-debugging.md +++ b/.claude/skills/write-ui-test/reference/running-and-debugging.md @@ -100,6 +100,46 @@ curl -s http://localhost:8081/__admin/requests/unmatched | jq '.requests[] | "\( (harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the local instance is missing. +## The app's own log lives in `files/log.txt`, NOT logcat + +The mocked build routes `TangemLogger` to a **file** via `FileLogWriter`, so `adb logcat | grep …` +finds nothing of the app's own logs. When a screen fails silently — fee shows "—", a banner never +appears, an action button stays disabled — *and* WireMock shows everything matched, the real reason is +almost always in the app log: + +```bash +adb exec-out run-as com.tangem.wallet.mocked cat files/log.txt | grep -iE "loadFee|getFee|No native|Error|DataError" +``` + +This is the single fastest way to find app-side root causes. It's what pinpointed a transfer-fee failure +to `loadFee[transfer]: DataError(... IllegalStateException: No native currency found ...)` — i.e. a +missing native coin in the mock, invisible from the UI and from the WireMock journal alone. + +## WireMock journal: "mock missing" vs "the app never asked" + +`/__admin/requests/unmatched` finds *missing* mappings. But when a feature silently doesn't happen (a fee +that never computes, a banner that never shows), also inspect the **full** request log — the app may not +be issuing the request at all (an app-side data gate), which is a different problem than a missing mock +and is NOT fixable by adding mappings: + +```bash +curl -s "http://localhost:8081/__admin/requests?limit=500" \ + | jq -r '.requests[].request | "\(.method) \(.url)"' | sort -u +# For RPC providers, also break down by method: +curl -s "http://localhost:8081/__admin/requests?limit=500" \ + | jq -r '.requests[].request.body' | grep -oE '"method":"[^"]+"' | sort | uniq -c +``` + +`unmatched=0` **and** the expected request absent → the app never asked (data/state gate, e.g. a coin +missing from the portfolio) → fix the mock *data* or app, not the mappings. + +## "UiAutomationService already registered" = back-to-back runs; retry + +Rapid consecutive `am instrument` invocations sometimes fail instantly with +`IllegalStateException: UiAutomationService … already registered!`. It's an instrumentation-teardown +race between runs, not a test failure — just retry. (The orchestrator/CI spaces runs out and avoids it.) +When scripting many manual runs, retry-on-this-string rather than counting it as a failure. + ## Classify the result — Allure noise vs. real failure After `pm clear`, `/data/user/0//files/original_screenshots` doesn't exist → diff --git a/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md b/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md new file mode 100644 index 0000000000..8387e1a99d --- /dev/null +++ b/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md @@ -0,0 +1,80 @@ +# Swap / transfer-mode / accounts-mode test setup + +Hard-won prerequisites for swap, transfer-mode (same-token swap), and multi-account ("accounts mode") +tests. Each missing item below produces a state that *looks* like a locator/test bug but is actually +missing mock data — you'll burn hours on the UI before realizing the data never arrived. + +> **Read this as symptom → where-to-look, not as a recipe.** The durable part of each item is the +> *symptom* and the *source-of-truth file/class* it names. The concrete mock-state names +> (`USDTHotWalletSvS`, `BitcoinExchangeEnabled`, …), mock classes (`Wallet2WithDerivationsMockContent`), +> string resources, and even "a token needs its native coin for the fee" are **a snapshot that will +> drift** — verify each against the cited source before trusting it. The change-proof skills are the +> *symptom → category* mapping here plus the two diagnostics in `running-and-debugging.md` (the app's +> `files/log.txt` and the WireMock request journal), which surface the *current* cause regardless of +> renames. If the specifics below stop matching, don't patch around them — re-derive from source and +> update this doc. + +## Accounts mode: the main screen shows ACCOUNT cards, not a token list + +With a two-accounts mock (`user_tokens_api=TwoAccountsSame…`, served via the `/v1/wallets/{id}/accounts` +endpoint), the main screen renders `MAIN_SCREEN_ACCOUNT_LIST_ITEM` cards ("Account 1", "Account 2"), NOT a +flat token list. `tokenWithTitleAndAddress("Bitcoin")` finds nothing. To reach a token: expand the +account, then click the token inside it. + +```kotlin +onMainScreen { scrollToAccount("Account 1") } // semantics scroll (see traps) +onMainScreen { findAccountSectionByName("Account 1").clickWithAssertion() } // expand the account +onMainScreen { findTokenInAnyAccountByName("Bitcoin").clickWithAssertion() } // token inside the account +``` + +The Swap **receive** selector also groups assets by account and renders the *other* account **collapsed** — +expand its group header before tapping the identical token (`tokenWithName("Account 2")` then +`tokenWithName("Bitcoin")`). Reuse `openSwapInTransferMode(token, fromAccountName, toAccountName)` in +`SwapScenarios.kt`, which encapsulates this. + +## Four mock prerequisites for a token to be transferable / swappable + +A transfer or swap depends on ALL of these. Each missing one fails differently: + +1. **Native coin present — else the fee never computes.** To compute an ERC20 token's transfer/send fee + the wallet must contain the token's NATIVE coin (e.g. Ethereum for USDT-on-ethereum). If the + user-tokens mock lists only the token, `GetFeeUseCase` → `getFee` raises + `IllegalStateException: No native currency found` → fee stays "—" → the fee-warning banner never shows + and the action button stays disabled. **Fix: add the native coin to each account** in the mock + response (`{"name":"Ethereum","symbol":"ETH","networkId":"ethereum","decimals":18,"id":"ethereum", + "derivationPath":""}`). +2. **A price quote — else fiat = $0 → empty-wallet banner.** A token with a balance but no price → total + fiat $0 → the "Get your first crypto" banner appears and pushes the account list down **under the + Markets sheet** (then account navigation can't reach the cards). **Fix: set the quotes scenario that + prices the token** (`quotes_api=Solana`, `quotes_api=USDTHotWalletSvS`, …). +3. **Exchange-enabled — else the token-details Swap button is disabled.** Set `express_api_assets` to a + state marking the token `exchangeAvailable=true` (e.g. `BitcoinExchangeEnabled`). +4. **Matching derivation style — else `isCustom` → Swap disabled.** The default `Wallet` mock is + derivation-style **V2**; for a segwit/BIP-84 coin the user-tokens stub sends `m/84'/…`, which V2 + resolves as a *custom* path → `CryptoCurrency.isCustom == true` → `CommonActionsFactory.createSwapAction` + returns `CustomToken` → Swap button disabled. **Fix: scan a V3 card** — + `openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent)` — so `m/84'` is the + card's default path and the coin isn't custom. + +Account-2 derivations: `Wallet2WithDerivationsMockContent.derivationTaskResponse` re-keys +`WalletMockContent`'s entries, so a missing per-account path (e.g. Bitcoin account-2 `m/84'/0'/1'/0/0`) +must be added to `WalletMockContent`'s `derivationTaskResponse` for the receive account to resolve. + +## "Insufficient funds" vs "insufficient fee" are DIFFERENT banners + +Don't confuse them when porting (iOS often asserts "any notification", which masks which one fired): + +- **amount > token balance** → `swapping_insufficient_funds` ("Insufficient funds … Reduce the amount") — + shows immediately, no fee needed. +- **native coin < fee** → `warning_send_blocked_funds_for_fee_title` ("Insufficient `` to cover + network fee", from `NotificationUM.Error.TokenExceedsBalance`). This fires only once a fee is **computed** + (`fee > 0`, via `GetBalanceNotEnoughForFeeWarningUseCase`), so prerequisite #1 is mandatory and the + native balance must be set to **zero/insufficient** (`eth_network_balance=EmptyAnyId`) *with the coin + present*. + +## Why an iOS transfer test may assert something the screen can't show + +iOS `waitForNotificationShown()` checks "any `notificationTitle` exists", so the iOS test goes green on a +generic "Error" banner when the fee fails to load — it does **not** prove the intended banner rendered. +Re-derive the correct Android notification string from production source and assert *that* specific banner; +treat the iOS assertion's leniency as a hint, not a spec. \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index cd9cfb4dd6..9430532499 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -11,6 +11,7 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.extensions.assertVisibility +import com.tangem.common.extensions.clickAndWaitFor import com.tangem.common.extensions.clickWhenEnabled import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText @@ -18,6 +19,7 @@ import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.HotWalletAccessCodeTestTags import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.MockContent import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.Allure.step import com.tangem.common.ui.R as CommonUiR @@ -260,6 +262,56 @@ fun BaseTestCase.checkSwapWarning( } } +/** Opens Swap for [tokenName] in [fromAccountName] and picks it again in [toAccountName] to enter Transfer mode; needs a two-accounts-same-token mock. */ +fun BaseTestCase.openSwapInTransferMode( + tokenName: String, + fromAccountName: String = "Account 1", + toAccountName: String = "Account 2", + mockContent: MockContent? = null, +) { + step("Open 'Main' screen") { + openMainScreen(mockContent = mockContent) + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") { + onMainScreen { scrollToAccount(fromAccountName) } + } + step("Expand account '$fromAccountName' and reveal token '$tokenName'") { + onMainScreen { + findAccountSectionByName(fromAccountName).clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onMainScreen { findTokenInAnyAccountByName(tokenName).assertIsDisplayed() } + }, + ) + } + } + step("Click on token with name: '$tokenName'") { + onMainScreen { findTokenInAnyAccountByName(tokenName).clickWithAssertion() } + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) + } + step("Choose identical receive token '$tokenName' from '$toAccountName'") { + chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName) + } +} + +/** Picks the identical [tokenName] in [receiveAccountName]; the receive list collapses the other account, so its header is expanded first. */ +fun BaseTestCase.chooseIdenticalReceiveToken(tokenName: String, receiveAccountName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Expand account '$receiveAccountName' in receive selector") { + onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() } + } + step("Click on token with name '$tokenName'") { + onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() } + } +} + fun BaseTestCase.chooseReceiveToken(tokenName: String) { step("Click on 'Choose token' button") { onSwapTokenScreen { chooseTokenButton.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 7ce81ac85b..8e83fc81ea 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -102,6 +102,15 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti } } + /** Scrolls to [accountName] via ScrollToIndex semantics, not a touch swipe — a bottom-edge drag is stolen by the Markets sheet's nested scroll. */ + @OptIn(ExperimentalTestApi::class) + fun scrollToAccount(accountName: String) { + semanticsProvider.onNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER)) + .performScrollToNode( + withTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM) and hasAnyDescendant(withText(accountName)), + ) + } + val restoringProgressText: KNode = child { hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT) useUnmergedTree = true diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index a0374130c3..3bdeff493c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -130,6 +130,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + fun insufficientFeeForTransferNotificationTitle(feeCoinName: String): KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCoinName)) + useUnmergedTree = true + } + fun warningTitle(title: String): KNode = child { hasTestTag(NotificationTestTags.TITLE) hasText(title) @@ -158,6 +164,23 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_swap)) } + val transferButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.swapping_transfer_action)) + } + + val transferTitle: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(R.string.common_transfer)) + useUnmergedTree = true + } + + // PercentPill testTag is the PredefinedPercentAmount enum name; MAX == "MAX". + val maxAmountButton: KNode = child { + hasTestTag("MAX") + useUnmergedTree = true + } + val youSwapBlock: KNode = child { hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER) hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2))) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt new file mode 100644 index 0000000000..4e87d66fd5 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -0,0 +1,495 @@ +package com.tangem.tests.transfer + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.extractText +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.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.* +import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent +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 + +@HiltAndroidTest +class AppTransfersTest : BaseTestCase() { + + private val ethCallScenario = "eth_call_api" + private val ethBalanceScenario = "eth_network_balance" + private val started = "Started" + // Disable the first-time-swap stories (500 → not shown); their auto-advancing animation keeps Compose non-idle and flakes the close. + private val storiesScenario = "stories_first_time_swap_v2" + private val storiesErrorState = "Error" + + private fun BaseTestCase.assertTransferReady() { + step("Assert action button label is 'Transfer'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess + } + } + step("Assert provider block is not displayed") { + onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } + } + } + + private fun BaseTestCase.inputAmount(amount: String) { + step("Input amount '$amount'") { + waitForIdle() + onSwapTokenScreen { + textInput.clickWithAssertion() + textInput.performTextReplacement(amount) + } + } + } + + private fun BaseTestCase.waitForFeeDisplayed() { + step("Assert fee amount is displayed") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess + } + } + } + + @AllureId("9838") + @DisplayName("App transfers: identical pair switches to Transfer mode") + @Test + fun identicalPairSwitchesToTransferModeTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + inputAmount(amount) + assertTransferReady() + waitForFeeDisplayed() + } + } + + @AllureId("9843") + @DisplayName("App transfers: zero amount keeps Transfer button disabled") + @Test + fun zeroAmountKeepsTransferButtonDisabledTest() { + val token = "Ethereum" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Assert provider block is not displayed") { + onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } + } + step("Assert 'Transfer' button is disabled") { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } + + @AllureId("9992") + @DisplayName("App transfers: reversing tokens keeps Transfer mode") + @Test + fun reversingTokensKeepsTransferModeTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + inputAmount(amount) + waitForFeeDisplayed() + step("Click on 'Swap tokens' (reverse) button") { + onSwapTokenScreen { replaceTokensButton.performClick() } + } + assertTransferReady() + } + } + + @AllureId("9847") + @DisplayName("App transfers: Max amount keeps Transfer enabled and subtracts fee") + @Test + fun maxAmountFractionSubtractsFeeTest() { + val token = "Ethereum" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Focus amount field to reveal predefined amount buttons") { + waitForIdle() + onSwapTokenScreen { textInput.clickWithAssertion() } + } + step("Click on 'Max' amount button") { + onSwapTokenScreen { maxAmountButton.performClick() } + } + waitForFeeDisplayed() + assertTransferReady() + step("Assert 'Transfer' button is enabled") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { transferButton.assertIsEnabled() } }.isSuccess + } + } + } + } + + @AllureId("9844") + @DisplayName("App transfers: amount above balance disables Transfer") + @Test + fun amountAboveBalanceDisablesTransferTest() { + val token = "Ethereum" + val aboveBalanceAmount = "100" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + inputAmount(aboveBalanceAmount) + // Above-balance recalculates the fee forever (Compose never idles), so assert the "Insufficient funds" title, not button state. + step("Assert 'Insufficient funds' is displayed") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } }.isSuccess + } + } + } + } + + @AllureId("10003") + @DisplayName("App transfers: EVM network fee speed options") + @Test + fun evmNetworkFeeSpeedOptionsTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + inputAmount(amount) + waitForFeeDisplayed() + assertTransferReady() + + var marketFee = "" + step("Read displayed 'Market' fee amount") { + onSwapTokenScreen { marketFee = feeAmount.extractText() } + } + step("Open 'Network fee' selector via 'Select fee' icon") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { selectFeeIcon.performClick() } } + runCatching { onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } }.isSuccess + } + } + step("Click on 'Fast' fee option") { + onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() } + } + step("Assert fee amount changed from Market fee") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { + onSwapTokenScreen { feeAmount.assertIsDisplayed() } + }.isSuccess && onSwapTokenScreenFeeDiffers(marketFee) + } + } + } + } + + @AllureId("10002") + @DisplayName("App transfers: UTXO network fee") + @Test + fun utxoNetworkFeeTest() { + val token = "Bitcoin" + val amount = "0.001" + val userTokensState = "TwoAccountsSameBitcoin" + val bitcoinUtxoScenario = "bitcoin_utxo" + val bitcoinUtxoState = "BalanceAnyAddress" + val assetsScenario = "express_api_assets" + val assetsBitcoinState = "BitcoinExchangeEnabled" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(bitcoinUtxoScenario) + resetWireMockScenarioState(assetsScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$bitcoinUtxoScenario' to state: '$bitcoinUtxoState'") { + setWireMockScenarioState(scenarioName = bitcoinUtxoScenario, state = bitcoinUtxoState) + } + // Bitcoin swap must be exchange-enabled or the token-details Swap button stays disabled. + step("Set WireMock scenario: '$assetsScenario' to state: '$assetsBitcoinState'") { + setWireMockScenarioState(scenarioName = assetsScenario, state = assetsBitcoinState) + } + + // V3 card: Bitcoin's default path is m/84' (matches the stub) so the coin isn't custom — else the Swap button stays disabled. + step("Open Swap in Transfer mode for '$token'") { + openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent) + } + inputAmount(amount) + assertTransferReady() + waitForFeeDisplayed() + } + } + + @AllureId("10004") + @DisplayName("App transfers: Solana network fee") + @Test + fun solanaNetworkFeeTest() { + val token = "Solana" + val amount = "0.001" + val userTokensState = "TwoAccountsSameSolana" + val solanaBalanceScenario = "solana_balance" + val quotesSolanaState = "Solana" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(solanaBalanceScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started) + } + // Non-zero SOL price keeps total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + inputAmount(amount) + assertTransferReady() + waitForFeeDisplayed() + } + } + + @AllureId("9845") + @DisplayName("App transfers: insufficient native coin for fee disables Transfer") + @Test + fun insufficientNativeCoinForFeeDisablesTransferTest() { + val token = "Tether" + val feeCoinName = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameUsdt" + // Zero native ETH (coin present in the mock so the fee still estimates) → fee exceeds balance. + val ethBalanceState = "EmptyAnyId" + val quotesUsdtState = "USDTHotWalletSvS" + val feeHistoryScenario = "eth_fee_history" + val estimateGasScenario = "eth_estimate_gas" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(feeHistoryScenario) + resetWireMockScenarioState(estimateGasScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$ethBalanceState'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = ethBalanceState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesUsdtState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesUsdtState) + } + step("Set WireMock scenario: '$feeHistoryScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = feeHistoryScenario, state = started) + } + step("Set WireMock scenario: '$estimateGasScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = estimateGasScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + inputAmount(amount) + step("Assert 'Insufficient $feeCoinName to cover network fee' notification is displayed") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { + onSwapTokenScreen { + insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed() + } + }.isSuccess + } + } + } + } + + @AllureId("9990") + @DisplayName("App transfers: search filters receive token list") + @Test + fun searchFiltersReceiveTokenListTest() { + val sourceToken = "Polygon" + val ethereumToken = "Ethereum" + val polygonReceiveName = "POL (ex-MATIC)" + val noMatchQuery = "f" + val polygonQuery = "pol" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { resetWireMockScenarioState(storiesScenario) }, + ).run { + step("Open 'Main' screen") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Click on token with name: '$sourceToken'") { + onMainScreen { tokenWithTitleAndAddress(sourceToken).clickWithAssertion() } + } + step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } + step("Open receive token selector") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Type '$noMatchQuery' in search field") { + onSwapSelectTokenScreen { + searchBarBlock.performClick() + searchBarBlock.performTextInput(noMatchQuery) + } + } + step("Assert '$ethereumToken' is not displayed") { + onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() } + } + step("Assert '$polygonReceiveName' is not displayed") { + onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsNotDisplayed() } + } + step("Replace search text with '$polygonQuery'") { + onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(polygonQuery) } + } + step("Assert '$polygonReceiveName' is displayed") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() } }.isSuccess + } + } + step("Assert '$ethereumToken' is not displayed") { + onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() } + } + } + } + + private fun BaseTestCase.onSwapTokenScreenFeeDiffers(previousFee: String): Boolean { + var current = "" + onSwapTokenScreen { current = feeAmount.extractText() } + return current.isNotEmpty() && current != previousFee + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index e6d872be54..78c6b52b7b 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -172,7 +172,16 @@ object WalletMockContent : MockContent { remainingSignatures = null, index = 1, hasBackup = false, - derivedKeys = emptyMap(), + derivedKeys = mapOf( + DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana (account 1) + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + DerivationPath("m/44'/501'/1'") to ExtendedPublicKey( // Solana (account 2) + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + ), extendedPublicKey = ExtendedPublicKey( publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), @@ -218,6 +227,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/84'/0'/1'/0/0") to ExtendedPublicKey( // btc (account 2) + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), From d0e80545355ef30e5421b128f369a6692041c301 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 17:55:18 +0200 Subject: [PATCH 02/22] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 10 +-- .../write-ui-test/reference/compose-traps.md | 72 +++++------------ .../reference/running-and-debugging.md | 53 +++++------- .../reference/swap-transfer-accounts.md | 80 ------------------- 4 files changed, 41 insertions(+), 174 deletions(-) delete mode 100644 .claude/skills/write-ui-test/reference/swap-transfer-accounts.md diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index c0254bd437..f35d144d56 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -145,11 +145,5 @@ Delete anything explaining WHAT a step does. look like passing tests. - **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using - `@Ignore`, or driving WireMock scenarios. Includes **how to read the app's own log (`files/log.txt`, - not logcat)** and using the WireMock request journal to tell "mock missing" from "app never asked" — - the two techniques that find app-side root causes when the UI fails silently. -- **`reference/swap-transfer-accounts.md`** — read for any **swap, transfer-mode (same-token swap), or - multi-account ("accounts mode")** test. Covers how the accounts main screen / receive selector are - navigated, and the four mock prerequisites a token needs to be swappable/transferable (native coin for - the fee, a price quote so fiat≠0, exchange-enabled assets, and a V3 derivation card for segwit `m/84'` - coins) — each missing one fails like a locator bug but is really missing mock data. \ No newline at end of file + `@Ignore`, or driving WireMock scenarios. Includes how to find app-side root causes when the UI fails + silently (the app log in `files/log.txt`, and the WireMock journal). \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index f5469d551e..817f5c019a 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -154,62 +154,32 @@ Non-obvious points that bite: Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`). -## Main-screen Markets bottom sheet swallows touch-based auto-scroll +## Touch auto-scroll gets hijacked by a nested-scroll container (e.g. a bottom sheet) -The main screen hosts a Material3 Markets bottom sheet (nested scroll, like `PullToRefreshBox` above). -Kakao's **touch-based** auto-scroll — fired when a target is below the fold (an account card, the -"Generate addresses" button) — is handed to the sheet via nested scroll and **expands it over the -list**; the next click then lands on a market token (you end up on an unrelated token's details, e.g. -TRON). Symptoms: `autoscroll did not help` / "3 click attempts", or the test navigates somewhere random. +When a screen hosts a nested-scroll container (a Material3 bottom sheet, `PullToRefreshBox`), Kakao's +**touch-based** auto-scroll toward a below-the-fold target can be consumed by that container instead — +expanding the sheet over the content, so the next click lands on the wrong element. -- **Scroll with semantics, not touch:** `onNode(SCREEN_CONTAINER).performScrollToNode(matcher)` issues a - `ScrollToIndex` semantics action that does NOT engage the sheet's nested scroll. (See - `MainScreenPageObject.scrollToAccount`.) -- **Do NOT `device.pressBack()` to collapse the expanded sheet** on the root main screen — back exits the - app. The sheet's `BackHandler` only collapses when its `currentValue == Expanded`, which races the - press, so back frequently falls through to the activity and quits to the launcher. -- **Best: avoid the trigger** — keep total fiat > 0 (a price quote for the token, see - `swap-transfer-accounts.md`) so the empty-wallet banner doesn't push the list under the sheet's peek in - the first place. +- **Scroll with semantics, not touch:** `onNode(CONTAINER).performScrollToNode(matcher)` issues a + `ScrollToIndex` action that does NOT engage nested scroll. +- **Don't `device.pressBack()` to collapse the sheet** on a root screen — its `BackHandler` only fires + when already expanded, races the press, and back often falls through and quits the app. -## A screen with a perpetual animation keeps Compose non-idle → idle-synced actions flake +## A perpetually animating screen keeps Compose non-idle → idle-synced actions flake -**General principle.** Espresso/Kakao/Compose-test actions block on Compose reaching *idle* before they -act. A screen that animates forever — an auto-advancing stories/onboarding carousel, a looping shimmer, a -spinner that never stops — never goes idle, so `clickWithAssertion()`, `assertIsDisplayed()`, and Kakao -waits on it fail intermittently (`… is not displayed`, or `ComposeNotIdleException`). The fix is to **get -rid of the non-idle screen**, not to out-wait it. +Kakao/Compose-test actions block on Compose reaching *idle* first. A screen that animates forever — an +auto-advancing stories/onboarding carousel, a looping shimmer, a never-ending spinner — never idles, so +`clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or +`ComposeNotIdleException`). **Remove the screen at its source rather than out-waiting it:** most are gated +by a feature toggle or a mock response — flip it off so the screen never renders. If it's server-driven, +set the toggle **before app launch** (config is fetched at startup), not mid-test. (Example: the swap +stories are disabled via their WireMock scenario, then opened with `storiesExist = false`.) -**Polling the animated node does NOT fix it** — two attempts that look right but aren't: -- `composeTestRule.waitUntilAtLeastOneExists(hasTestTag(TAG), …)` polls the **merged** tree (it has no - `useUnmergedTree` option). If the target is a `clickable` element inside a `mergeDescendants` container - (common for tap-to-advance surfaces), its tag lives **only in the unmerged tree** → the wait never - matches → full-timeout on every run. -- `waitUntil { runCatching { onScreen { node.assertIsDisplayed() } }.isSuccess }` reads the unmerged tree - (good) but Kakao's `assertIsDisplayed` *itself* blocks on idle, and the screen never idles → each probe - hangs → the outer `waitUntil` times out too. - -**Fix: remove the animated screen at the source.** Most such screens are gated by a feature toggle or a -mock response — flip it off so the screen never renders, instead of interacting with it. When the toggle -is server-driven, set it **before app launch** (`additionalBeforeAppLaunchSection`, which runs before -`ActivityScenario.launch`) since the config is usually fetched at startup; setting it mid-test is too late. - -**Concrete instance (verify against current source — names drift):** the first-time *swap stories* are -controlled by the WireMock scenario `stories_first_time_swap_v2`. Its `Error` state returns 500, so the -screen never shows, and `openSwapScreen(…, storiesExist = false)` skips the close entirely: - -```kotlin -setupHooks( - additionalBeforeAppLaunchSection = { setWireMockScenarioState("stories_first_time_swap_v2", "Error") }, - additionalAfterSection = { resetWireMockScenarioState("stories_first_time_swap_v2") }, -).run { - openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) -} -``` - -`SwapStoriesTest` uses this for every flow that isn't specifically testing stories. Only keep -`storiesExist = true` + `clickWithAssertion()` when the animated screen itself is the subject under test -(then accept that you're synchronizing against an animation and budget a longer, existence-based wait). +**Polling the animated node does NOT rescue it** — two false fixes: +- `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree (no `useUnmergedTree` option); a + `clickable` node inside a `mergeDescendants` container exists only in the *unmerged* tree → never matches. +- `waitUntil { runCatching { node.assertIsDisplayed() }.isSuccess }` reads the unmerged tree but + `assertIsDisplayed` itself blocks on idle, which never comes → the outer wait times out too. ## Decompose model lifecycle vs. data refresh diff --git a/.claude/skills/write-ui-test/reference/running-and-debugging.md b/.claude/skills/write-ui-test/reference/running-and-debugging.md index d8bc28f591..8f73aaa83c 100644 --- a/.claude/skills/write-ui-test/reference/running-and-debugging.md +++ b/.claude/skills/write-ui-test/reference/running-and-debugging.md @@ -100,45 +100,28 @@ curl -s http://localhost:8081/__admin/requests/unmatched | jq '.requests[] | "\( (harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the local instance is missing. -## The app's own log lives in `files/log.txt`, NOT logcat +## When the UI fails silently, the cause is usually app-side — two places to look -The mocked build routes `TangemLogger` to a **file** via `FileLogWriter`, so `adb logcat | grep …` -finds nothing of the app's own logs. When a screen fails silently — fee shows "—", a banner never -appears, an action button stays disabled — *and* WireMock shows everything matched, the real reason is -almost always in the app log: +A screen failing silently with correct locators (fee shows "—", a banner never appears, a button stays +disabled) is usually missing mock *data* or an app-side gate, not a test bug. Two diagnostics find it: -```bash -adb exec-out run-as com.tangem.wallet.mocked cat files/log.txt | grep -iE "loadFee|getFee|No native|Error|DataError" -``` +- **The app's own log is in `files/log.txt`, not logcat** — the mocked build routes `TangemLogger` to a + file, so `adb logcat` shows nothing. Fastest path to a root cause (e.g. it surfaced + `IllegalStateException: No native currency found` → a native coin missing from the mock): + ```bash + adb exec-out run-as cat files/log.txt | grep -iE "Error|Exception|" + ``` +- **The WireMock journal separates "mock missing" from "app never asked"** — + `/__admin/requests/unmatched` finds missing mappings, but if `unmatched=0` *and* the expected request + is also absent from the full log (`/__admin/requests`), the app never issued it (a data/state gate) → + fix the mock data or the app, not the mappings. -This is the single fastest way to find app-side root causes. It's what pinpointed a transfer-fee failure -to `loadFee[transfer]: DataError(... IllegalStateException: No native currency found ...)` — i.e. a -missing native coin in the mock, invisible from the UI and from the WireMock journal alone. +## "UiAutomationService already registered" — retry, it's not a failure -## WireMock journal: "mock missing" vs "the app never asked" - -`/__admin/requests/unmatched` finds *missing* mappings. But when a feature silently doesn't happen (a fee -that never computes, a banner that never shows), also inspect the **full** request log — the app may not -be issuing the request at all (an app-side data gate), which is a different problem than a missing mock -and is NOT fixable by adding mappings: - -```bash -curl -s "http://localhost:8081/__admin/requests?limit=500" \ - | jq -r '.requests[].request | "\(.method) \(.url)"' | sort -u -# For RPC providers, also break down by method: -curl -s "http://localhost:8081/__admin/requests?limit=500" \ - | jq -r '.requests[].request.body' | grep -oE '"method":"[^"]+"' | sort | uniq -c -``` - -`unmatched=0` **and** the expected request absent → the app never asked (data/state gate, e.g. a coin -missing from the portfolio) → fix the mock *data* or app, not the mappings. - -## "UiAutomationService already registered" = back-to-back runs; retry - -Rapid consecutive `am instrument` invocations sometimes fail instantly with -`IllegalStateException: UiAutomationService … already registered!`. It's an instrumentation-teardown -race between runs, not a test failure — just retry. (The orchestrator/CI spaces runs out and avoids it.) -When scripting many manual runs, retry-on-this-string rather than counting it as a failure. +Back-to-back `am instrument` runs sometimes fail instantly with `UiAutomationService … already +registered!` — a teardown race between runs, not a test failure. Retry. (The orchestrator avoids it by +spacing runs — another reason to confirm a flaky-looking suite via the orchestrator, not raw +`am instrument`.) ## Classify the result — Allure noise vs. real failure diff --git a/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md b/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md deleted file mode 100644 index 8387e1a99d..0000000000 --- a/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md +++ /dev/null @@ -1,80 +0,0 @@ -# Swap / transfer-mode / accounts-mode test setup - -Hard-won prerequisites for swap, transfer-mode (same-token swap), and multi-account ("accounts mode") -tests. Each missing item below produces a state that *looks* like a locator/test bug but is actually -missing mock data — you'll burn hours on the UI before realizing the data never arrived. - -> **Read this as symptom → where-to-look, not as a recipe.** The durable part of each item is the -> *symptom* and the *source-of-truth file/class* it names. The concrete mock-state names -> (`USDTHotWalletSvS`, `BitcoinExchangeEnabled`, …), mock classes (`Wallet2WithDerivationsMockContent`), -> string resources, and even "a token needs its native coin for the fee" are **a snapshot that will -> drift** — verify each against the cited source before trusting it. The change-proof skills are the -> *symptom → category* mapping here plus the two diagnostics in `running-and-debugging.md` (the app's -> `files/log.txt` and the WireMock request journal), which surface the *current* cause regardless of -> renames. If the specifics below stop matching, don't patch around them — re-derive from source and -> update this doc. - -## Accounts mode: the main screen shows ACCOUNT cards, not a token list - -With a two-accounts mock (`user_tokens_api=TwoAccountsSame…`, served via the `/v1/wallets/{id}/accounts` -endpoint), the main screen renders `MAIN_SCREEN_ACCOUNT_LIST_ITEM` cards ("Account 1", "Account 2"), NOT a -flat token list. `tokenWithTitleAndAddress("Bitcoin")` finds nothing. To reach a token: expand the -account, then click the token inside it. - -```kotlin -onMainScreen { scrollToAccount("Account 1") } // semantics scroll (see traps) -onMainScreen { findAccountSectionByName("Account 1").clickWithAssertion() } // expand the account -onMainScreen { findTokenInAnyAccountByName("Bitcoin").clickWithAssertion() } // token inside the account -``` - -The Swap **receive** selector also groups assets by account and renders the *other* account **collapsed** — -expand its group header before tapping the identical token (`tokenWithName("Account 2")` then -`tokenWithName("Bitcoin")`). Reuse `openSwapInTransferMode(token, fromAccountName, toAccountName)` in -`SwapScenarios.kt`, which encapsulates this. - -## Four mock prerequisites for a token to be transferable / swappable - -A transfer or swap depends on ALL of these. Each missing one fails differently: - -1. **Native coin present — else the fee never computes.** To compute an ERC20 token's transfer/send fee - the wallet must contain the token's NATIVE coin (e.g. Ethereum for USDT-on-ethereum). If the - user-tokens mock lists only the token, `GetFeeUseCase` → `getFee` raises - `IllegalStateException: No native currency found` → fee stays "—" → the fee-warning banner never shows - and the action button stays disabled. **Fix: add the native coin to each account** in the mock - response (`{"name":"Ethereum","symbol":"ETH","networkId":"ethereum","decimals":18,"id":"ethereum", - "derivationPath":""}`). -2. **A price quote — else fiat = $0 → empty-wallet banner.** A token with a balance but no price → total - fiat $0 → the "Get your first crypto" banner appears and pushes the account list down **under the - Markets sheet** (then account navigation can't reach the cards). **Fix: set the quotes scenario that - prices the token** (`quotes_api=Solana`, `quotes_api=USDTHotWalletSvS`, …). -3. **Exchange-enabled — else the token-details Swap button is disabled.** Set `express_api_assets` to a - state marking the token `exchangeAvailable=true` (e.g. `BitcoinExchangeEnabled`). -4. **Matching derivation style — else `isCustom` → Swap disabled.** The default `Wallet` mock is - derivation-style **V2**; for a segwit/BIP-84 coin the user-tokens stub sends `m/84'/…`, which V2 - resolves as a *custom* path → `CryptoCurrency.isCustom == true` → `CommonActionsFactory.createSwapAction` - returns `CustomToken` → Swap button disabled. **Fix: scan a V3 card** — - `openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent)` — so `m/84'` is the - card's default path and the coin isn't custom. - -Account-2 derivations: `Wallet2WithDerivationsMockContent.derivationTaskResponse` re-keys -`WalletMockContent`'s entries, so a missing per-account path (e.g. Bitcoin account-2 `m/84'/0'/1'/0/0`) -must be added to `WalletMockContent`'s `derivationTaskResponse` for the receive account to resolve. - -## "Insufficient funds" vs "insufficient fee" are DIFFERENT banners - -Don't confuse them when porting (iOS often asserts "any notification", which masks which one fired): - -- **amount > token balance** → `swapping_insufficient_funds` ("Insufficient funds … Reduce the amount") — - shows immediately, no fee needed. -- **native coin < fee** → `warning_send_blocked_funds_for_fee_title` ("Insufficient `` to cover - network fee", from `NotificationUM.Error.TokenExceedsBalance`). This fires only once a fee is **computed** - (`fee > 0`, via `GetBalanceNotEnoughForFeeWarningUseCase`), so prerequisite #1 is mandatory and the - native balance must be set to **zero/insufficient** (`eth_network_balance=EmptyAnyId`) *with the coin - present*. - -## Why an iOS transfer test may assert something the screen can't show - -iOS `waitForNotificationShown()` checks "any `notificationTitle` exists", so the iOS test goes green on a -generic "Error" banner when the fee fails to load — it does **not** prove the intended banner rendered. -Re-derive the correct Android notification string from production source and assert *that* specific banner; -treat the iOS assertion's leniency as a hint, not a spec. \ No newline at end of file From 0ce9be5b08bcad15e707c2292ca2ab9951590848 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 23:39:09 +0200 Subject: [PATCH 03/22] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 25 ++-- .../write-ui-test/reference/compose-traps.md | 19 +-- .../com/tangem/scenarios/SwapScenarios.kt | 31 +++++ .../tangem/tests/transfer/AppTransfersTest.kt | 108 ++++++------------ 4 files changed, 94 insertions(+), 89 deletions(-) diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index f35d144d56..7508749a1f 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -74,6 +74,10 @@ When the user asks to **port** an iOS test to Android: strings inside `step(...)`. - **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which click failed in the Allure report. +- **No reusable step-helpers as private functions in the test class.** A sequence reused across tests + (e.g. `enterAmount`, `assertReady`) goes in a `scenarios/` file as a `BaseTestCase` extension, not as a + private method on the test class — reviewers reject the latter. The test body then calls it wrapped in a + `step(...)` like any scenario. - **Every scenario call in the test body is wrapped in its own `step("…")`**, even though the scenario itself contains inner `step(...)`s — the outer step names the flow in the Allure tree, the inner ones detail it (nested steps are expected). `step(...)` (Allure) is callable anywhere, including inside @@ -121,13 +125,20 @@ Scenario files orchestrate flows; they must not define page objects or duplicate ### Waits and synchronization -- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop). Use: - - `composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMillis)` — wait for one thing to appear. - - `composeTestRule.waitUntil(timeout) { runCatching { someAssertion() }.isSuccess }` — wait until an - action no longer throws. - - `composeTestRule.waitUntil(timeout) { matcherA exists || matcherB exists }` — the either/or case. -- **`flakySafely(timeout)`** (Kaspresso) is reachable only from `TestCase` subclasses, NOT from - extension functions on `BaseTestCase`. In extension code use the `waitUntil` variants above. +- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop) — even + if a bot reviewer suggests one. +- **Default in the test body: `flakySafely(TIMEOUT) { assertion }`** — the codebase idiom (hundreds of + uses); reviewers prefer it over `composeTestRule.waitUntil { runCatching { … }.isSuccess }`. +- **`ComposeNotIdleException` / `AppNotIdleException` ("busy for ~60s") is usually a sick emulator, not + your test.** After many back-to-back local runs the emulator degrades (you may even see a "System UI + isn't responding" ANR), and idle-synced ops (`flakySafely`, `waitForIdle()`, Kakao actions) start + timing out *anywhere* data is loading — different test each run. Before concluding a test is flaky or + that a screen "never idles", **cold-boot a fresh emulator** (`emulator -avd … -no-snapshot -wipe-data + -memory 4096 -cores 2`) and re-run. A suite that flaked across runs on a tired emulator can be a clean + 10/10 on a fresh one (verified on this exact suite). Don't rewrite waits to work around emulator rot. +- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use the + same `composeTestRule.waitUntil` fallback (or `waitUntilAtLeastOneExists(matcher, timeout)` to wait for + appearance, `{ a exists || b exists }` for either/or). ### Comment hygiene diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index 817f5c019a..96e51a4384 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -170,16 +170,17 @@ expanding the sheet over the content, so the next click lands on the wrong eleme Kakao/Compose-test actions block on Compose reaching *idle* first. A screen that animates forever — an auto-advancing stories/onboarding carousel, a looping shimmer, a never-ending spinner — never idles, so `clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or -`ComposeNotIdleException`). **Remove the screen at its source rather than out-waiting it:** most are gated -by a feature toggle or a mock response — flip it off so the screen never renders. If it's server-driven, -set the toggle **before app launch** (config is fetched at startup), not mid-test. (Example: the swap -stories are disabled via their WireMock scenario, then opened with `storiesExist = false`.) +`ComposeNotIdleException`). **First rule out a degraded emulator** (see running-and-debugging) — a +slow-*loading* screen on a tired emulator throws the identical exception but is fixed by a cold-boot, not +by changing the test. Only treat it as a *truly* infinite animation if it reproduces on a fresh emulator. -**Polling the animated node does NOT rescue it** — two false fixes: -- `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree (no `useUnmergedTree` option); a - `clickable` node inside a `mergeDescendants` container exists only in the *unmerged* tree → never matches. -- `waitUntil { runCatching { node.assertIsDisplayed() }.isSuccess }` reads the unmerged tree but - `assertIsDisplayed` itself blocks on idle, which never comes → the outer wait times out too. +For a genuinely infinite animation, **remove the screen at its source rather than out-waiting it:** most +are gated by a feature toggle or a mock response — flip it off so the screen never renders. If it's +server-driven, set the toggle **before app launch** (config is fetched at startup), not mid-test. +(Example: the swap first-time stories are disabled via their WireMock scenario, then opened with +`storiesExist = false`.) Note that `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree +(no `useUnmergedTree` option), so a `clickable` node inside a `mergeDescendants` container — which exists +only in the *unmerged* tree — will never match it; poll through the page object instead. ## Decompose model lifecycle vs. data refresh diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index 9430532499..db4e1a2771 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -435,4 +435,35 @@ enum class FeeType { Fast } +fun BaseTestCase.inputAmount(amount: String) { + // No waitForIdle(): the transfer screen recalculates the fee continuously and never reaches idle. + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { textInput.assertIsDisplayed() } }.isSuccess + } + onSwapTokenScreen { + textInput.clickWithAssertion() + textInput.performTextReplacement(amount) + } +} + +// composeTestRule.waitUntil rather than flakySafely — the latter is unavailable in extensions on BaseTestCase. +fun BaseTestCase.assertTransferReady() { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess + } + onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } +} + +fun BaseTestCase.waitForFeeDisplayed() { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess + } +} + +fun BaseTestCase.swapFeeDiffersFrom(previousFee: String): Boolean { + var current = "" + onSwapTokenScreen { current = feeAmount.extractText() } + return current.isNotEmpty() && current != previousFee +} + diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt index 4e87d66fd5..d7897ef413 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -27,35 +27,6 @@ class AppTransfersTest : BaseTestCase() { private val storiesScenario = "stories_first_time_swap_v2" private val storiesErrorState = "Error" - private fun BaseTestCase.assertTransferReady() { - step("Assert action button label is 'Transfer'") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess - } - } - step("Assert provider block is not displayed") { - onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } - } - } - - private fun BaseTestCase.inputAmount(amount: String) { - step("Input amount '$amount'") { - waitForIdle() - onSwapTokenScreen { - textInput.clickWithAssertion() - textInput.performTextReplacement(amount) - } - } - } - - private fun BaseTestCase.waitForFeeDisplayed() { - step("Assert fee amount is displayed") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess - } - } - } - @AllureId("9838") @DisplayName("App transfers: identical pair switches to Transfer mode") @Test @@ -84,9 +55,9 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) - assertTransferReady() - waitForFeeDisplayed() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } } } @@ -154,12 +125,12 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) - waitForFeeDisplayed() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert network fee is displayed") { waitForFeeDisplayed() } step("Click on 'Swap tokens' (reverse) button") { onSwapTokenScreen { replaceTokensButton.performClick() } } - assertTransferReady() + step("Assert Transfer mode is ready") { assertTransferReady() } } } @@ -197,11 +168,11 @@ class AppTransfersTest : BaseTestCase() { step("Click on 'Max' amount button") { onSwapTokenScreen { maxAmountButton.performClick() } } - waitForFeeDisplayed() - assertTransferReady() + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Assert Transfer mode is ready") { assertTransferReady() } step("Assert 'Transfer' button is enabled") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapTokenScreen { transferButton.assertIsEnabled() } }.isSuccess + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { transferButton.assertIsEnabled() } } } } @@ -235,11 +206,11 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(aboveBalanceAmount) + step("Enter amount '$aboveBalanceAmount'") { inputAmount(aboveBalanceAmount) } // Above-balance recalculates the fee forever (Compose never idles), so assert the "Insufficient funds" title, not button state. step("Assert 'Insufficient funds' is displayed") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } }.isSuccess + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } } } } @@ -273,28 +244,27 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) - waitForFeeDisplayed() - assertTransferReady() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Assert Transfer mode is ready") { assertTransferReady() } var marketFee = "" step("Read displayed 'Market' fee amount") { onSwapTokenScreen { marketFee = feeAmount.extractText() } } step("Open 'Network fee' selector via 'Select fee' icon") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapTokenScreen { selectFeeIcon.performClick() } } - runCatching { onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } }.isSuccess + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { selectFeeIcon.performClick() } + onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } } } step("Click on 'Fast' fee option") { onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() } } step("Assert fee amount changed from Market fee") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { - onSwapTokenScreen { feeAmount.assertIsDisplayed() } - }.isSuccess && onSwapTokenScreenFeeDiffers(marketFee) + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { feeAmount.assertIsDisplayed() } + check(swapFeeDiffersFrom(marketFee)) { "Network fee did not change from '$marketFee'" } } } } @@ -336,9 +306,9 @@ class AppTransfersTest : BaseTestCase() { step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent) } - inputAmount(amount) - assertTransferReady() - waitForFeeDisplayed() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } } } @@ -373,9 +343,9 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) - assertTransferReady() - waitForFeeDisplayed() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } } } @@ -425,14 +395,12 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) + step("Enter amount '$amount'") { inputAmount(amount) } step("Assert 'Insufficient $feeCoinName to cover network fee' notification is displayed") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { - onSwapTokenScreen { - insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed() - } - }.isSuccess + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { + insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed() + } } } } @@ -477,8 +445,8 @@ class AppTransfersTest : BaseTestCase() { onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(polygonQuery) } } step("Assert '$polygonReceiveName' is displayed") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() } }.isSuccess + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() } } } step("Assert '$ethereumToken' is not displayed") { @@ -486,10 +454,4 @@ class AppTransfersTest : BaseTestCase() { } } } - - private fun BaseTestCase.onSwapTokenScreenFeeDiffers(previousFee: String): Boolean { - var current = "" - onSwapTokenScreen { current = feeAmount.extractText() } - return current.isNotEmpty() && current != previousFee - } } \ No newline at end of file From 01b7433542bae83c2660b2b48d2249b17b716266 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 10:25:19 +0300 Subject: [PATCH 04/22] Updated on 2026-08-14 --- .../model/TangemPayEditDisplayNameModel.kt | 5 +- .../TangemPayEditDisplayNameModelTest.kt | 167 ++++++++++++++++++ 2 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModelTest.kt diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt index b9d33268ec..f0419494f2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -107,9 +107,8 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( } private fun onValueChanged(value: TextFieldValue) { - val displayName = CardDisplayName(value.text) - val isAvailableForConfirm = displayName.isRight() - uiState.update { it.copy(editingValue = value, isDoneEnabled = isAvailableForConfirm) } + if (value.text.length > CardDisplayName.MAX_LENGTH) return + uiState.update { it.copy(editingValue = value, isDoneEnabled = value.text.isNotBlank()) } } private fun onDoneClick() { diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModelTest.kt new file mode 100644 index 0000000000..9cf134e92b --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModelTest.kt @@ -0,0 +1,167 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.ui.text.input.TextFieldValue +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase +import com.tangem.features.tangempay.TangemPayFeatureToggles +import com.tangem.features.tangempay.components.TangemPayEditDisplayNameComponent +import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.emptyFlow +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +internal class TangemPayEditDisplayNameModelTest { + + private val cardId = "card_1" + private val userWalletId = UserWalletId("123") + + private val router: Router = mockk(relaxed = true) + private val updateCardNameUseCase: UpdateTangemPayCardNameUseCase = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + private val featureToggles: TangemPayFeatureToggles = mockk(relaxed = true) + private val cardDetailsControllerFactory: TangemPayCardDetailsController.Factory = mockk(relaxed = true) + + init { + every { paymentAccountStatusSupplier.invoke(any()) } returns emptyFlow() + } + + private fun createModel(displayName: CardDisplayName? = null) = TangemPayEditDisplayNameModel( + paramsContainer = MutableParamsContainer( + TangemPayEditDisplayNameComponent.Params( + card = card(displayName = displayName), + userWalletId = userWalletId, + ), + ), + dispatchers = TestingCoroutineDispatcherProvider(), + router = router, + updateCardNameUseCase = updateCardNameUseCase, + uiMessageSender = uiMessageSender, + paymentAccountStatusSupplier = paymentAccountStatusSupplier, + featureToggles = featureToggles, + cardDetailsControllerFactory = cardDetailsControllerFactory, + ) + + @Nested + inner class OnValueChanged { + + // [REDACTED_TASK_KEY]: invalid-but-present input must NOT disable the button, otherwise the + // user can never press Done to see the "Invalid characters" alert. + @Test + fun `GIVEN emoji input WHEN onValueChanged THEN button stays enabled`() { + val model = createModel() + + model.uiState.value.onValueChanged(TextFieldValue("Card 😀")) + + assertThat(model.uiState.value.isDoneEnabled).isTrue() + } + + @Test + fun `GIVEN special characters input WHEN onValueChanged THEN button stays enabled`() { + val model = createModel() + + model.uiState.value.onValueChanged(TextFieldValue("Card #1!")) + + assertThat(model.uiState.value.isDoneEnabled).isTrue() + } + + @Test + fun `GIVEN valid input WHEN onValueChanged THEN button enabled`() { + val model = createModel() + + model.uiState.value.onValueChanged(TextFieldValue("My Card")) + + assertThat(model.uiState.value.isDoneEnabled).isTrue() + } + + @Test + fun `GIVEN blank input WHEN onValueChanged THEN button disabled`() { + val model = createModel() + + model.uiState.value.onValueChanged(TextFieldValue(" ")) + + assertThat(model.uiState.value.isDoneEnabled).isFalse() + } + + @Test + fun `GIVEN input longer than max length WHEN onValueChanged THEN change is ignored`() { + val model = createModel() + val maxLengthText = "a".repeat(CardDisplayName.MAX_LENGTH) + model.uiState.value.onValueChanged(TextFieldValue(maxLengthText)) + + model.uiState.value.onValueChanged(TextFieldValue(maxLengthText + "b")) + + assertThat(model.uiState.value.editingValue.text).isEqualTo(maxLengthText) + } + } + + @Nested + inner class OnDoneClick { + + // [REDACTED_TASK_KEY]: pressing Done on an invalid name surfaces the "Invalid characters" alert + // and does not persist the name. + @Test + fun `GIVEN invalid name WHEN onDoneClick THEN error dialog shown and name not updated`() { + val model = createModel() + model.uiState.value.onValueChanged(TextFieldValue("Card 😀")) + + model.uiState.value.onDoneClick() + + verify(exactly = 1) { uiMessageSender.send(any()) } + coVerify(exactly = 0) { updateCardNameUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN valid changed name WHEN onDoneClick THEN name updated`() { + coEvery { updateCardNameUseCase(any(), any(), any()) } returns Unit.right() + val model = createModel() + model.uiState.value.onValueChanged(TextFieldValue("New Name")) + + model.uiState.value.onDoneClick() + + coVerify(exactly = 1) { + updateCardNameUseCase(cardId, userWalletId, CardDisplayName("New Name").getOrNull()!!) + } + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN unchanged name WHEN onDoneClick THEN screen closed without update`() { + val model = createModel(displayName = CardDisplayName("My Card").getOrNull()) + + model.uiState.value.onDoneClick() + + verify(exactly = 1) { router.pop() } + coVerify(exactly = 0) { updateCardNameUseCase(any(), any(), any()) } + } + } + + private fun card(displayName: CardDisplayName? = null) = TangemPayCard( + id = cardId, + productInstanceId = "product", + cardStatus = TangemPayCard.Status.ACTIVE, + hasPinCode = true, + displayName = displayName, + limit = null, + frozenState = TangemPayCardFrozenState.Unfrozen, + lastDigits = "1234", + state = TangemPayCardState.Active, + ) +} \ No newline at end of file From ee599a99c419e74acdbb883e76422989c8b1be12 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:48:05 +0500 Subject: [PATCH 05/22] Updated on 2026-08-14 --- ...DefaultYieldSupplyTransactionRepository.kt | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt index ac1c2517a6..92f845f1ea 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt @@ -18,8 +18,10 @@ import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext import java.math.BigDecimal @@ -97,7 +99,7 @@ internal class DefaultYieldSupplyTransactionRepository( cryptoCurrency: CryptoCurrency, ): BigDecimal? = withContext(dispatchers.io) { require(cryptoCurrency is CryptoCurrency.Token) - runCatching { + runSuspendCatching { val walletManager = walletManagersFacade.getOrCreateWalletManager( userWalletId = userWalletId, blockchain = cryptoCurrency.network.toBlockchain(), @@ -110,7 +112,7 @@ internal class DefaultYieldSupplyTransactionRepository( decimals = cryptoCurrency.decimals, ), ) - }.onFailure { TangemLogger.e("Error", it) }.getOrThrow() + }.logErrorUnlessCancellation().getOrThrow() } @Suppress("LongParameterList") @@ -201,27 +203,27 @@ internal class DefaultYieldSupplyTransactionRepository( cryptoCurrency: CryptoCurrency, ): String? = withContext(dispatchers.io) { require(cryptoCurrency is CryptoCurrency.Token) - runCatching { + runSuspendCatching { val walletManager = walletManagersFacade.getOrCreateWalletManager( userWalletId = userWalletId, blockchain = cryptoCurrency.network.toBlockchain(), derivationPath = cryptoCurrency.network.derivationPath.value, ) ?: error("Wallet manager not found") walletManager.calculateYieldModuleAddress() - }.onFailure { TangemLogger.e("Error", it) }.getOrThrow() + }.logErrorUnlessCancellation().getOrThrow() } override suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? = withContext(dispatchers.io) { require(cryptoCurrency is CryptoCurrency.Token) - runCatching { + runSuspendCatching { val walletManager = walletManagersFacade.getOrCreateWalletManager( userWalletId = userWalletId, blockchain = cryptoCurrency.network.toBlockchain(), derivationPath = cryptoCurrency.network.derivationPath.value, ) ?: error("Wallet manager not found") walletManager.getYieldModuleAddress() - }.onFailure { TangemLogger.e("Error", it) }.getOrThrow() + }.logErrorUnlessCancellation().getOrThrow() } override suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded( @@ -242,7 +244,7 @@ internal class DefaultYieldSupplyTransactionRepository( walletManager: WalletManager, cryptoCurrency: CryptoCurrency.Token, ): YieldSupplyStatus? = withContext(dispatchers.io) { - runCatching { + runSuspendCatching { val sdkSupplyStatus = walletManager.getYieldSupplyStatus(cryptoCurrency.contractAddress) val protocolBalance = if (sdkSupplyStatus?.isActive == true) { walletManager.getEffectiveProtocolBalance( @@ -269,7 +271,20 @@ internal class DefaultYieldSupplyTransactionRepository( isAllowedToSpend = isAllowedToSpend, effectiveProtocolBalance = protocolBalance, ) - }.onFailure { TangemLogger.e("Error", it) }.getOrNull() + }.logErrorUnlessCancellation().getOrThrow() + } + + /** + * Logs failures via [TangemLogger] but rethrows coroutine cancellation so a cancelled scope + * (e.g. leaving the yield-supply screen) cancels cleanly instead of being reported as a real + * error and swallowed to `null`. Covers both a raw [CancellationException] and one wrapped by + * the blockchain SDK as [BlockchainSdkError.WrappedThrowable] (carried in [Throwable.cause]), + * which is what `EthereumLikeJsonRpcProvider.post` throws when an in-flight RPC is cancelled. + */ + private fun Result.logErrorUnlessCancellation(): Result = onFailure { error -> + val cancellation = error as? CancellationException ?: error.cause as? CancellationException + if (cancellation != null) throw cancellation + TangemLogger.e("Error", error) } private fun createDeployTransaction( From f229849259ee771e2b8a0c75936d54b64c836046 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 15:18:24 +0500 Subject: [PATCH 06/22] Updated on 2026-08-14 --- .../DefaultCustomerOffersRepositoryTest.kt | 2 +- .../kotlin/com/tangem/domain/pay/model/OrderType.kt | 6 ++---- .../domain/pay/model/OrderConflictRulesTest.kt | 4 ++-- .../pay/usecase/CheckOrderConflictUseCaseTest.kt | 2 +- .../pay/usecase/IssueAdditionalCardUseCaseTest.kt | 13 +++++-------- .../usecase/RestoreActiveIssueOrdersUseCaseTest.kt | 4 ++-- 6 files changed, 13 insertions(+), 18 deletions(-) diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt index c4a97ce566..0513c84f3c 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt @@ -32,7 +32,7 @@ internal class DefaultCustomerOffersRepositoryTest { fee = CustomerOffersResponse.Fee(amount = BigDecimal("1.00"), currency = "USD"), data = CustomerOffersResponse.Data( specificationName = "SP_000004", - orderType = "CARD_ISSUE_ADDITIONAL", + orderType = "CARD_ISSUE_VIRTUAL_RAIN_KYC", ), ), ), diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt index c5cf104ae8..432d0ea5e0 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt @@ -33,12 +33,10 @@ enum class OrderType(val wireValue: String) { companion object { /** - * All order types that represent issuing a card: the first virtual card (and its KYC - * variants) and an additional card. Used both to filter `findOrders` and to detect - * issue-card conflicts. + * All order types that represent issuing a card: the virtual card and its KYC variants. + * Used both to filter `findOrders` and to detect issue-card conflicts. */ val issueCardTypes = setOf( - CARD_ISSUE_ADDITIONAL, CARD_ISSUE_VIRTUAL_RAIN, CARD_ISSUE_VIRTUAL_RAIN_KYC, CARD_ISSUE_VIRTUAL_RAIN_KYC_V2, diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt index d0a4c4895a..1ed00d3f7b 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt @@ -18,8 +18,8 @@ internal class OrderConflictRulesTest { } @Test - fun `IssueCard is blocked by an active additional-issue order`() { - val active = listOf(order(type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.NEW)) + fun `IssueCard is blocked by an active KYC v2 issue order`() { + val active = listOf(order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2, status = OrderStatus.NEW)) val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, active) diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt index 270eda776c..d36c36616e 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt @@ -30,7 +30,7 @@ internal class CheckOrderConflictUseCaseTest { @Test fun `WHEN active issue order exists AND intent is IssueCard THEN returns Blocked`() = runTest { - val activeIssue = order(type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.PROCESSING) + val activeIssue = order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING) coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = ACTIVE_STATUSES) } returns listOf(activeIssue).right() diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt index 5200c2b329..e64a03b9d1 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt @@ -40,7 +40,7 @@ internal class IssueAdditionalCardUseCaseTest { private val offer = Offer( type = Offer.Type.CARD_ISSUE_VIRTUAL_RAIN, fee = Offer.Fee(amount = BigDecimal("1.00"), currency = Currency.getInstance("USD")), - data = Offer.Data(specificationName = spec, orderType = OrderType.CARD_ISSUE_ADDITIONAL), + data = Offer.Data(specificationName = spec, orderType = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC), ) @Test @@ -58,7 +58,7 @@ internal class IssueAdditionalCardUseCaseTest { fun `WHEN active issue order exists THEN reuses it without calling createOrder`() = runTest { val existing = order( id = "existing", - type = OrderType.CARD_ISSUE_ADDITIONAL, + type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING, ) coEvery { offersRepository.getOffers(userWalletId) } returns listOf(offer).right() @@ -66,7 +66,6 @@ internal class IssueAdditionalCardUseCaseTest { orderRepository.findOrders( userWalletId, types = setOf( - OrderType.CARD_ISSUE_ADDITIONAL, OrderType.CARD_ISSUE_VIRTUAL_RAIN, OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2, @@ -89,7 +88,6 @@ internal class IssueAdditionalCardUseCaseTest { orderRepository.findOrders( userWalletId, types = setOf( - OrderType.CARD_ISSUE_ADDITIONAL, OrderType.CARD_ISSUE_VIRTUAL_RAIN, OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2, @@ -100,7 +98,7 @@ internal class IssueAdditionalCardUseCaseTest { coEvery { orderRepository.createOrder( userWalletId = userWalletId, - type = OrderType.CARD_ISSUE_ADDITIONAL, + type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, specificationName = spec, idempotencyKey = any(), ) @@ -118,7 +116,6 @@ internal class IssueAdditionalCardUseCaseTest { orderRepository.findOrders( userWalletId, types = setOf( - OrderType.CARD_ISSUE_ADDITIONAL, OrderType.CARD_ISSUE_VIRTUAL_RAIN, OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2, @@ -128,13 +125,13 @@ internal class IssueAdditionalCardUseCaseTest { } returns emptyList().right() val newOrder = order( id = "new", - type = OrderType.CARD_ISSUE_ADDITIONAL, + type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.NEW, ) coEvery { orderRepository.createOrder( userWalletId = userWalletId, - type = OrderType.CARD_ISSUE_ADDITIONAL, + type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, specificationName = spec, idempotencyKey = any(), ) diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt index bfbfecfd1a..a6d1b46934 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt @@ -34,7 +34,7 @@ internal class RestoreActiveIssueOrdersUseCaseTest { @Test fun `GIVEN active issue orders WHEN invoke THEN each order is stored and polled`() = runTest { // Arrange - val first = order(id = "first", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.NEW) + val first = order(id = "first", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN, status = OrderStatus.NEW) val second = order(id = "second", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING) coEvery { orderRepository.findOrders( @@ -78,7 +78,7 @@ internal class RestoreActiveIssueOrdersUseCaseTest { @Test fun `GIVEN a terminal order leaks through WHEN invoke THEN it is filtered out`() = runTest { // Arrange - val completed = order(id = "done", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.COMPLETED) + val completed = order(id = "done", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN, status = OrderStatus.COMPLETED) coEvery { orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES) } returns listOf(completed).right() From dacc49b17e2929be67393fa322a87ca41d577e87 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 14:47:02 +0400 Subject: [PATCH 07/22] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractor.kt | 3 + .../feature/swap/domain/SwapInteractorImpl.kt | 18 +++- ...pInteractorImplFindProvidersForPairTest.kt | 90 +++++++++++++++++++ .../tangem/feature/swap/model/SwapModel.kt | 68 +++++++++++++- .../swap/model/SwapNotificationsFactory.kt | 14 +++ .../swap/models/states/SwapNotificationUM.kt | 22 +++++ .../tangem/feature/swap/ui/StateBuilder.kt | 31 ++++++- 7 files changed, 241 insertions(+), 5 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 830560dcc8..09b5ce82af 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -7,6 +7,7 @@ import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData @@ -29,6 +30,8 @@ interface SwapInteractor { pairs: List, ): List + suspend fun getUnfulfilledReceiveRequirement(toSwapCurrencyStatus: SwapCurrencyStatus): AssetRequirementsCondition? + fun findProvidersForPair( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9464e5f5b1..554c14c48a 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -46,6 +46,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.transaction.usecase.* import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.utils.convertToSdkAmount @@ -207,12 +208,14 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, pairs: List, ): List { - val requirements = getAssetRequirementsUseCase.invoke( + val fromRequirements = getAssetRequirementsUseCase.invoke( fromSwapCurrencyStatus.userWalletId, fromSwapCurrencyStatus.currency, ).getOrNull() - if (!rampStateManager.checkAssetRequirements(requirements)) { + val isToFulfilled = getUnfulfilledReceiveRequirement(toSwapCurrencyStatus) == null + + if (!rampStateManager.checkAssetRequirements(fromRequirements) || !isToFulfilled) { return emptyList() } @@ -223,6 +226,17 @@ internal class SwapInteractorImpl @Inject constructor( ) } + override suspend fun getUnfulfilledReceiveRequirement( + toSwapCurrencyStatus: SwapCurrencyStatus, + ): AssetRequirementsCondition? { + val requirements = getAssetRequirementsUseCase.invoke( + toSwapCurrencyStatus.userWalletId, + toSwapCurrencyStatus.currency, + ).getOrNull() + + return requirements?.takeUnless { rampStateManager.checkAssetRequirements(it) } + } + override suspend fun findBestQuote( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt index 1f7e5c8d85..234f50b740 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt @@ -4,6 +4,7 @@ import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import io.mockk.coEvery import io.mockk.every @@ -156,5 +157,94 @@ internal class SwapInteractorImplFindProvidersForPairTest : SwapInteractorImplTe // Then assertThat(result).containsExactly(providerA, providerB) } + + @Test + fun `should return empty list when destination asset requires association even if source is fulfilled`() = + runTest { + // Arrange — source has no requirements, but the destination (e.g. unassociated Hedera HTS token) + // requires an on-chain opt-in. Without this check the swap would proceed and the payout would + // get stuck (AND-Hedera ERC20/HTS association). + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus( + networkRawId = btcNetwork, + contractAddress = "0xAbc", + isCoin = false, + ) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0xAbc", + providers = listOf(buildSwapProvider(ExchangeProviderType.CEX, "A")), + ) + + val toRequirement = AssetRequirementsCondition.PaidTransaction + coEvery { + getAssetRequirementsUseCase.invoke(any(), fromStatus.currency) + } returns null.right() + coEvery { + getAssetRequirementsUseCase.invoke(any(), toStatus.currency) + } returns toRequirement.right() + every { rampStateManager.checkAssetRequirements(null) } returns true + every { rampStateManager.checkAssetRequirements(toRequirement) } returns false + + // Act + val result = sut.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(pair), + ) + + // Assert + assertThat(result).isEmpty() + } + } + + @Nested + inner class GetUnfulfilledReceiveRequirement { + + @Test + fun `should return requirement when destination asset requirement is not fulfilled`() = runTest { + // Arrange + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false) + val requirement = AssetRequirementsCondition.PaidTransaction + coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns requirement.right() + every { rampStateManager.checkAssetRequirements(requirement) } returns false + + // Act + val result = sut.getUnfulfilledReceiveRequirement(toStatus) + + // Assert + assertThat(result).isEqualTo(requirement) + } + + @Test + fun `should return null when destination asset requirement is fulfilled`() = runTest { + // Arrange + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false) + val requirement = AssetRequirementsCondition.PaidTransaction + coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns requirement.right() + every { rampStateManager.checkAssetRequirements(requirement) } returns true + + // Act + val result = sut.getUnfulfilledReceiveRequirement(toStatus) + + // Assert + assertThat(result).isNull() + } + + @Test + fun `should return null when there is no destination asset requirement`() = runTest { + // Arrange + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false) + coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns null.right() + every { rampStateManager.checkAssetRequirements(null) } returns true + + // Act + val result = sut.getUnfulfilledReceiveRequirement(toStatus) + + // Assert + assertThat(result).isNull() + } } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index c1b113f3c2..a2a1637ac1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -78,6 +78,7 @@ import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase @@ -650,7 +651,7 @@ internal class SwapModel @Inject constructor( pairs = dataState.pairs, ) if (toProvidersList.isEmpty()) { - handleSwapNotSupported( + handlePairUnavailable( fromSwapCurrencyStatus = newFromSwapCurrencyStatus, toSwapCurrencyStatus = newToSwapCurrencyStatus, ) @@ -714,7 +715,7 @@ internal class SwapModel @Inject constructor( pairs = pairs, ) if (providerList.isEmpty()) { - handleSwapNotSupported( + handlePairUnavailable( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, ) @@ -927,6 +928,16 @@ internal class SwapModel @Inject constructor( ) return } + + if (toProvidersList.isEmpty()) { + modelScope.launch { + handlePairUnavailable( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + return + } if (!isSilent) { uiState = stateBuilder.createQuotesLoadingState( fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -1318,6 +1329,15 @@ internal class SwapModel @Inject constructor( return } modelScope.launch(dispatchers.main) { + val toRequirement = swapInteractor.getUnfulfilledReceiveRequirement(toSwapCurrencyStatus) + if (toRequirement != null) { + handleDestinationRequirementBlocked( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + requirement = toRequirement, + ) + return@launch + } runCatching(dispatchers.io) { swapInteractor.onSwap( fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -2238,6 +2258,50 @@ internal class SwapModel @Inject constructor( ) } + private suspend fun handlePairUnavailable( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ) { + val toRequirement = swapInteractor.getUnfulfilledReceiveRequirement(toSwapCurrencyStatus) + if (toRequirement != null) { + handleDestinationRequirementBlocked( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + requirement = toRequirement, + ) + } else { + handleSwapNotSupported( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + } + + private fun handleDestinationRequirementBlocked( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + requirement: AssetRequirementsCondition, + ) { + singleTaskScheduler.cancelTask() + lastReducedBalanceBy.value = BigDecimal.ZERO + lastAmount.value = INITIAL_AMOUNT + isFiatInput.value = false + uiState = stateBuilder.createDestinationRequirementBlockedState( + uiStateHolder = uiState, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + requirement = requirement, + onAssociateClick = { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = toSwapCurrencyStatus.userWalletId, + currency = toSwapCurrencyStatus.currency, + ), + ) + }, + ) + } + private fun handleSwapNotSupported( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 86595797c2..1d2ca68bb9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -20,6 +20,7 @@ import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -78,6 +79,19 @@ internal class SwapNotificationsFactory( ) } + fun getDestinationRequirementNotifications( + requirement: AssetRequirementsCondition, + onAssociateClick: () -> Unit, + ): ImmutableList { + val notification = when (requirement) { + is AssetRequirementsCondition.RequiredTrustline -> + SwapNotificationUM.Warning.TokenTrustlineRequired(onAssociateClick) + else -> + SwapNotificationUM.Warning.TokenAssociationRequired(onAssociateClick) + } + return persistentListOf(notification) + } + fun getQuotesErrorStateNotifications( expressDataError: ExpressDataError, fromToken: CryptoCurrency, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 0769f7b5b7..59ef6fac05 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -149,6 +149,28 @@ internal object SwapNotificationUM { ), ) + data class TokenAssociationRequired( + val onAssociateClick: () -> Unit, + ) : Warning( + title = resourceReference(R.string.warning_hedera_missing_token_association_title), + subtitle = resourceReference(R.string.warning_receive_blocked_hedera_token_association_required_message), + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_hedera_missing_token_association_button_title), + onClick = onAssociateClick, + ), + ) + + data class TokenTrustlineRequired( + val onAssociateClick: () -> Unit, + ) : Warning( + title = resourceReference(R.string.warning_token_trustline_title), + subtitle = resourceReference(R.string.warning_receive_blocked_token_trustline_required_message), + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_token_trustline_button_title), + onClick = onAssociateClick, + ), + ) + data class NeedReserveToCreateAccount( val receiveAmount: String, val receiveToken: String, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index a7fe90f04a..dbb2a99da1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -35,6 +35,7 @@ import com.tangem.domain.swap.models.PredefinedPercentAmount import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.Amount import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.converters.SwapProviderResolver import com.tangem.feature.swap.converters.SwapProviderStateBuilder @@ -455,6 +456,34 @@ internal class StateBuilder( uiStateHolder: SwapStateHolder, fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, + ): SwapStateHolder = createBlockedSwapState( + uiStateHolder = uiStateHolder, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + notifications = notificationsFactory.getSwapNotSupportedNotifications(), + ) + + fun createDestinationRequirementBlockedState( + uiStateHolder: SwapStateHolder, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + requirement: AssetRequirementsCondition, + onAssociateClick: () -> Unit, + ): SwapStateHolder = createBlockedSwapState( + uiStateHolder = uiStateHolder, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + notifications = notificationsFactory.getDestinationRequirementNotifications( + requirement = requirement, + onAssociateClick = onAssociateClick, + ), + ) + + private fun createBlockedSwapState( + uiStateHolder: SwapStateHolder, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + notifications: ImmutableList, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( @@ -482,7 +511,7 @@ internal class StateBuilder( isBalanceHidden = isBalanceHiddenProvider(), appCurrency = appCurrencyProvider(), ), - notifications = notificationsFactory.getSwapNotSupportedNotifications(), + notifications = notifications, swapButton = SwapButton( walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, From e338fb8fdcadb97ef6c66d273735185fb86d26e4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 11:54:27 +0100 Subject: [PATCH 08/22] Updated on 2026-08-14 --- features/swap/domain/build.gradle.kts | 1 + .../swap/domain/models/ui/SwapState.kt | 1 + .../domain/transfer/SwapTransferInteractor.kt | 2 + .../transfer/SwapTransferInteractorImpl.kt | 12 + .../SwapTransferInteractorImplTest.kt | 8 + .../tangem/feature/swap/model/SwapModel.kt | 15 +- .../feature/swap/models/SwapStateHolder.kt | 1 + .../tangem/feature/swap/models/UiActions.kt | 1 + .../swap/models/states/SwapNotificationUM.kt | 5 + .../tangem/feature/swap/ui/StateBuilder.kt | 1 + .../feature/swap/ui/SwapScreenContent.kt | 15 +- .../SwapTransferNotificationsFactory.kt | 60 ++++- .../ui/transfer/SwapTransferStateBuilder.kt | 22 +- .../feature/swap/StateBuilderQuotesTest.kt | 0 .../feature/swap/StateBuilderSwapDataTest.kt | 0 .../ui/SwapAmountScreenClickIntentsTest.kt | 1 + .../SwapTransferNotificationsFactoryTest.kt | 211 +++++++++++++++--- .../transfer/SwapTransferStateBuilderTest.kt | 63 ++++-- 18 files changed, 351 insertions(+), 68 deletions(-) delete mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt delete mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 13530ac93f..d8f2337725 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation(projects.domain.visa.models) implementation(projects.domain.balanceHiding) implementation(projects.domain.yieldSupply) + implementation(projects.domain.notifications) /** Common modules */ implementation(projects.common.ui) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index a1759b9e86..209108a216 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -54,6 +54,7 @@ sealed interface SwapState { val isAccountsMode: Boolean, val isFeeCoverage: Boolean, val sendingAmount: BigDecimal, + val tronFeeNotificationShowCount: Int, val isSendingAmountLoading: Boolean = false, val currencyCheck: CryptoCurrencyCheck? = null, val validationResult: Throwable? = null, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index fc42b91521..8c4476d24c 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -53,4 +53,6 @@ interface SwapTransferInteractor { cryptoAmount: BigDecimal, toSwapCurrencyStatus: SwapCurrencyStatus, ): Either + + suspend fun incrementTronTokenFeeShowCount(cryptoCurrencyStatus: CryptoCurrencyStatus?) } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 03f6d275be..ac06572551 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -19,6 +19,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.notifications.GetTronFeeNotificationShowCountUseCase +import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase @@ -62,6 +64,8 @@ class SwapTransferInteractorImpl @Inject constructor( private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, + private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase, + private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase, ) : SwapTransferInteractor { override suspend fun updateTransfer( @@ -120,6 +124,7 @@ class SwapTransferInteractorImpl @Inject constructor( feeStatus = feeStatus, ) } + val tronFeeNotificationShowCount = getTronFeeNotificationShowCountUseCase() return SwapState.Transfer( userWallet = userWallet, fromTokenInfo = fromTokenInfo, @@ -131,6 +136,7 @@ class SwapTransferInteractorImpl @Inject constructor( isAccountsMode = isAccountsMode, isFeeCoverage = coverageState.isFeeCoverage, sendingAmount = coverageState.sendingAmount, + tronFeeNotificationShowCount = tronFeeNotificationShowCount, isSendingAmountLoading = coverageState.isSendingAmountLoading, currencyCheck = currencyCheck, ) @@ -390,4 +396,10 @@ class SwapTransferInteractorImpl @Inject constructor( private fun SwapCurrencyStatus.destinationAddress(): String? { return status.value.networkAddress?.defaultAddress?.value } + + override suspend fun incrementTronTokenFeeShowCount(cryptoCurrencyStatus: CryptoCurrencyStatus?) { + cryptoCurrencyStatus?.currency?.let { cryptoCurrency -> + incrementNotificationsShowCountUseCase(cryptoCurrency) + } + } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index cdecb5a391..b140e07270 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -16,6 +16,8 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.notifications.GetTronFeeNotificationShowCountUseCase +import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase @@ -61,6 +63,8 @@ internal class SwapTransferInteractorImplTest { private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk() private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true) + private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase = mockk(relaxed = true) + private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase = mockk(relaxed = true) private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -76,6 +80,8 @@ internal class SwapTransferInteractorImplTest { isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, tangemPayWithdrawUseCase = tangemPayWithdrawUseCase, getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, + getTronFeeNotificationShowCountUseCase = getTronFeeNotificationShowCountUseCase, + incrementNotificationsShowCountUseCase = incrementNotificationsShowCountUseCase, ) @AfterEach @@ -180,6 +186,7 @@ internal class SwapTransferInteractorImplTest { isAccountsMode = true, isFeeCoverage = false, sendingAmount = expectedAmount, + tronFeeNotificationShowCount = 0, currencyCheck = currencyCheck, ) assertThat(result).isEqualTo(expected) @@ -251,6 +258,7 @@ internal class SwapTransferInteractorImplTest { isAccountsMode = true, isFeeCoverage = false, sendingAmount = expectedAmount, + tronFeeNotificationShowCount = 0, currencyCheck = currencyCheck, ) assertThat(result).isEqualTo(expected) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index c1b113f3c2..ddd139e87f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -822,6 +822,7 @@ internal class SwapModel @Inject constructor( uiStateHolder = uiState, feePaidCryptoCurrencyStatus = feePaidCryptoCurrency, fee = selectedFee, + feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error, ) when { uiState.successState != null -> Unit @@ -866,9 +867,10 @@ internal class SwapModel @Inject constructor( transferState = refreshed, actions = actions, uiStateHolder = uiState, - feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, + feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency, fee = fee, isTangemPayWithdrawal = isTangemPayWithdrawal(), + feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error, ) } } @@ -2105,6 +2107,7 @@ internal class SwapModel @Inject constructor( }, onSwapUIModeChange = ::onSwapUIModeChange, onSwapTypeMenuOpened = ::onSwapTypeMenuOpened, + onTronBannerShown = ::incrementTronTokenFeeShowCount, ) } @@ -2132,6 +2135,16 @@ internal class SwapModel @Inject constructor( ) } + private fun incrementTronTokenFeeShowCount() { + // Fired once per banner appearance from the UI (tied to the banner's composition lifecycle), + // so the show-count advances per appearance rather than on every transfer-state rebuild. + modelScope.launch { + swapTransferInteractor.incrementTronTokenFeeShowCount( + cryptoCurrencyStatus = dataState.fromSwapCurrencyStatus?.status, + ) + } + } + private fun selectWalletInSelector( fromSwapCurrencyStatus: SwapCurrencyStatus?, toSwapCurrencyStatus: SwapCurrencyStatus?, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 65ae024ca4..ad200c6f48 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -49,6 +49,7 @@ internal data class SwapStateHolder( val onShowPermissionBottomSheet: () -> Unit = {}, val onSwapUIModeChange: (SwapUIMode) -> Unit = {}, val onSwapTypeMenuOpened: () -> Unit = {}, + val onTronBannerShown: () -> Unit = {}, ) @Immutable diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index 97fe358356..1e01c3a4aa 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -34,4 +34,5 @@ internal data class UiActions( val onReceiveCardWarningClick: () -> Unit, val onSwapUIModeChange: (SwapUIMode) -> Unit, val onSwapTypeMenuOpened: () -> Unit, + val onTronBannerShown: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 0769f7b5b7..a195f9bd4a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -251,5 +251,10 @@ internal object SwapNotificationUM { onClick = onApproveClick, ), ) + + data object TronTokenFee : Info( + title = resourceReference(R.string.tron_will_be_send_token_fee_title), + subtitle = resourceReference(R.string.tron_will_be_send_token_fee_description), + ) } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index a7fe90f04a..726e1c5a5b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -122,6 +122,7 @@ internal class StateBuilder( swapUIMode = swapUIMode, onSwapUIModeChange = actions.onSwapUIModeChange, onSwapTypeMenuOpened = actions.onSwapTypeMenuOpened, + onTronBannerShown = actions.onTronBannerShown, shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index de4b81df32..4f15adb4cd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -94,7 +94,12 @@ internal fun SwapScreenContent( feeBlock?.invoke(Modifier.fillMaxWidth()) - if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications) + if (state.notifications.isNotEmpty()) { + SwapNotifications( + notifications = state.notifications, + onTronBannerShown = state.onTronBannerShown, + ) + } SpacerHMax() @@ -342,7 +347,13 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) { @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -private fun SwapNotifications(notifications: List) { +private fun SwapNotifications(notifications: List, onTronBannerShown: () -> Unit) { + // The Tron token-fee banner's show-count is an "impression": tied to actual on-screen visibility. + // LaunchedEffect re-arms only when the boolean flips, so it fires once per hidden -> shown appearance. + val isTronBannerShown = notifications.any { it is SwapNotificationUM.Info.TronTokenFee } + LaunchedEffect(isTronBannerShown) { + if (isTronBannerShown) onTronBannerShown() + } Column( modifier = Modifier .background(color = TangemTheme.colors.background.secondary) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt index 5ae7859709..9508fd1e4b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -6,18 +6,22 @@ import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNot import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.lib.crypto.BlockchainUtils import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold import com.tangem.lib.crypto.BlockchainUtils.isTezos +import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList @@ -31,9 +35,8 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { transferState: SwapState.Transfer, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, fee: Fee?, - onReduceByAmount: (SwapAmount, BigDecimal) -> Unit, - onReduceToAmount: (SwapAmount) -> Unit, - onBuyClick: (CryptoCurrency) -> Unit, + actions: UiActions, + getFeeError: GetFeeError?, ): ImmutableList { return buildList { maybeAddRentExemptionError(transferState) @@ -41,11 +44,21 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { state = transferState, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = fee, - onReduceByAmount = onReduceByAmount, - onReduceToAmount = onReduceToAmount, + onReduceByAmount = actions.onReduceByAmount, + onReduceToAmount = actions.onReduceToAmount, ) maybeAddNeedReserveToCreateAccountWarning(transferState) - maybeAddExceedsBalanceNotification(transferState, onBuyClick) + maybeAddExceedsBalanceNotification(transferState, onBuyClick = actions.openTokenDetailsScreen) + addTronNetworkFeesNotification( + cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status, + transferState = transferState, + ) + maybeAddFeeUnreachableNotification( + transferState = transferState, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + feeError = getFeeError, + actions = actions, + ) }.toPersistentList() } @@ -194,4 +207,39 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { onResetAnalyticsEvent = {}, ) } + + private fun MutableList.addTronNetworkFeesNotification( + cryptoCurrencyStatus: CryptoCurrencyStatus, + transferState: SwapState.Transfer, + ) { + val cryptoCurrency = cryptoCurrencyStatus.currency + val isTronToken = cryptoCurrency is CryptoCurrency.Token && isTron(cryptoCurrency.network.rawId) + val isVisible = isTronToken && + transferState.tronFeeNotificationShowCount <= TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT + + if (isVisible) { + add(SwapNotificationUM.Info.TronTokenFee) + } + } + + private fun MutableList.maybeAddFeeUnreachableNotification( + transferState: SwapState.Transfer, + feeCryptoCurrencyStatus: CryptoCurrencyStatus?, + feeError: GetFeeError?, + actions: UiActions, + ) { + feeCryptoCurrencyStatus ?: return + addFeeUnreachableNotification( + tokenStatus = transferState.fromTokenInfo.swapCurrencyStatus.status, + coinStatus = feeCryptoCurrencyStatus, + feeError = feeError, + dustValue = transferState.currencyCheck?.dustValue, + onReload = actions.onRetryClick, + onClick = actions.openTokenDetailsScreen, + ) + } + + companion object { + private const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3 + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 10a023634f..96b938cd3b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -37,6 +37,7 @@ import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents import com.tangem.feature.swap.ui.swapSuccessNavigation +import com.tangem.features.send.api.entity.FeeSelectorUM import com.tangem.features.send.api.utils.formatFooterFiatFee import com.tangem.features.send.api.utils.getTronTokenFeeSendingText import com.tangem.utils.extensions.orZero @@ -52,12 +53,14 @@ internal class SwapTransferStateBuilder @Inject constructor( private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter) + @Suppress("LongParameterList") fun createTransferState( actions: UiActions, transferState: SwapState.Transfer, uiStateHolder: SwapStateHolder, feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?, fee: Fee?, + feeError: FeeSelectorUM.Error?, ): SwapStateHolder { val fromTokenSwapInfo = transferState.fromTokenInfo val isInsufficientBalance = transferState.isInsufficientBalance @@ -67,9 +70,8 @@ internal class SwapTransferStateBuilder @Inject constructor( transferState = transferState, feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, - onBuyClick = actions.openTokenDetailsScreen, - onReduceByAmount = actions.onReduceByAmount, - onReduceToAmount = actions.onReduceToAmount, + actions = actions, + getFeeError = feeError?.error, ) return uiStateHolder.copy( sendCardData = createSendSwapCardState( @@ -342,14 +344,14 @@ internal class SwapTransferStateBuilder @Inject constructor( feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?, fee: Fee?, isTangemPayWithdrawal: Boolean, + feeError: FeeSelectorUM.Error?, ): SwapStateHolder { val notifications = notificationsFactory.getNotifications( transferState = transferState, feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, - onBuyClick = actions.openTokenDetailsScreen, - onReduceByAmount = actions.onReduceByAmount, - onReduceToAmount = actions.onReduceToAmount, + actions = actions, + getFeeError = feeError?.error, ) return uiStateHolder.copy( notifications = notifications, @@ -395,7 +397,8 @@ internal class SwapTransferStateBuilder @Inject constructor( val fiatAmountValue = tokenSwapInfo.amountFiat val status = dataState.fromSwapCurrencyStatus?.status ?: return null - val fiatFeeValue = fee.amount.value + val value = dataState.feePaidCryptoCurrency?.value + val fiatFeeValue = value?.fiatRate?.multiply(fee.amount.value) val isFeeConvertibleToFiat = status.currency.network.hasFiatFeeRate val fiatSendingValue = if (isFeeConvertibleToFiat) { @@ -412,8 +415,11 @@ internal class SwapTransferStateBuilder @Inject constructor( } val networkId = status.currency.network.id + // When the fee is convertible to fiat, show the fiat-converted value; otherwise keep the raw + // crypto fee amount — formatFooterFiatFee renders amount.value as crypto in the non-fiat case. + val feeAmount = if (isFeeConvertibleToFiat) fee.amount.copy(value = fiatFeeValue) else fee.amount val fiatFee = formatFooterFiatFee( - amount = fee.amount.copy(value = fiatFeeValue), + amount = feeAmount, isFeeConvertibleToFiat = isFeeConvertibleToFiat, isFeeApproximate = isFeeApproximateUseCase(networkId = networkId, amountType = fee.amount.type), appCurrency = appCurrency, diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt index 90da2f7079..ffaf87dce5 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt @@ -37,6 +37,7 @@ internal class SwapAmountScreenClickIntentsTest { onReceiveCardWarningClick = {}, onSwapUIModeChange = {}, onSwapTypeMenuOpened = {}, + onTronBannerShown = {}, ) private val sut = SwapAmountScreenClickIntents(actions) diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt index f541df7896..f415d9db37 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt @@ -12,9 +12,11 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.transaction.error.GetFeeError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.states.SwapNotificationUM import io.mockk.every import io.mockk.mockk @@ -28,6 +30,8 @@ internal class SwapTransferNotificationsFactoryTest { private val sut = SwapTransferNotificationsFactory() + private val actions: UiActions = mockk(relaxed = true) + private val userWalletId = UserWalletId(stringValue = "deadbeef") private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { every { walletId } returns userWalletId @@ -41,9 +45,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result).isEmpty() @@ -64,9 +67,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -91,9 +93,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = fee, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -114,9 +115,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -133,9 +133,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -157,9 +156,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -180,9 +178,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) val reserve = result.filterIsInstance() @@ -204,9 +201,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -226,14 +222,146 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result.filterIsInstance()).hasSize(1) } + @Test + fun `GIVEN Tron token and show count within limit WHEN getNotifications THEN Tron network fees Info is added`() = + runTest { + val transferState = buildTransferState( + fromTokenInfo = buildTokenInfo( + swapCurrencyStatus = buildTronTokenStatus(), + amount = BigDecimal("10"), + ), + tronFeeNotificationShowCount = TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT, + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + actions = actions, + getFeeError = null, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN Tron token but show count exceeds limit WHEN getNotifications THEN no Tron network fees Info`() = + runTest { + val transferState = buildTransferState( + fromTokenInfo = buildTokenInfo( + swapCurrencyStatus = buildTronTokenStatus(), + amount = BigDecimal("10"), + ), + tronFeeNotificationShowCount = TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT + 1, + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + actions = actions, + getFeeError = null, + ) + + assertThat(result.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN Tron coin (not token) WHEN getNotifications THEN no Tron network fees Info`() = runTest { + val transferState = buildTransferState( + fromTokenInfo = buildTokenInfo( + swapCurrencyStatus = buildCoinStatus(rawNetworkId = "tron"), + amount = BigDecimal("10"), + ), + tronFeeNotificationShowCount = 0, + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + actions = actions, + getFeeError = null, + ) + + assertThat(result.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN UnknownError fee error and fee currency status WHEN getNotifications THEN NetworkFeeUnreachable is added`() = + runTest { + val transferState = buildTransferState() + val feeCryptoCurrencyStatus = buildCoinStatus().status + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = null, + actions = actions, + getFeeError = GetFeeError.UnknownError, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN TronActivationError fee error and fee currency status WHEN getNotifications THEN TronAccountNotActivated is added`() = + runTest { + val transferState = buildTransferState() + val feeCryptoCurrencyStatus = buildCoinStatus().status + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = null, + actions = actions, + getFeeError = GetFeeError.BlockchainErrors.TronActivationError, + ) + + val notifications = result.filterIsInstance() + assertThat(notifications).hasSize(1) + assertThat(notifications.first().tokenName).isEqualTo(feeCryptoCurrencyStatus.currency.name) + } + + @Test + fun `GIVEN fee error but null fee currency status WHEN getNotifications THEN no fee unreachable notification`() = + runTest { + val transferState = buildTransferState() + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + actions = actions, + getFeeError = GetFeeError.UnknownError, + ) + + assertThat(result.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN null fee error and fee currency status WHEN getNotifications THEN no fee unreachable notification`() = + runTest { + val transferState = buildTransferState() + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = buildCoinStatus().status, + fee = null, + actions = actions, + getFeeError = null, + ) + + assertThat(result.filterIsInstance()).isEmpty() + } + @Suppress("LongParameterList") private fun buildTransferState( fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()), @@ -244,6 +372,7 @@ internal class SwapTransferNotificationsFactoryTest { minAdaValue: BigDecimal? = null, isFeeCoverage: Boolean = false, sendingAmount: BigDecimal = fromTokenInfo.tokenAmount.value, + tronFeeNotificationShowCount: Int = 0, ): SwapState.Transfer = SwapState.Transfer( userWallet = coldWallet, fromTokenInfo = fromTokenInfo, @@ -255,6 +384,7 @@ internal class SwapTransferNotificationsFactoryTest { isAccountsMode = false, isFeeCoverage = isFeeCoverage, sendingAmount = sendingAmount, + tronFeeNotificationShowCount = tronFeeNotificationShowCount, currencyCheck = currencyCheck, validationResult = validationResult, minAdaValue = minAdaValue, @@ -326,4 +456,31 @@ internal class SwapTransferNotificationsFactoryTest { every { decimals } returns 18 } } + + private fun buildTronTokenStatus(): SwapCurrencyStatus { + val token = mockk(relaxed = true) { + every { id } returns mockk(relaxed = true) + every { network } returns mockk(relaxed = true) { + every { rawId } returns "tron" + every { name } returns "Tron" + } + every { name } returns "Tether" + every { symbol } returns "USDT" + every { decimals } returns 6 + } + val statusValue: CryptoCurrencyStatus.Loaded = mockk(relaxed = true) { + every { amount } returns BigDecimal("100") + every { fiatRate } returns BigDecimal.ONE + every { fiatAmount } returns BigDecimal("100") + } + return SwapCurrencyStatus( + userWallet = coldWallet, + status = CryptoCurrencyStatus(currency = token, value = statusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + } + + private companion object { + const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3 + } } \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index a5d4203e98..64861e80a5 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -59,9 +59,8 @@ internal class SwapTransferStateBuilderTest { transferState = any(), feeCryptoCurrencyStatus = any(), fee = any(), - onReduceByAmount = any(), - onReduceToAmount = any(), - onBuyClick = any(), + actions = any(), + getFeeError = any(), ) } returns persistentListOf() } @@ -125,6 +124,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = uiState, feePaidCryptoCurrencyStatus = null, fee = null, + feeError = null, ) val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio @@ -156,9 +156,8 @@ internal class SwapTransferStateBuilderTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = any(), - onReduceToAmount = any(), - onBuyClick = any(), + actions = any(), + getFeeError = any(), ) } } @@ -179,6 +178,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = uiState, feePaidCryptoCurrencyStatus = null, fee = null, + feeError = null, ) val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable @@ -199,9 +199,8 @@ internal class SwapTransferStateBuilderTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = any(), - onReduceToAmount = any(), - onBuyClick = any(), + actions = any(), + getFeeError = any(), ) } } @@ -223,6 +222,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = uiState, feePaidCryptoCurrencyStatus = null, fee = null, + feeError = null, ) val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable @@ -243,9 +243,8 @@ internal class SwapTransferStateBuilderTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = any(), - onReduceToAmount = any(), - onBuyClick = any(), + actions = any(), + getFeeError = any(), ) } } @@ -267,6 +266,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = uiState, feePaidCryptoCurrencyStatus = null, fee = null, + feeError = null, ) val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio @@ -293,9 +293,8 @@ internal class SwapTransferStateBuilderTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = any(), - onReduceToAmount = any(), - onBuyClick = any(), + actions = any(), + getFeeError = any(), ) } } @@ -341,9 +340,8 @@ internal class SwapTransferStateBuilderTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = fee, - onReduceByAmount = any(), - onReduceToAmount = any(), - onBuyClick = any(), + actions = any(), + getFeeError = any(), ) } returns persistentListOf() @@ -355,6 +353,7 @@ internal class SwapTransferStateBuilderTest { feePaidCryptoCurrencyStatus = null, fee = fee, isTangemPayWithdrawal = false, + feeError = null, ) assertThat(result.swapButton.isEnabled).isTrue() @@ -365,9 +364,8 @@ internal class SwapTransferStateBuilderTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = fee, - onReduceByAmount = any(), - onReduceToAmount = any(), - onBuyClick = any(), + actions = any(), + getFeeError = any(), ) } } @@ -390,6 +388,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = baseStateHolder(), feePaidCryptoCurrencyStatus = null, fee = mockk(relaxed = true), + feeError = null, ) val sendCard = result.sendCardData as SwapCardState.SwapCardData @@ -424,6 +423,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = baseStateHolder(), feePaidCryptoCurrencyStatus = null, fee = null, + feeError = null, ) val receiveCard = result.receiveCardData as SwapCardState.SwapCardData @@ -451,6 +451,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = baseStateHolder(), feePaidCryptoCurrencyStatus = null, fee = null, + feeError = null, ) val receiveCard = result.receiveCardData as SwapCardState.SwapCardData @@ -481,6 +482,7 @@ internal class SwapTransferStateBuilderTest { feePaidCryptoCurrencyStatus = null, fee = mockk(relaxed = true), isTangemPayWithdrawal = false, + feeError = null, ) val receiveCard = result.receiveCardData as SwapCardState.SwapCardData @@ -515,6 +517,7 @@ internal class SwapTransferStateBuilderTest { feePaidCryptoCurrencyStatus = null, fee = fee, isTangemPayWithdrawal = false, + feeError = null, ) assertThat(result.transferFooter).isInstanceOf(TextReference.Combined::class.java) @@ -538,20 +541,27 @@ internal class SwapTransferStateBuilderTest { isAccountsMode = false, ) val statusWithNetwork = buildStatusWithNetwork(hasFiatFeeRate = true) - val dataState = SwapProcessDataState(fromSwapCurrencyStatus = statusWithNetwork) + // The fee's fiat value is derived from the fee-paid currency's fiat rate, not the raw crypto fee. + val feePaidStatus = buildSwapCurrencyStatus(coldWallet) + val feePaidRate = feePaidStatus.status.value.fiatRate!! + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = statusWithNetwork, + feePaidCryptoCurrency = feePaidStatus.status, + ) val feeValue = BigDecimal("0.001") val fee = Fee.Common( amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18), ) val uiState = baseStateHolder() val appCurrency = transferState.appCurrency - val expectedFiatSending = (fromAmount * QUOTE).plus(feeValue).format { + val fiatFeeValue = feePaidRate.multiply(feeValue) + val expectedFiatSending = (fromAmount * QUOTE).plus(fiatFeeValue).format { fiat( fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, ) } - val expectedFiatFee = feeValue.format { + val expectedFiatFee = fiatFeeValue.format { fiat( fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, @@ -566,6 +576,7 @@ internal class SwapTransferStateBuilderTest { feePaidCryptoCurrencyStatus = null, fee = fee, isTangemPayWithdrawal = false, + feeError = null, ) assertThat(result.transferFooter).isEqualTo( @@ -611,6 +622,7 @@ internal class SwapTransferStateBuilderTest { feePaidCryptoCurrencyStatus = null, fee = fee, isTangemPayWithdrawal = false, + feeError = null, ) assertThat(result.transferFooter).isEqualTo( @@ -741,6 +753,7 @@ internal class SwapTransferStateBuilderTest { feePaidCryptoCurrencyStatus = null, fee = null, isTangemPayWithdrawal = true, + feeError = null, ) assertThat(result.swapButton.isEnabled).isTrue() @@ -776,6 +789,7 @@ internal class SwapTransferStateBuilderTest { feePaidCryptoCurrencyStatus = null, fee = null, isTangemPayWithdrawal = false, + feeError = null, ) assertThat(result.swapButton.isEnabled).isFalse() @@ -924,6 +938,7 @@ internal class SwapTransferStateBuilderTest { isAccountsMode = isAccountsMode, isFeeCoverage = isFeeCoverage, sendingAmount = toAmount, + tronFeeNotificationShowCount = 0, isSendingAmountLoading = isSendingAmountLoading, ) } From e67a56ba1dbea77ffb5de077960f3655953aa4e3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 17:49:13 +0500 Subject: [PATCH 09/22] Updated on 2026-08-14 --- .../com/tangem/core/ui/ds/tabs/TangemTab.kt | 5 +- .../market/MarketsListBatchFlowManager.kt | 6 +-- .../market/state/SwapMarketCategory.kt | 49 +++++++++++++++++++ .../market/state/SwapMarketState.kt | 10 ++-- .../choosetoken/model/MarketBlockDelegate.kt | 40 ++++++++++++--- .../impl/choosetoken/ui/ChooseTokenScreen.kt | 14 +++--- .../ui/SwapMarketsListLazyColumn.kt | 42 ++++++++++++++-- .../DefaultManageFundsComponent.kt | 4 +- 8 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt index 15dccb3a1d..fb3522efef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt @@ -67,7 +67,10 @@ fun TangemTab( interactionSource = remember { MutableInteractionSource() }, indication = ripple(), ) - .padding(all = TangemTheme.dimens2.x3), + .padding( + vertical = TangemTheme.dimens2.x2, + horizontal = TangemTheme.dimens2.x3, + ), ) { Text( text = text.resolveReference(), diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt index bff9bfb159..d7099f3159 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt @@ -28,7 +28,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val dispatchers: CoroutineDispatcherProvider, @Assisted private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, - @Assisted private val order: TokenMarketListConfig.Order, + @Assisted private val currentOrder: Provider, @Assisted private val currentSearchText: Provider, @Assisted private val modelScope: CoroutineScope, ) { @@ -192,7 +192,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor( searchText ?: currentSearchText() }, priceChangeInterval = TokenMarketListConfig.Interval.H24, - order = order, + order = currentOrder(), shouldNetworks = true, ), ), @@ -262,7 +262,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor( interface Factory { fun create( batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, - order: TokenMarketListConfig.Order, + currentOrder: Provider, currentSearchText: Provider, modelScope: CoroutineScope, ): MarketsListBatchFlowManager diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt new file mode 100644 index 0000000000..5e480142ac --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt @@ -0,0 +1,49 @@ +package com.tangem.features.commonfeatures.impl.choosetoken.market.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.markets.TokenMarketListConfig +import kotlinx.collections.immutable.ImmutableList + +/** + * Category of the "Market Pulse" block on the Choose asset screen. + * + * Each category maps to a [TokenMarketListConfig.Order] used to fetch the markets list. + */ +internal enum class SwapMarketCategory( + val title: TextReference, + val order: TokenMarketListConfig.Order, +) { + Trending( + title = resourceReference(R.string.markets_sort_by_trending_title), + order = TokenMarketListConfig.Order.Trending, + ), + ExperiencedBuyers( + title = resourceReference(R.string.markets_sort_by_experienced_buyers_title), + order = TokenMarketListConfig.Order.Buyers, + ), + TopGainers( + title = resourceReference(R.string.markets_sort_by_top_gainers_title), + order = TokenMarketListConfig.Order.TopGainers, + ), + TopLosers( + title = resourceReference(R.string.markets_sort_by_top_losers_title), + order = TokenMarketListConfig.Order.TopLosers, + ), +} + +/** + * UI model for the selectable category tabs of the "Market Pulse" block. + * + * @property items all available categories in display order. + * @property selected currently selected category. + * @property onCategoryClick invoked when a category tab is tapped. + */ +@Immutable +internal data class SwapMarketCategoriesUM( + val items: ImmutableList, + val selected: SwapMarketCategory, + val onCategoryClick: (SwapMarketCategory) -> Unit, +) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt index dd11a69c73..98505e6f2c 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt @@ -13,6 +13,8 @@ internal sealed class SwapMarketState { abstract val marketsTitle: TextReference abstract val shouldAssetsCount: Boolean + open val categories: SwapMarketCategoriesUM? = null + data class Content( val items: ImmutableList, val total: Int, @@ -21,17 +23,20 @@ internal sealed class SwapMarketState { val visibleIdsChanged: (List) -> Unit, override val marketsTitle: TextReference, override val shouldAssetsCount: Boolean, + override val categories: SwapMarketCategoriesUM? = null, ) : SwapMarketState() data class Loading( override val marketsTitle: TextReference, override val shouldAssetsCount: Boolean, + override val categories: SwapMarketCategoriesUM? = null, ) : SwapMarketState() data class LoadingError( val onRetryClicked: () -> Unit, override val marketsTitle: TextReference, override val shouldAssetsCount: Boolean, + override val categories: SwapMarketCategoriesUM? = null, ) : SwapMarketState() data object SearchNothingFound : SwapMarketState() { @@ -40,11 +45,6 @@ internal sealed class SwapMarketState { } companion object { - val DefaultLoading - get() = Loading( - marketsTitle = TextReference.Res(R.string.feed_trending_now), - shouldAssetsCount = false, - ) val SearchLoading get() = Loading( marketsTitle = TextReference.Res(R.string.markets_common_title), diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt index 5703c8d4a0..15fffa4ab4 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt @@ -22,6 +22,8 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInter import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketCategoriesUM +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketCategory import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider @@ -49,6 +51,8 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val visibleMarketItemIds = MutableStateFlow>(emptyList()) private val visibleDefaultMarketItemIds = MutableStateFlow>(emptyList()) + private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.Trending) + val addToPortfolioSlot: SlotNavigation = SlotNavigation() val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( scope = modelScope, @@ -91,7 +95,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val defaultMarketsListManager by lazy { marketsListBatchFlowManagerFactory.create( batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, - order = TokenMarketListConfig.Order.Trending, + currentOrder = Provider { selectedCategoryFlow.value.order }, currentSearchText = Provider { null }, modelScope = modelScope, ) @@ -100,7 +104,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val searchMarketsListManager by lazy { marketsListBatchFlowManagerFactory.create( batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, - order = TokenMarketListConfig.Order.ByRating, + currentOrder = Provider { TokenMarketListConfig.Order.ByRating }, currentSearchText = Provider { searchQueryState.value.value }, modelScope = modelScope, ) @@ -148,19 +152,26 @@ internal class MarketBlockDelegate @AssistedInject constructor( } private fun createDefaultMarketsFlow(): Flow { - val marketsTitle = TextReference.Res(R.string.feed_trending_now) + val marketsTitle = TextReference.Res(R.string.markets_pulse_common_title) return combine( - defaultMarketsListManager.uiItems, - defaultMarketsListManager.isInInitialLoadingErrorState, - defaultMarketsListManager.totalCount, - ) { uiItems, isError, total -> + flow = defaultMarketsListManager.uiItems, + flow2 = defaultMarketsListManager.isInInitialLoadingErrorState, + flow3 = defaultMarketsListManager.totalCount, + flow4 = selectedCategoryFlow, + ) { uiItems, isError, total, selectedCategory -> + val categories = buildCategoriesUM(selectedCategory) when { isError -> SwapMarketState.LoadingError( onRetryClicked = { defaultMarketsListManager.reload() }, marketsTitle = marketsTitle, shouldAssetsCount = false, + categories = categories, + ) + uiItems.isEmpty() -> SwapMarketState.Loading( + marketsTitle = marketsTitle, + shouldAssetsCount = false, + categories = categories, ) - uiItems.isEmpty() -> SwapMarketState.DefaultLoading else -> SwapMarketState.Content( items = uiItems, loadMore = { defaultMarketsListManager.loadMore() }, @@ -169,11 +180,24 @@ internal class MarketBlockDelegate @AssistedInject constructor( total = total ?: uiItems.size, marketsTitle = marketsTitle, shouldAssetsCount = false, + categories = categories, ) } } } + private fun buildCategoriesUM(selected: SwapMarketCategory): SwapMarketCategoriesUM = SwapMarketCategoriesUM( + items = SwapMarketCategory.entries.toImmutableList(), + selected = selected, + onCategoryClick = ::onCategorySelected, + ) + + private fun onCategorySelected(category: SwapMarketCategory) { + if (selectedCategoryFlow.value == category) return + selectedCategoryFlow.value = category + defaultMarketsListManager.reload() + } + private fun createSearchMarketsFlow(): Flow { val marketsTitle = TextReference.Res(R.string.markets_common_title) return combine( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index 179376d9d2..f4b67a6364 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -153,7 +153,7 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { ) if (state.marketsBlock != null) { - item("markets_title_spacer") { SpacerH(height = 20.dp) } + item("markets_title_spacer") { SpacerH(height = 40.dp) } swapMarketsListItems(state.marketsBlock) } } @@ -206,9 +206,9 @@ private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapM private fun LazyListScope.assetsTitle() { item(key = "assets_title") { Text( - text = stringResourceSafe(R.string.swap_your_assets_title), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, + text = stringResourceSafe(R.string.markets_portfolio_block_subtitle), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, modifier = Modifier .fillMaxWidth() .padding( @@ -224,7 +224,7 @@ private fun LazyListScope.walletListItem(walletList: WalletListUM) { if (walletList.items.isEmpty()) return item("wallet_list") { LazyRow( - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8), contentPadding = PaddingValues(horizontal = 16.dp), ) { @@ -249,7 +249,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { Row( modifier = modifier - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(percent = 50)) .background(backgroundColor) .clickable(onClick = state.onClick) .padding(horizontal = 16.dp, vertical = 8.dp), @@ -259,7 +259,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { Text( text = state.text.resolveReference(), color = buttonTextColor, - style = TangemTheme.typography.button, + style = TangemTheme.typography2.bodySemibold16, ) val count = state.count diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt index adf96a1463..2368913a12 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt @@ -2,6 +2,8 @@ package com.tangem.features.commonfeatures.impl.choosetoken.ui import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -14,10 +16,12 @@ import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.tabs.TangemTab import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketCategoriesUM import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { @@ -26,17 +30,22 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { Text( text = buildAnnotatedString { append(state.marketsTitle.resolveReference()) - if (totalCount != null) { + if (totalCount != null && state.shouldAssetsCount) { withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { append(" $totalCount") } } }, - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16), ) } + state.categories?.let { categories -> + item(key = "market_categories") { + MarketCategoriesRow(categories = categories) + } + } when (state) { is SwapMarketState.Loading -> { items(count = 100, key = { "market_placeholder_$it" }) { @@ -69,7 +78,7 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { modifier = Modifier.roundedShapeItemDecoration( currentIndex = index, lastIndex = state.items.lastIndex, - backgroundColor = TangemTheme.colors.background.action, + backgroundColor = TangemTheme.colors.background.primary, ), ) } @@ -77,6 +86,31 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { } } +@Composable +private fun MarketCategoriesRow(categories: SwapMarketCategoriesUM, modifier: Modifier = Modifier) { + LazyRow( + modifier = modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing8, + ), + contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing16), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + items( + items = categories.items, + key = { category -> category.name }, + ) { category -> + TangemTab( + text = category.title, + isChecked = category == categories.selected, + onCheckedChange = { categories.onCategoryClick(category) }, + ) + } + } +} + @Composable private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { Box( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt index acd951b2b2..355331a109 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt @@ -199,7 +199,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor( iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_back_28), onClick = onBackClick, size = TangemButton.Size.X11, - variant = TangemButton.Variant.Material, + variant = TangemButton.Variant.Secondary, ) } } else { @@ -211,7 +211,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor( iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24), onClick = onCloseClick, size = TangemButton.Size.X11, - variant = TangemButton.Variant.Material, + variant = TangemButton.Variant.Secondary, ) }, ) From 0939f4b85ead10d12e4b8ae359a4fb89897ed6f0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 19:16:58 +0500 Subject: [PATCH 10/22] Updated on 2026-08-14 --- .../impl/choosetoken/ui/ChooseTokenScreen.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index f4b67a6364..18fbcd8716 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -331,7 +331,13 @@ private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) { item("EmptyTokensList") { Box( modifier = modifier - .background(TangemTheme.colors.background.secondary) + .background( + color = if (LocalRedesignEnabled.current) { + TangemTheme.colors2.surface.level2 + } else { + TangemTheme.colors.background.secondary + }, + ) .fillParentMaxSize(), ) { Column(modifier = Modifier.align(Alignment.Center)) { @@ -362,7 +368,13 @@ private fun LazyListScope.tokensNotFound(modifier: Modifier = Modifier) { item("TokensNotFound") { Box( modifier = modifier - .background(TangemTheme.colors.background.secondary) + .background( + color = if (LocalRedesignEnabled.current) { + TangemTheme.colors2.surface.level2 + } else { + TangemTheme.colors.background.secondary + }, + ) .fillParentMaxSize(), ) { Text( From 0e382ab739676ccc3d053d5d4f5d08ccde249b29 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 20:08:49 +0500 Subject: [PATCH 11/22] Updated on 2026-08-14 --- .../GetWalletNotificationsCarouselFactory.kt | 20 +++++- .../domain/GetWalletNotificationsFactory.kt | 8 ++- ...tWalletNotificationsCarouselFactoryTest.kt | 68 +++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt index ac71135be8..82610ab8c7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt @@ -3,7 +3,10 @@ package com.tangem.feature.wallet.presentation.wallet.domain import com.tangem.common.TangemSiteUrlBuilder import com.tangem.common.ui.notifications.NotificationId import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.settings.IsReadyToShowRateAppUseCase @@ -20,12 +23,14 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import javax.inject.Inject /** * Factory for creating a list of notifications that can be shown on the wallet screen. * These notifications are not critical and can be stacked with each other. */ +@Suppress("LongParameterList") @ModelScoped internal class GetWalletNotificationsCarouselFactory @Inject constructor( private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, @@ -34,8 +39,15 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor( private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase, private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase, private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, ) { fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow> { + val isBalanceResolvedFlow = singleAccountStatusListSupplier( + SingleAccountStatusListProducer.Params(userWallet.walletId), + ) + .map { it.totalFiatBalance !is TotalFiatBalance.Loading } + .distinctUntilChanged() + return combine( flow = notificationsRepository.getShouldShowNotification( NotificationId.EnablePushesReminderNotification.key, @@ -43,11 +55,15 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor( flow2 = isReadyToShowRateAppUseCase().distinctUntilChanged(), flow3 = getWalletsUseCase().conflate(), flow4 = yieldSupplyGetShouldShowMainPromoUseCase().distinctUntilChanged(), - ) { showPushesNotification, showRateAppPromo, wallets, shouldShowYieldPromoLocal -> + flow5 = isBalanceResolvedFlow, + ) { showPushesNotification, showRateAppPromo, wallets, shouldShowYieldPromoLocal, isBalanceResolved -> buildList { addNoteMigrationNotification(userWallet, wallets, clickIntents) - addRateAppNotification(showRateAppPromo, clickIntents) + + // isBalanceResolved gates Rate App on the balance leaving the loading state, so it does not + // flash during loading and then get replaced once balance-dependent banners are resolved. + addRateAppNotification(showRateAppPromo && isBalanceResolved, clickIntents) addPushNotification( shouldShow = showPushesNotification, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 6909483f63..5085257879 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -338,20 +338,22 @@ internal class GetWalletNotificationsFactory @Inject constructor( ) { if (userWallet !is UserWallet.Hot) return + if (totalFiatBalance is TotalFiatBalance.Loading) return + val isBackupExists = userWallet.backedUp val isAccessCodeRequired = userWallet.hotWalletId.authType == HotWalletId.AuthType.NoPassword && !shouldAccessCodeSkipped val shouldShowFinishActivation = !isBackupExists || isAccessCodeRequired val messageEffect = when (totalFiatBalance) { - TotalFiatBalance.Failed, - TotalFiatBalance.Loading, - -> TangemMessageEffect.None is TotalFiatBalance.Loaded -> if (totalFiatBalance.amount.orZero().isPositive()) { TangemMessageEffect.Warning } else { TangemMessageEffect.None } + TotalFiatBalance.Loading, + TotalFiatBalance.Failed, + -> TangemMessageEffect.None } addIf( diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt index fb3b3d3ad7..5ce92507a5 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt @@ -2,6 +2,13 @@ package com.tangem.feature.wallet.presentation.wallet.domain import arrow.core.Either import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.notifications.repository.NotificationsRepository @@ -25,6 +32,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class GetWalletNotificationsCarouselFactoryTest { @@ -35,6 +43,7 @@ internal class GetWalletNotificationsCarouselFactoryTest { private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase = mockk() private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase = mockk() private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk() + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true) private val clickIntents: WalletClickIntents = mockk(relaxed = true) private val userWallet: UserWallet.Hot = mockk(relaxed = true) @@ -45,6 +54,7 @@ internal class GetWalletNotificationsCarouselFactoryTest { shouldShowYieldBoostMainBannerUseCase = shouldShowYieldBoostMainBannerUseCase, yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase, yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, ) @BeforeEach @@ -56,6 +66,7 @@ internal class GetWalletNotificationsCarouselFactoryTest { shouldShowYieldBoostMainBannerUseCase, yieldSupplyGetShouldShowMainPromoUseCase, yieldSupplyFeatureToggles, + singleAccountStatusListSupplier, clickIntents, userWallet, ) @@ -68,6 +79,10 @@ internal class GetWalletNotificationsCarouselFactoryTest { every { yieldSupplyGetShouldShowMainPromoUseCase() } returns flowOf(true) every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true coEvery { shouldShowYieldBoostMainBannerUseCase(any()) } returns Either.Right(true) + // Balance is loaded by default, so banners gated on balance are not suppressed. + every { + singleAccountStatusListSupplier(any()) + } returns flowOf(accountStatusList(TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL))) } @ParameterizedTest @@ -85,6 +100,24 @@ internal class GetWalletNotificationsCarouselFactoryTest { assertThat(result.any { it is WalletNotificationUM.YieldBoostPromo }).isEqualTo(model.expectedShown) } + @ParameterizedTest + @MethodSource("provideRateAppTestModels") + fun `GIVEN ready to show rate app and balance state WHEN create THEN rate app banner visibility matches`( + model: RateAppModel, + ) = runTest { + // Arrange + every { isReadyToShowRateAppUseCase() } returns flowOf(model.isReadyToShow) + every { + singleAccountStatusListSupplier(any()) + } returns flowOf(accountStatusList(model.balance)) + + // Act + val result = factory.create(userWallet, clickIntents).first() + + // Assert + assertThat(result.any { it is WalletNotificationUM.RateApp }).isEqualTo(model.expectedShown) + } + @Test fun `GIVEN banner shown WHEN buttons clicked THEN routes to click intents`() = runTest { // Arrange @@ -101,6 +134,16 @@ internal class GetWalletNotificationsCarouselFactoryTest { verify { clickIntents.onDismissYieldBoostBanner(WALLET_ID) } } + private fun accountStatusList(balance: TotalFiatBalance) = AccountStatusList( + userWalletId = WALLET_ID, + accountStatuses = emptyList(), + totalAccounts = 0, + totalArchivedAccounts = 0, + totalFiatBalance = balance, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + internal data class Model( val toggleEnabled: Boolean, val shouldShowLocal: Boolean, @@ -121,6 +164,31 @@ internal class GetWalletNotificationsCarouselFactoryTest { ), ) + internal data class RateAppModel( + val isReadyToShow: Boolean, + val balance: TotalFiatBalance, + val expectedShown: Boolean, + ) + + private fun provideRateAppTestModels() = listOf( + // Ready to show, but the balance is still loading — don't flash before Add Funds may appear. + RateAppModel(isReadyToShow = true, balance = TotalFiatBalance.Loading, expectedShown = false), + // Ready to show and the balance is loaded — the banner can appear. + RateAppModel( + isReadyToShow = true, + balance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL), + expectedShown = true, + ), + // Ready to show and the balance failed — terminal state, only loading suppresses the banner. + RateAppModel(isReadyToShow = true, balance = TotalFiatBalance.Failed, expectedShown = true), + // Not ready to show — the banner stays hidden regardless of the balance state. + RateAppModel( + isReadyToShow = false, + balance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL), + expectedShown = false, + ), + ) + private companion object { val WALLET_ID = UserWalletId("01") } From d412fc147398b92cc17dffd46afa0a3a1ab02cc7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 22:46:00 +0200 Subject: [PATCH 12/22] Updated on 2026-08-14 --- .../com/tangem/scenarios/BaseScenarios.kt | 5 +- .../com/tangem/scenarios/SwapScenarios.kt | 111 ++++- .../tangem/screens/SwapSuccessPageObject.kt | 7 + .../com/tangem/screens/SwapTokenPageObject.kt | 6 + .../tangem/tests/transfer/AppTransfersTest.kt | 453 ++++++++++++++++++ .../sdk/mocks/content/WalletMockContent.kt | 21 + 6 files changed, 576 insertions(+), 27 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 08641bc0d6..31b41c8bea 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -140,12 +140,15 @@ fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessC fun BaseTestCase.synchronizeAddresses( balance: String? = null, - isBalanceAvailable: Boolean = true + isBalanceAvailable: Boolean = true, + assertBalance: Boolean = true, ) { step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } + if (!assertBalance) return + when { !isBalanceAvailable -> step("Assert wallet balance = '$DASH_SIGN'") { onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index db4e1a2771..77403f32f0 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -11,10 +11,10 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.extensions.assertVisibility -import com.tangem.common.extensions.clickAndWaitFor import com.tangem.common.extensions.clickWhenEnabled import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText +import com.tangem.common.extensions.isDisplayedSafely import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.HotWalletAccessCodeTestTags @@ -262,6 +262,24 @@ fun BaseTestCase.checkSwapWarning( } } +/** Scans a card wallet and opens Swap for [tokenName] in [fromAccountName] without choosing the receive token yet. */ +fun BaseTestCase.openSwapForTokenInAccount( + tokenName: String, + fromAccountName: String = "Account 1", + mockContent: MockContent? = null, +) { + step("Open 'Main' screen") { + openMainScreen(mockContent = mockContent) + } + step("Synchronize addresses") { + synchronizeAddresses(assertBalance = false) + } + step("Wait for addresses to be generated") { + waitForAddressesGenerated() + } + navigateToSwapForToken(tokenName, fromAccountName) +} + /** Opens Swap for [tokenName] in [fromAccountName] and picks it again in [toAccountName] to enter Transfer mode; needs a two-accounts-same-token mock. */ fun BaseTestCase.openSwapInTransferMode( tokenName: String, @@ -269,36 +287,59 @@ fun BaseTestCase.openSwapInTransferMode( toAccountName: String = "Account 2", mockContent: MockContent? = null, ) { - step("Open 'Main' screen") { - openMainScreen(mockContent = mockContent) - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") { - onMainScreen { scrollToAccount(fromAccountName) } - } - step("Expand account '$fromAccountName' and reveal token '$tokenName'") { - onMainScreen { - findAccountSectionByName(fromAccountName).clickAndWaitFor( - rule = composeTestRule, - expectedCondition = { - onMainScreen { findTokenInAnyAccountByName(tokenName).assertIsDisplayed() } - }, - ) - } - } - step("Click on token with name: '$tokenName'") { - onMainScreen { findTokenInAnyAccountByName(tokenName).clickWithAssertion() } - } - step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) - } + openSwapForTokenInAccount(tokenName, fromAccountName, mockContent) step("Choose identical receive token '$tokenName' from '$toAccountName'") { chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName) } } +/** Like [openSwapInTransferMode] but imports a hot wallet first — required for broadcasting flows (the mock card can't sign). */ +fun BaseTestCase.openSwapInTransferModeWithHotWallet( + tokenName: String, + seedPhrase: String, + fromAccountName: String = "Account 1", + toAccountName: String = "Account 2", +) { + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Generate missing addresses") { + generateMissingHotWalletAddresses() + } + step("Wait for addresses to be generated") { + waitForAddressesGenerated() + } + navigateToSwapForToken(tokenName, fromAccountName) + step("Choose identical receive token '$tokenName' from '$toAccountName'") { + chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName) + } +} + +// Mirrors iOS: enter Swap via the main action button (source auto-selected) — avoids the account list under the Markets promo. +private fun BaseTestCase.navigateToSwapForToken(tokenName: String, fromAccountName: String) { + step("Open 'Swap' for '$tokenName' from '$fromAccountName' via the main 'Swap' button") { + openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false) + } +} + +// Hot wallets derive locally, so the second account's missing addresses are generated without a card scan when prompted. +fun BaseTestCase.generateMissingHotWalletAddresses() { + var notificationShown = false + onMainScreen { notificationShown = synchronizeAddressesButton.isDisplayedSafely() } + if (notificationShown) { + onMainScreen { synchronizeAddressesButton.performClick() } + } +} + +// The receive selector shows "No address" until the second account's derivation lands; the prompt disappears when it does. +fun BaseTestCase.waitForAddressesGenerated() { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + var generated = false + onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() } + generated + } +} + /** Picks the identical [tokenName] in [receiveAccountName]; the receive list collapses the other account, so its header is expanded first. */ fun BaseTestCase.chooseIdenticalReceiveToken(tokenName: String, receiveAccountName: String) { step("Click on 'Choose token' button") { @@ -321,6 +362,16 @@ fun BaseTestCase.chooseReceiveToken(tokenName: String) { } } +/** Reopens the receive selector via the receive-card icon and picks [tokenName] directly — the reopened selector keeps the account expanded. */ +fun BaseTestCase.changeReceiveToken(tokenName: String) { + step("Open receive token selector") { + onSwapTokenScreen { receiveSelectTokenIcon.performClick() } + } + step("Click on token with name '$tokenName'") { + onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() } + } +} + /** * From a clean start: open the main screen (cold by default, or an existing hot wallet when * [seedPhrase] is given), open Swap for [fromTokenName], choose [receiveTokenName] to receive and @@ -423,6 +474,14 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) { } } +/** Holds the last BASE_BUTTON to confirm a transfer; the caller asserts the outcome (transfer mode has no in-progress marker to wait on). */ +fun BaseTestCase.holdToConfirmTransfer() { + val buttons = composeTestRule.onAllNodes(hasTestTag(BaseButtonTestTags.BUTTON)) + val confirmButton = buttons[buttons.fetchSemanticsNodes().lastIndex] + confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) } + waitForIdle() +} + sealed class SwapEntryPoint { object MainScreen : SwapEntryPoint() object TokenDetails : SwapEntryPoint() diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt index 3ba2a38384..0dace29264 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt @@ -20,6 +20,13 @@ class SwapSuccessPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + // App Transfers reuses the swap success screen; in transfer mode its title is "Transfer in progress". + val transferInProgressTitle: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.TITLE) + hasText(getResourceString(R.string.transfer_in_progress_title)) + useUnmergedTree = true + } + val closeButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasAnyDescendant(withText(getResourceString(R.string.common_close))) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index 3bdeff493c..c123dbb27c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -245,6 +245,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_choose_token)) useUnmergedTree = true } + + // Transfer mode auto-fills the memo/destination tag — the manual Send-address field must never render here. + val destinationTagField: KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD) + useUnmergedTree = true + } } internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt index d7897ef413..14e9fb1557 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -4,17 +4,21 @@ import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R as CoreUiR import com.tangem.scenarios.* import com.tangem.screens.* import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -454,4 +458,453 @@ class AppTransfersTest : BaseTestCase() { } } } + + @AllureId("9841") + @DisplayName("App transfers: full transfer reaches 'Transfer in progress' screen") + @Test + fun fullTransferReachesTransferInProgressScreenTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token' with existing hot wallet") { + openSwapInTransferModeWithHotWallet(tokenName = token, seedPhrase = SVS_SEED_PHRASE_12) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transfer in progress' screen is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() } + } + } + } + } + + @AllureId("9999") + @DisplayName("App transfers: broadcast error shows alert without finish screen") + @Test + fun broadcastErrorShowsAlertWithoutFinishScreenTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + val sendRawTransactionScenario = "eth_sendRawTransaction" + val broadcastErrorState = "BroadcastError" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + resetWireMockScenarioState(sendRawTransactionScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + step("Set WireMock scenario: '$sendRawTransactionScenario' to state: '$broadcastErrorState'") { + setWireMockScenarioState(scenarioName = sendRawTransactionScenario, state = broadcastErrorState) + } + + step("Open Swap in Transfer mode for '$token' with existing hot wallet") { + openSwapInTransferModeWithHotWallet(tokenName = token, seedPhrase = SVS_SEED_PHRASE_12) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transaction failed' dialog is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onFailedTransactionDialog { dialogContainer.assertIsDisplayed() } + } + } + step("Assert 'Transfer in progress' screen is not displayed") { + onSwapSuccessScreen { transferInProgressTitle.assertDoesNotExist() } + } + } + } + + @AllureId("9989") + @DisplayName("App transfers: receive list allows identical token on another account") + @Test + fun receiveListAllowsIdenticalTokenOnAnotherAccountTest() { + val token = "Ethereum" + val receiveAccountName = "Account 2" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap for '$token' in 'Account 1'") { openSwapForTokenInAccount(token) } + step("Open receive token selector") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Expand account '$receiveAccountName' in receive selector") { + onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() } + } + step("Assert token '$token' is displayed in receive selector") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSelectTokenScreen { tokenWithName(token).assertIsDisplayed() } + } + } + } + } + + @AllureId("9998") + @DisplayName("App transfers: fee calculation error disables Transfer") + @Test + fun feeCalculationErrorDisablesTransferTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + val feeHistoryScenario = "eth_fee_history" + val estimateGasScenario = "eth_estimate_gas" + val unreachable = "Unreachable" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + resetWireMockScenarioState(feeHistoryScenario) + resetWireMockScenarioState(estimateGasScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + step("Set WireMock scenario: '$feeHistoryScenario' to state: '$unreachable'") { + setWireMockScenarioState(scenarioName = feeHistoryScenario, state = unreachable) + } + step("Set WireMock scenario: '$estimateGasScenario' to state: '$unreachable'") { + setWireMockScenarioState(scenarioName = estimateGasScenario, state = unreachable) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + // Unreachable fee endpoints leave the fee unresolved (shown as '—'); the transfer stays blocked. + step("Assert 'Transfer' button is disabled") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } + } + + @AllureId("9994") + @DisplayName("App transfers: mode switches reactively without screen reload") + @Test + fun modeSwitchesReactivelyWithoutScreenReloadTest() { + val token = "Solana" + val swapReceiveToken = "USDC" + val userTokensState = "TwoAccountsSameSolanaWithUsdc" + val solanaBalanceScenario = "solana_balance" + val assetsScenario = "express_api_assets" + val fromPairsScenario = "solana_from_pairs" + val dexProviderState = "DexProvider" + val quotesSolanaState = "Solana" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(solanaBalanceScenario) + resetWireMockScenarioState(assetsScenario) + resetWireMockScenarioState(fromPairsScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started) + } + step("Set WireMock scenario: '$assetsScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = assetsScenario, state = started) + } + step("Set WireMock scenario: '$fromPairsScenario' to state: '$dexProviderState'") { + setWireMockScenarioState(scenarioName = fromPairsScenario, state = dexProviderState) + } + // Non-zero SOL price keeps total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Change receive token to '$swapReceiveToken' to switch to Swap mode") { + changeReceiveToken(swapReceiveToken) + } + step("Assert 'Swap' button is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { swapButton.assertIsDisplayed() } + } + } + step("Change receive token back to identical '$token' to switch to Transfer mode") { + changeReceiveToken(token) + } + step("Assert Transfer mode is ready") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { assertTransferReady() } + } + } + } + + @AllureId("10001") + @DisplayName("App transfers: memo field is not entered manually in Transfer mode") + @Test + fun memoFieldIsNotEnteredManuallyInTransferModeTest() { + val token = "XRP Ledger" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXRP" + val rippleAccountInfoScenario = "ripple_account_info" + val quotesRippleState = "Ripple" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(rippleAccountInfoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = started) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesRippleState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesRippleState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert manual 'Destination tag' field is not displayed") { + onSwapTokenScreen { destinationTagField.assertDoesNotExist() } + } + } + } + + @AllureId("10009") + @DisplayName("App transfers: XRP network fee") + @Test + fun xrpNetworkFeeTest() { + val token = "XRP Ledger" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXRP" + val rippleAccountInfoScenario = "ripple_account_info" + val quotesRippleState = "Ripple" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(rippleAccountInfoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = started) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesRippleState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesRippleState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10011") + @DisplayName("App transfers: Stellar network fee") + @Test + fun stellarNetworkFeeTest() { + val token = "Stellar" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXLM" + val quotesXlmState = "XLM" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesXlmState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesXlmState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + // [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows. + @Ignore("[REDACTED_JIRA]") + @AllureId("9852") + @DisplayName("App transfers: amount below destination reserve disables Transfer") + @Test + fun amountBelowDestinationReserveDisablesTransferTest() { + val token = "Solana" + val belowReserveAmount = "0.0001" + val userTokensState = "TwoAccountsSameSolana" + val solanaBalanceScenario = "solana_balance" + val recipientAccountScenario = "solana_recipient_account" + val notExistState = "NotExist" + val quotesSolanaState = "Solana" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(solanaBalanceScenario) + resetWireMockScenarioState(recipientAccountScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started) + } + step("Set WireMock scenario: '$recipientAccountScenario' to state: '$notExistState'") { + setWireMockScenarioState(scenarioName = recipientAccountScenario, state = notExistState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$belowReserveAmount'") { inputAmount(belowReserveAmount) } + step("Assert error notification is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { errorNotificationTitle.assertIsDisplayed() } + } + } + step("Assert 'Transfer' button is disabled") { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } + + @AllureId("9997") + @DisplayName("App transfers: amount below minimum disables Transfer") + @Test + fun amountBelowMinimumDisablesTransferTest() { + val token = "Kaspa" + val belowMinimumAmount = "0.00000001" + val userTokensState = "TwoAccountsSameKaspa" + val kaspaUtxoScenario = "kaspa_utxo" + // Android-specific UTXO body — addresses differ from the iOS fixture (see kaspa-utxo.json). + val kaspaUtxoState = "more_than_84_android" + val quotesKaspaState = "Kaspa" + val invalidAmountTitle = getResourceString(CoreUiR.string.send_notification_invalid_amount_title) + val minimumAmountMessagePrefix = + getResourceString(CoreUiR.string.send_notification_invalid_minimum_amount_text).substringBefore("%1") + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(kaspaUtxoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$kaspaUtxoScenario' to state: '$kaspaUtxoState'") { + setWireMockScenarioState(scenarioName = kaspaUtxoScenario, state = kaspaUtxoState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesKaspaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesKaspaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$belowMinimumAmount'") { inputAmount(belowMinimumAmount) } + step("Assert '$invalidAmountTitle' notification title is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { warningTitle(invalidAmountTitle).assertIsDisplayed() } + } + } + step("Assert notification message contains the minimum-amount text") { + onSwapTokenScreen { errorNotificationText.assertTextContains(minimumAmountMessagePrefix, substring = true) } + } + step("Assert 'Transfer' button is disabled") { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index 78c6b52b7b..aa7881a6db 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -297,6 +297,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/144'/1'/0/0") to ExtendedPublicKey( // XRP (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/1729'/0'/0'") to ExtendedPublicKey( // Tezos publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -396,6 +403,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/148'/1'") to ExtendedPublicKey( // Stellar (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), @@ -564,6 +578,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/148'/1'") to ExtendedPublicKey( // Stellar (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), ), ), ), From 81d971eafbf6545b8293bc821c1a4e0bf36c913b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 23:40:42 +0200 Subject: [PATCH 13/22] Updated on 2026-08-14 --- .../com/tangem/scenarios/SwapScenarios.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index 77403f32f0..ad101a5545 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -11,6 +11,7 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.extensions.assertVisibility +import com.tangem.common.extensions.clickAndWaitFor import com.tangem.common.extensions.clickWhenEnabled import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText @@ -315,10 +316,25 @@ fun BaseTestCase.openSwapInTransferModeWithHotWallet( } } -// Mirrors iOS: enter Swap via the main action button (source auto-selected) — avoids the account list under the Markets promo. private fun BaseTestCase.navigateToSwapForToken(tokenName: String, fromAccountName: String) { - step("Open 'Swap' for '$tokenName' from '$fromAccountName' via the main 'Swap' button") { - openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false) + step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") { + onMainScreen { scrollToAccount(fromAccountName) } + } + step("Expand account '$fromAccountName' and reveal token '$tokenName'") { + onMainScreen { + findAccountSectionByName(fromAccountName).clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onMainScreen { findTokenInAnyAccountByName(tokenName).assertIsDisplayed() } + }, + ) + } + } + step("Click on token with name: '$tokenName'") { + onMainScreen { findTokenInAnyAccountByName(tokenName).clickWithAssertion() } + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } } From 5beda756fae8631fee3d3fac1912975b3880a82c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 09:28:50 +0200 Subject: [PATCH 14/22] Updated on 2026-08-14 --- .../com/tangem/core/ui/ds/image/DeviceIcon.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/image/DeviceIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/image/DeviceIcon.kt index 6d6d6f046d..0ab1068bb0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/image/DeviceIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/image/DeviceIcon.kt @@ -11,13 +11,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.takeOrElse -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.core.ui.R import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_cloud_24_filled + +private const val DEFAULT_DEVICE_ICON_COLOR = 0xFF2C2C2C +private const val DEFAULT_BORDER_COLOR = 0x1A1E1E1E /** * Composable function for displaying a wallet icon based on the provided [DeviceIconUM] state. @@ -57,9 +59,9 @@ fun TangemDeviceIcon(state: DeviceIconUM, modifier: Modifier = Modifier) { ) DeviceIconUM.Mobile -> Icon( modifier = modifier, - imageVector = ImageVector.vectorResource(R.drawable.ic_shield_24), + imageVector = Icons.ic_cloud_24_filled, contentDescription = null, - tint = TangemTheme.colors2.graphic.status.attention, + tint = Color(DEFAULT_DEVICE_ICON_COLOR), ) } } @@ -73,10 +75,10 @@ private fun DeviceIcon( tColor: Color?, modifier: Modifier = Modifier, ) { - val main = mainColor.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant } - val second = secondColor?.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant } - val third = thirdColor?.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant } - val borderColor = TangemTheme.colors2.border.walletIcon + val main = mainColor.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) } + val second = secondColor?.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) } + val third = thirdColor?.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) } + val borderColor = Color(DEFAULT_BORDER_COLOR) val imageVector = remember(isRing, main, second, third, borderColor, tColor) { when { From 2a06529480e4b7edf11fd272500daeb5cfe1d2f4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 09:29:36 +0200 Subject: [PATCH 15/22] Updated on 2026-08-14 --- .../ui/ds/field/search/TangemSearchField.kt | 67 ++++++++----------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt index c341a7949c..cbfae4ad27 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester @@ -201,6 +200,7 @@ private fun DecorationBox( Field( state = state, innerTextField = innerTextField, + modifier = if (state.query.isEmpty()) Modifier else Modifier.weight(1f), ) ClearButton( state = state, @@ -217,58 +217,45 @@ private fun DecorationBox( } @Composable -private fun Field(state: SearchBarUM, innerTextField: @Composable () -> Unit) { +private fun Field(state: SearchBarUM, modifier: Modifier = Modifier, innerTextField: @Composable () -> Unit) { Box( contentAlignment = Alignment.CenterStart, - modifier = Modifier + modifier = modifier .heightIn(min = TangemTheme.dimens2.x5) - .width(IntrinsicSize.Max), + .then(if (state.query.isEmpty()) Modifier.width(IntrinsicSize.Max) else Modifier), ) { innerTextField() - val placeholderOpacity by remember(state.query) { - derivedStateOf { - if (state.query.isNotEmpty()) { - 0f - } else { - 1f - } - } + if (state.query.isEmpty()) { + Text( + text = state.placeholderText.resolveReference(), + color = TangemTheme.colors2.text.neutral.tertiary, + style = TangemTheme.typography2.bodySemibold16, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(SearchBarTestTags.PLACEHOLDER_TEXT), + ) } - - Text( - text = state.placeholderText.resolveReference(), - color = TangemTheme.colors2.text.neutral.tertiary, - style = TangemTheme.typography2.bodySemibold16, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .testTag(SearchBarTestTags.PLACEHOLDER_TEXT) - .alpha(placeholderOpacity), - ) } } @Composable private fun ClearButton(state: SearchBarUM) { if (state.query.isNotEmpty()) { - Box(modifier = Modifier.fillMaxWidth()) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_close_new_20), - tint = TangemTheme.colors2.graphic.neutral.tertiary, - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterEnd) - .size(TangemTheme.dimens2.x5) - .clip(CircleShape) - .clickable( - onClick = state.onClearClick, - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = false), - ) - .testTag(SearchBarTestTags.CLEAR_BUTTON), - ) - } + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_close_new_20), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + modifier = Modifier + .size(TangemTheme.dimens2.x5) + .clip(CircleShape) + .clickable( + onClick = state.onClearClick, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + ) + .testTag(SearchBarTestTags.CLEAR_BUTTON), + ) } } From 3b8d57864ccf985abe4694d089382425680b8f4a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 11:04:11 +0200 Subject: [PATCH 16/22] Updated on 2026-08-14 --- .../img_tangem_pay_visa_reissuing.webp | Bin 0 -> 2470 bytes .../img_tangem_pay_visa_reissuing.webp | Bin 0 -> 3412 bytes .../img_tangem_pay_visa_reissuing.webp | Bin 0 -> 5554 bytes .../img_tangem_pay_visa_reissuing.webp | Bin 0 -> 8222 bytes .../TangemPayCardDetailsBlockStateFactory.kt | 3 + .../tangempay/entity/TangemPayDetailsUM.kt | 1 + .../TangemPayCardDetailsController.kt | 2 + .../tangempay/ui/TangemPayCardDetailsBlock.kt | 17 ++- .../tangempay/ui/TangemPayCardPageScreen.kt | 110 ++++++++++++++---- .../tangempay/ui/TangemPayReissueBlock.kt | 52 +++++++++ .../ui/TangemPayReplacingCardBlock.kt | 34 +----- 11 files changed, 162 insertions(+), 57 deletions(-) create mode 100644 core/ui/src/main/res/drawable-hdpi/img_tangem_pay_visa_reissuing.webp create mode 100644 core/ui/src/main/res/drawable-xhdpi/img_tangem_pay_visa_reissuing.webp create mode 100644 core/ui/src/main/res/drawable-xxhdpi/img_tangem_pay_visa_reissuing.webp create mode 100644 core/ui/src/main/res/drawable-xxxhdpi/img_tangem_pay_visa_reissuing.webp create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueBlock.kt diff --git a/core/ui/src/main/res/drawable-hdpi/img_tangem_pay_visa_reissuing.webp b/core/ui/src/main/res/drawable-hdpi/img_tangem_pay_visa_reissuing.webp new file mode 100644 index 0000000000000000000000000000000000000000..1fbd34056cf669144950541f1067755d93046b23 GIT binary patch literal 2470 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 literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable-xhdpi/img_tangem_pay_visa_reissuing.webp b/core/ui/src/main/res/drawable-xhdpi/img_tangem_pay_visa_reissuing.webp new file mode 100644 index 0000000000000000000000000000000000000000..10ab621d279c52355dc9aa74367442e1f8ea1e63 GIT binary patch literal 3412 zcmchaS5yN)sGB^~3-tEL6~NQ2REEn~+`_IC+oVXU5tXR&5hxj>6d*rf$BS6F>QpBX_1^?ej1y{QsKudC^he4w-FB5uJbvQ6fDx% zRDBmY>sgn3wLfI2G1PX%##?p|YbFxX-H{Vjqoyhqv(u^lw;FR*^HBWGEpP{Yd{|d2 z@)dP*#GEt31qQ;#$n95r8t=TuvexGmcHJxjNxnb42$DDCEbr&kQoF^cA(z9$9{eFp5e9}g0wZ^CSXqmT6+gg*v_LgGVQH;?vVeVBpZRsl?|{mFySX;%8N+O7|a zCdnP?MfTb;dxzHBXSu!|)87XH7dK+@KAMFgG1$_ftH?P*IbM3@FMPvjFf#5=#oo>Hj#!yMh z_6EnbC3?EOVD7I{Vlkfmz#S1RLox~UrZ2_! z;H?Lc?^v_$>}%9($A?gO0L73^0gViX+a>B&`04BYH-@vjm2y>mX5?inB|^b4u}EsG z>@TEG)}y4j6BFn1W#Nz1=q+xg@wBbD-VdT4CNGd5EiO^q=E5titAsAkDT9RlWm*9PzD~+Bog+js^It`hFY6APv4@xx_+#0Bss>9>#V9K3ob4vK${YCK>LoQl zSAK4oYFZ{KCkfhSLSsjo|J8<3^coK@p%iPhk6izh8Z31X|1#0o;|O%dxOOjP_57o5 zy`jWX+LazC;rBM^Pc5Gn+G@>>GyyxqWg>wL`JJp&G!?=`jC4tf58Rg2I(YjR)+t1l zzl=s>X5hytU+2lMr})2JB*4w9TWqr~f0`b@%=Fvs&jV0ECRpmBcY}iCjKoX-Anjj< z=X%V^zK+2A#D*FPCC;Dy{dI?auwg@!NrRZ#q-@?-d3FE|IjuBI&(?I_rC^dfW z1g5C&{O1k)k+N-I-~Zd{KiaBzr-Hx!YyVdlN{-L{&)O-g=Gs3Xe>|OHrH2BJcel=< zs?iLWKnwjQF5nxr7dwdNUM}0s3{=es9-Ep;(R!BC%*xw^_o<;#6dBM zV@W7(eSfBR_N+75T@qSNc~zQR@B@$pxOCJdeG5(*l6Gx=-*@4y8lEoG2xg^83#zSf zTV&MI>tR8JD~y%U6v=v35e5_D2O<5c6VLC9D+r*qJ3pCmbHpZ(%PPw>dat>HpZ-<6 zMfbG;D*k|P3u>u#FeBdZ8r2P4CuGAPUGnJd)<>FYHL2Rl<0me;UQnJ_wf9IbPkXFS zHfB#;&>Bpc88dl57Y&~Ps|Ii8Ho?TY&7An-h3sGjE`gr%q)6#7c`;iR8fT|w9@u6h z6uq(H?KnGlS5}=-B`JrC7^Z)2R83!Fu{>8HGy1T#sDnIYt7LUs$tEptlgq7Jda{+L zW1g2r_hQOnLe~CnqF2`d(Gu#6R+j3UAsi8b22jSt-te+vVzJun(VBNgZ83DDqwdF) z?nRyCoEIH2UmqxTL>lz2ZJ)ODnn?O%Gb&foYxt5>RnSN9TP@(#oPv$MB7!2eg|T0f6Yd3MDMeR9GI> zxdv5sXWAw{e7V*mL%DEy1lf|C9==YHEXbE%UCol1KGBW0x)zLz4CgykZhe}uqO(}_ zEs;N=A&@PBr>^4hq^apSccK_XPqy!7{Y~>o4S)=dx5oW->O*NLXg|a80=KOCi!ih1 zn}tqj0eF&wZU`3#-W$ql4l}^KVARwn& zMFV39b-zUHA5h06QnDxZhQuHoDL~fWFhqM#ml>yXz9~7?U_`Z7JMY+pT#OBsxU*_S zT>72t>?KUMprY^;Y-Y>1qr%KES7*y_T; z>%JE^8~0Qx~+!C^UVyJ0?%EYHACD2izA})r8vAW{IgsgaD zZCV_FY=dx9bVDo7w-CyME>9^;XVNY3R$u(c;ZIvG%5hwpx2d$VC3v<R$G>+ zFDs5r%@>oOHa-aC6q8Mxfp(pDE@n5uGGD>Sc&LR3biDp+}BBy^H87l4A+2$L>9L>mxYW2 zhcWreOXt8>Yq8C%-ByK(W`rt{^c{w?&pJuga@3jV;7Ldt0`qt>kA>3%QFh5l{5;Tl z?}_v6gsCB|LW4eeLA~J0ov78d;l2UdGSvD?8)OKj&(X@Ks6z+O$fyyg9EpNFHysEj zIirB`F(itW$*$GyQIxR`dJeoDObfsDzCoj}JK9ygQTZ63eF3C1E1)@(3M zZhyVq88s6a*z%;5$5F-%W^s97fmJmWmSR9fUzKVkv8J^9^h zo<8khGFULmutf-6a*rHd08__6i1hm*(;+#*o=Z@FVDe67acUsy8>(a`1In>(RI4)U z$bxLEY>)a<)25Pir#G3AwQ6(>7oAF_i8)riwb~`D2Yz`)z2s(pN+fv&{aI@CX24xr zHd~fhlSW9Q+joEJSixMyI87ZtqhH*RTNqyXlm=|P={C- zm~nx?R=2GH6?L_z_U56+G2KBVMtCrPfEks^V`igViQid9YOeO<4!c$u+P>$>K3%Sl z(`P~ZLwrhBZ$yWiolDUR6Qr?SwN1F&m0>(4(e~0of#zPb9~b?FUHuq3C2(0by||+K zIG?f*OxftG_h>cMx+^5KtITPXhI*sqmY|%XVd>jbK_OHBR}j)0@NB zH)!ew!NQ{*fkMU_HN~TBOq}_U&_)^iT&uO7a7QK=X(6k zG0K#GV`+eim&1jD=I97!|EBP)rr?FYLf>YsGj)yI1H(bwsmB10RRE1dsMF)qPZB^o Zpe!UaZ|)fyyrrZaYO_}jNf`qG{sSiVv)upy literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable-xxhdpi/img_tangem_pay_visa_reissuing.webp b/core/ui/src/main/res/drawable-xxhdpi/img_tangem_pay_visa_reissuing.webp new file mode 100644 index 0000000000000000000000000000000000000000..15b0b2b7257c71cf68d6adf6b6a393e219a48065 GIT binary patch literal 5554 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 Unit, private val onReveal: () -> Unit, private val onCopy: (String, CardDataType) -> Unit, @@ -38,6 +40,7 @@ internal class TangemPayCardDetailsBlockStateFactory( null }, shouldShowCardDetailsButtonOnCard = shouldShowCardDetailsButtonOnCard, + cardState = cardState, ) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 93713fe0a9..3bce71d410 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -38,6 +38,7 @@ internal data class TangemPayCardDetailsUM( val displayNameState: DisplayNameState?, val isActionsAvailable: Boolean = false, val shouldShowCardDetailsButtonOnCard: Boolean = false, + val cardState: TangemPayCardState = TangemPayCardState.Active, ) @Immutable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt index 9f742a1ab7..7f089402ee 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt @@ -77,6 +77,7 @@ internal class TangemPayCardDetailsController @AssistedInject constructor( onReveal = ::requestReveal, onCopy = ::copyData, shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard, + cardState = card.state, ) val uiState: StateFlow @@ -117,6 +118,7 @@ internal class TangemPayCardDetailsController @AssistedInject constructor( numberShort = "${StringsSigns.ASTERISK}${card.lastDigits}", cardFrozenState = card.frozenState, isActionsAvailable = card.state == TangemPayCardState.Active, + cardState = card.state, ) } subscribeToCardFrozenState(card.id) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 7cea9f32f3..c6a024f59e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -60,6 +60,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.* import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.CardDataType import com.tangem.features.tangempay.entity.DisplayNameState @@ -120,6 +121,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif .matchParentSize() .zIndex(0f), cardFrozenState = state.cardFrozenState, + cardState = state.cardState, ) Box( @@ -209,7 +211,11 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif } @Composable -private fun TangemPayCardBackground(cardFrozenState: TangemPayCardFrozenState, modifier: Modifier = Modifier) { +private fun TangemPayCardBackground( + cardState: TangemPayCardState, + cardFrozenState: TangemPayCardFrozenState, + modifier: Modifier = Modifier, +) { val isFrozen = cardFrozenState == TangemPayCardFrozenState.Frozen val freezeProgress by animateFloatAsState( targetValue = if (isFrozen) 1f else 0f, @@ -223,7 +229,14 @@ private fun TangemPayCardBackground(cardFrozenState: TangemPayCardFrozenState, m Box(modifier = modifier.fillMaxSize()) { Image( modifier = Modifier.fillMaxSize(), - painter = painterResource(R.drawable.img_tangem_pay_visa), + painter = when (cardState) { + TangemPayCardState.Active, + -> painterResource(R.drawable.img_tangem_pay_visa) + TangemPayCardState.Reissuing, + TangemPayCardState.Closing, + TangemPayCardState.Issuing, + -> painterResource(R.drawable.img_tangem_pay_visa_reissuing) + }, contentDescription = null, contentScale = ContentScale.FillBounds, ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 3be9ae9f4d..230a6ac5cb 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -32,9 +32,7 @@ import com.tangem.core.ui.ds.TangemPagerIndicator import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.* import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.pay.TangemPayCardState @@ -92,29 +90,93 @@ private fun TangemPayCardPageScreen( }, ) { scaffoldPaddings -> val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } - LazyColumn( + val contentBottomPadding = TangemTheme.dimens.spacing16 + bottomBarHeight + val reissueTitle = reissueTitleOrNull(isRedesignEnabled = isRedesignEnabled, cardState = state.cardState) + + if (reissueTitle != null) { + ReissueCardLayout( + title = reissueTitle, + cardSection = cardSection, + modifier = Modifier + .fillMaxSize() + .padding(scaffoldPaddings) + .padding(bottom = contentBottomPadding), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(scaffoldPaddings), + contentPadding = PaddingValues(bottom = contentBottomPadding), + verticalArrangement = Arrangement.spacedBy( + if (isRedesignEnabled) 0.dp else TangemTheme.dimens.spacing16, + ), + ) { + item(key = "Card") { + Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) { + cardSection() + } + } + if ( + isRedesignEnabled && + state.settingsV2.isNotEmpty() && + state.cardState == TangemPayCardState.Active + ) { + cardPageItem("Settings buttons") { + TangemPayCardPageSettingsButtonsBlock( + modifier = Modifier.fillMaxWidth(), + settings = state.settingsV2, + ) + } + } + cardState(state = state) + } + } + } +} + +private fun reissueTitleOrNull(isRedesignEnabled: Boolean, cardState: TangemPayCardState): TextReference? { + if (!isRedesignEnabled) return null + return when (cardState) { + TangemPayCardState.Reissuing -> combinedReference( + resourceReference(R.string.tangempay_reissue_card_in_progress), + stringReference(". "), + resourceReference(R.string.tangempay_reissue_card_in_progress_description), + ) + TangemPayCardState.Issuing -> combinedReference( + resourceReference(R.string.tangempay_issuing_new_digital_card_title), + stringReference(". "), + resourceReference(R.string.tangempay_reissue_card_in_progress_description), + ) + TangemPayCardState.Closing -> combinedReference( + resourceReference(R.string.tangempay_card_page_closing_banner_title), + stringReference(". "), + resourceReference(R.string.tangempay_card_page_closing_banner_description), + ) + TangemPayCardState.Active -> null + } +} + +@Composable +private fun ReissueCardLayout( + title: TextReference, + modifier: Modifier = Modifier, + cardSection: @Composable () -> Unit, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) { + cardSection() + } + Box( modifier = Modifier - .fillMaxSize() - .padding(scaffoldPaddings), - contentPadding = PaddingValues( - bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, - ), - verticalArrangement = Arrangement.spacedBy(if (isRedesignEnabled) 0.dp else TangemTheme.dimens.spacing16), + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, ) { - item(key = "Card") { - Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) { - cardSection() - } - } - if (isRedesignEnabled && state.settingsV2.isNotEmpty() && state.cardState == TangemPayCardState.Active) { - cardPageItem("Settings buttons") { - TangemPayCardPageSettingsButtonsBlock( - modifier = Modifier.fillMaxWidth(), - settings = state.settingsV2, - ) - } - } - cardState(state) + TangemPayReissueBlock(title = title) } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueBlock.kt new file mode 100644 index 0000000000..f764bfc445 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueBlock.kt @@ -0,0 +1,52 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_clock_20 + +@Composable +internal fun TangemPayReissueBlock(title: TextReference, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 48.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = TangemTheme.colors3.bg.opaque.primary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.ic_clock_20, + tint = TangemTheme.colors3.icon.secondary, + contentDescription = null, + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + text = title.resolveReference(), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, + textAlign = TextAlign.Center, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt index 8ea8355a62..7b1c3675d9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt @@ -2,20 +2,13 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH24 import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig -import com.tangem.core.ui.ds.message.TangemMessage import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.LocalVisaRedesignEnabled @@ -31,14 +24,14 @@ internal fun TangemPayReplacingCardBlock( subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description), ) { if (LocalVisaRedesignEnabled.current) { - BlockV2(title = title, subtitle = subtitle, modifier = modifier) + return } else { - BlockV1(title = title, subtitle = subtitle, modifier = modifier) + Block(title = title, subtitle = subtitle, modifier = modifier) } } @Composable -private fun BlockV1( +private fun Block( modifier: Modifier = Modifier, title: TextReference? = resourceReference(R.string.tangempay_reissue_card_in_progress), subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description), @@ -55,27 +48,6 @@ private fun BlockV1( ) } -@Composable -private fun BlockV2( - modifier: Modifier = Modifier, - title: TextReference? = resourceReference(R.string.tangempay_reissue_card_in_progress), - subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description), -) { - TangemMessage( - modifier = modifier.padding(top = TangemTheme.dimens2.x2), - title = title, - subtitle = subtitle, - leadingContent = { - Icon( - modifier = Modifier.size(20.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24), - contentDescription = null, - tint = TangemTheme.colors3.icon.primary, - ) - }, - ) -} - @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable From ffa093007b3ffe81be1362917c960982e9ca14db Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 11:04:34 +0200 Subject: [PATCH 17/22] Updated on 2026-08-14 --- .../features/tangempay/model/TangemPayChangePinModel.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt index 8163372405..d529fcd2fd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -77,9 +77,9 @@ internal class TangemPayChangePinModel @Inject constructor( uiMessageSender.send(message = ToastMessage(resourceReference(R.string.common_unknown_error))) return@launch } - uiState.update { it.copy(submitButtonLoading = false) } when (result) { SetPinResult.PIN_TOO_WEAK -> { + uiState.update { it.copy(submitButtonLoading = false) } uiMessageSender.send( message = ToastMessage(resourceReference(R.string.tangempay_pin_validation_error_message)), ) @@ -92,7 +92,10 @@ internal class TangemPayChangePinModel @Inject constructor( SetPinResult.DECRYPTION_ERROR, SetPinResult.UNKNOWN_ERROR, null, - -> uiMessageSender.send(message = ToastMessage(resourceReference(R.string.common_unknown_error))) + -> { + uiState.update { it.copy(submitButtonLoading = false) } + uiMessageSender.send(message = ToastMessage(resourceReference(R.string.common_unknown_error))) + } } } } From 132924c8c74b29b2a4558aa8c44158c09d33cb3f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 14:10:40 +0300 Subject: [PATCH 18/22] Updated on 2026-08-14 --- .../entity/TangemPayDetailsStateFactory.kt | 4 +- .../tangempay/model/TangemPayDetailsModel.kt | 9 +- ...TangemPayFreezeUnfreezeStateTransformer.kt | 4 +- .../utils/PaymentAccountStatusExt.kt | 3 + ...emPayFreezeUnfreezeStateTransformerTest.kt | 108 ++++++++++++++++++ 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformerTest.kt diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 735192a0ff..9eb2362844 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -10,7 +10,6 @@ import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_document_20 -import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardFrozenState @@ -18,6 +17,7 @@ import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.models.pay.isFrozen import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.utils.TangemPayDetailIntents +import com.tangem.features.tangempay.utils.isFresh import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -60,7 +60,7 @@ internal class TangemPayDetailsStateFactory( } fun getLoadedState(status: PaymentAccountStatusValue.Loaded): TangemPayDetailsUM { - val isFresh = status.source == StatusSource.ACTUAL && status.error == null + val isFresh = status.isFresh val hasUnfrozenCard = status.cards.any { it.frozenState == TangemPayCardFrozenState.Unfrozen } val hasIssuingCard = status.cards.any { it.state == TangemPayCardState.Issuing } val isAddCardEnabled = isFresh && !hasIssuingCard diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 888788f83f..0b89ff84dd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -169,7 +169,14 @@ internal class TangemPayDetailsModel @Inject constructor( frozenStateJobHolder.cancel() cardDetailsRepository .cardFrozenState(cardId) - .onEach { uiState.update(TangemPayFreezeUnfreezeStateTransformer(cardFrozenState = it)) } + .onEach { frozenState -> + uiState.update( + TangemPayFreezeUnfreezeStateTransformer( + cardFrozenState = frozenState, + isDataFresh = currentStatus.value.ifLoadedOrNull { it.isFresh } == true, + ), + ) + } .launchIn(modelScope) .saveIn(frozenStateJobHolder) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt index 0146ce7829..2644465ec6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt @@ -8,12 +8,14 @@ import kotlinx.collections.immutable.toPersistentList internal class TangemPayFreezeUnfreezeStateTransformer( private val cardFrozenState: TangemPayCardFrozenState, + private val isDataFresh: Boolean, ) : Transformer { override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { val balanceBlockState = if (prevState.balanceBlockState is TangemPayDetailsBalanceBlockState.Content) { + val isEnabled = isDataFresh && cardFrozenState == TangemPayCardFrozenState.Unfrozen val actionButtons = prevState.balanceBlockState.actionButtons.map { - it.copy(isEnabled = cardFrozenState == TangemPayCardFrozenState.Unfrozen) + it.copy(isEnabled = isEnabled) } prevState.balanceBlockState.copy(actionButtons = actionButtons.toPersistentList()) } else { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index 56139acdaa..0398035dfc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -21,6 +21,9 @@ internal val AccountStatus.Payment.cryptoCurrency: CryptoCurrency.Token internal val AccountStatus.Payment.isDeactivated: Boolean get() = value is PaymentAccountStatusValue.Deactivated +internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean + get() = source == StatusSource.ACTUAL && error == null + internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded = value as? PaymentAccountStatusValue.Loaded ?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}") diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformerTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformerTest.kt new file mode 100644 index 0000000000..44e221858c --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformerTest.kt @@ -0,0 +1,108 @@ +package com.tangem.features.tangempay.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +internal class TangemPayFreezeUnfreezeStateTransformerTest { + + @ParameterizedTest + @MethodSource("provideCases") + fun `GIVEN content state WHEN transform THEN buttons enabled only when fresh and unfrozen`(case: Case) { + // Arrange + val state = contentState(actionButtonsEnabled = true) + + // Act + val result = TangemPayFreezeUnfreezeStateTransformer( + cardFrozenState = case.frozenState, + isDataFresh = case.isDataFresh, + ).transform(state) + + // Assert + val enabled = result.balanceBlockState.actionButtons.map { it.isEnabled } + assertThat(enabled).containsExactly(case.expectedEnabled, case.expectedEnabled) + } + + @Test + fun `GIVEN non-content state WHEN transform THEN state is unchanged`() { + // Arrange + val state = loadingState() + + // Act + val result = TangemPayFreezeUnfreezeStateTransformer( + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + isDataFresh = true, + ).transform(state) + + // Assert + assertThat(result).isEqualTo(state) + } + + private fun contentState(actionButtonsEnabled: Boolean): TangemPayDetailsUM = baseState( + balanceBlockState = TangemPayDetailsBalanceBlockState.Content( + actionButtons = persistentListOf( + actionButton(isEnabled = actionButtonsEnabled), + actionButton(isEnabled = actionButtonsEnabled), + ), + cardsBlockState = null, + fiatBalance = TextReference.EMPTY, + isBalanceFlickering = false, + ), + ) + + private fun loadingState(): TangemPayDetailsUM = baseState( + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( + actionButtons = persistentListOf(actionButton(isEnabled = true)), + cardsBlockState = null, + ), + ) + + private fun baseState(balanceBlockState: TangemPayDetailsBalanceBlockState) = TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig( + onBackClick = {}, + onOpenMenu = {}, + items = persistentListOf(), + itemsV2 = persistentListOf(), + ), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + balanceBlockState = balanceBlockState, + addToWalletBlockState = null, + isBalanceHidden = false, + errorNotificationConfig = null, + accountDeactivatedNotificationConfig = null, + ) + + private fun actionButton(isEnabled: Boolean) = ActionButtonConfig( + text = TextReference.EMPTY, + iconResId = 0, + onClick = {}, + isEnabled = isEnabled, + ) + + internal data class Case( + val frozenState: TangemPayCardFrozenState, + val isDataFresh: Boolean, + val expectedEnabled: Boolean, + ) + + private companion object { + @JvmStatic + fun provideCases() = listOf( + Case(frozenState = TangemPayCardFrozenState.Unfrozen, isDataFresh = true, expectedEnabled = true), + Case(frozenState = TangemPayCardFrozenState.Unfrozen, isDataFresh = false, expectedEnabled = false), + Case(frozenState = TangemPayCardFrozenState.Frozen, isDataFresh = true, expectedEnabled = false), + Case(frozenState = TangemPayCardFrozenState.Pending, isDataFresh = true, expectedEnabled = false), + Case(frozenState = TangemPayCardFrozenState.Frozen, isDataFresh = false, expectedEnabled = false), + Case(frozenState = TangemPayCardFrozenState.Pending, isDataFresh = false, expectedEnabled = false), + ) + } +} \ No newline at end of file From c3740a70c0a511005d28360c1233b7fc03c2df9a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 17:56:10 +0500 Subject: [PATCH 19/22] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 15 +- ...actorImplLoadIntegratedApprovalDataTest.kt | 207 ++++++++++++++++++ .../tangem/lib/crypto/BlockchainFeeUtils.kt | 52 +++++ 3 files changed, 269 insertions(+), 5 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 554c14c48a..9735d76df7 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -63,6 +63,7 @@ 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.lib.crypto.BlockchainFeeUtils.patchIntegratedApprovalPriorityFee import com.tangem.lib.crypto.BlockchainUtils.isBitcoin import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.coroutines.runSuspendCatching @@ -1460,11 +1461,13 @@ internal class SwapInteractorImpl @Inject constructor( raise(GetFeeError.DataError(error)) } - val approvalFee = getFeeUseCase( - transactionData = approvalTx, - userWallet = fromStatus.userWallet, - network = fromStatus.currency.network, - ).bind() + val approvalFee = runSuspendCatching { + getFeeUseCase( + transactionData = approvalTx, + userWallet = fromStatus.userWallet, + network = fromStatus.currency.network, + ).bind().patchIntegratedApprovalPriorityFee(INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL) + }.getOrElse { error -> raise(GetFeeError.DataError(error)) } IntegratedApprovalData( approvalTransaction = approvalTx, @@ -2445,6 +2448,8 @@ internal class SwapInteractorImpl @Inject constructor( } companion object { + private const val INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL = 115 // 15% increase + private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 25.toBigDecimal() // in USD private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD private val PRICE_IMPACT_AMOUNT_LOW_THRESHOLD = 100_000.toBigDecimal() // in USD diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt index f2ec1512df..25d50a9077 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee @@ -17,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import java.math.BigDecimal +import java.math.BigInteger /** * Tests for [SwapInteractorImpl.loadIntegratedApprovalData]. @@ -185,6 +187,211 @@ internal class SwapInteractorImplLoadIntegratedApprovalDataTest : SwapInteractor } } + // region patchIntegratedApprovalPriorityFee — INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL (115 = +15% gas-price) + + /** + * The loaded approval fee is patched via + * [com.tangem.lib.crypto.BlockchainFeeUtils.patchIntegratedApprovalPriorityFee] before being + * returned. Scales the **gas-price** fields (Legacy `gasPrice`; EIP1559 + * `maxFeePerGas` and `priorityFee`) and the derived `amount` for [Fee.Ethereum] legs by 15%; + * The new `amount` is recomputed from `gasLimit * newGasPrice` shifted left by `decimals`, + * independent of the input amount value. + */ + @Test + fun `GIVEN Ethereum Legacy Single fee WHEN loaded THEN gasPrice and amount bumped by 15 percent`() = runTest { + // Arrange + val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT) + val initialFee = Fee.Ethereum.Legacy( + amount = ethAmount(BigDecimal("0.002")), // gasLimit * gasPrice / 1e18 = 100_000 * 20e9 / 1e18 + gasLimit = BigInteger.valueOf(100_000), + gasPrice = BigInteger.valueOf(20_000_000_000), + ) + coEvery { + getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any()) + } returns TransactionFee.Single(normal = initialFee).right() + + // Act + val result = loadLimited(fromStatus) + + // Assert + val patched = result.singleNormal() + // gasLimit is NOT changed by this patch (it bumps gas-price only) + assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(100_000)) + // 20_000_000_000 * 115 / 100 = 23_000_000_000 + assertThat(patched.gasPrice).isEqualTo(BigInteger.valueOf(23_000_000_000)) + // amount recomputed from gasLimit * newGasPrice: 100_000 * 23e9 / 1e18 = 0.0023 + assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0023")) + // amount decimals preserved + assertThat(patched.amount.decimals).isEqualTo(18) + } + + @Test + fun `GIVEN Ethereum EIP1559 Single fee WHEN loaded THEN gas-price fields bumped AND gasLimit untouched`() = + runTest { + // Arrange + val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT) + val initialFee = Fee.Ethereum.EIP1559( + amount = ethAmount(BigDecimal("0.0032")), // gasLimit * maxFeePerGas / 1e18 = 80_000 * 40e9 / 1e18 + gasLimit = BigInteger.valueOf(80_000), + maxFeePerGas = BigInteger.valueOf(40_000_000_000), + priorityFee = BigInteger.valueOf(2_000_000_000), + ) + coEvery { + getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any()) + } returns TransactionFee.Single(normal = initialFee).right() + + // Act + val result = loadLimited(fromStatus) + + // Assert + val patched = result.singleNormal() + // gasLimit is NOT changed by this patch (it bumps gas-price only) + assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(80_000)) + // EIP1559 gas-price fields scaled by 115 / 100 + assertThat(patched.maxFeePerGas).isEqualTo(BigInteger.valueOf(46_000_000_000)) + assertThat(patched.priorityFee).isEqualTo(BigInteger.valueOf(2_300_000_000)) + // amount recomputed from gasLimit * newMaxFeePerGas: 80_000 * 46e9 / 1e18 = 0.00368 + assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00368")) + } + + @Test + fun `GIVEN Choosable Ethereum fee WHEN loaded THEN all three legs gasPrice bumped by 15 percent`() = runTest { + // Arrange + val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT) + val gasPrice = BigInteger.valueOf(20_000_000_000) + val choosable = TransactionFee.Choosable( + minimum = Fee.Ethereum.Legacy( + amount = ethAmount(BigDecimal("0.0008")), // 40_000 * 20e9 / 1e18 + gasLimit = BigInteger.valueOf(40_000), + gasPrice = gasPrice, + ), + normal = Fee.Ethereum.Legacy( + amount = ethAmount(BigDecimal("0.0016")), // 80_000 * 20e9 / 1e18 + gasLimit = BigInteger.valueOf(80_000), + gasPrice = gasPrice, + ), + priority = Fee.Ethereum.Legacy( + amount = ethAmount(BigDecimal("0.0024")), // 120_000 * 20e9 / 1e18 + gasLimit = BigInteger.valueOf(120_000), + gasPrice = gasPrice, + ), + ) + coEvery { + getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any()) + } returns choosable.right() + + // Act + val patched = (loadLimited(fromStatus).feeOrFail() as TransactionFee.Choosable) + + // Assert — every leg's gas-price scaled (gasPrice * 115 / 100 = 23e9), gasLimit unchanged + val newGasPrice = BigInteger.valueOf(23_000_000_000) + assertThat((patched.minimum as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(40_000)) + assertThat((patched.minimum as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice) + assertThat((patched.normal as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(80_000)) + assertThat((patched.normal as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice) + assertThat((patched.priority as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(120_000)) + assertThat((patched.priority as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice) + } + + @Test + fun `GIVEN non-Ethereum approval fee WHEN loaded THEN fee is returned unchanged`() = runTest { + // Arrange + val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT) + val commonFee = Fee.Common(amount = ethAmount(BigDecimal("0.5"), decimals = 8)) + coEvery { + getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any()) + } returns TransactionFee.Single(normal = commonFee).right() + + // Act + val result = loadLimited(fromStatus) + + // Assert — non-Ethereum legs pass through untouched (same instance) + assertThat(result.singleNormal()).isSameInstanceAs(commonFee) + } + + @Test + fun `GIVEN Ethereum Legacy fee with zero gasLimit WHEN loaded THEN gasPrice bumped and amount is zero`() = + runTest { + // Arrange + val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT) + val zeroGasFee = Fee.Ethereum.Legacy( + amount = ethAmount(BigDecimal("0.000002")), + gasLimit = BigInteger.ZERO, + gasPrice = BigInteger.valueOf(20_000_000_000), + ) + coEvery { + getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any()) + } returns TransactionFee.Single(normal = zeroGasFee).right() + + // Act + val result = loadLimited(fromStatus) + + // Assert — the gas-price path does NOT short-circuit on zero gasLimit (unlike the + // gas-limit path); gasPrice is still bumped and amount recomputes to gasLimit(0) * price = 0 + val patched = result.singleNormal() + assertThat(patched.gasLimit).isEqualTo(BigInteger.ZERO) + assertThat(patched.gasPrice).isEqualTo(BigInteger.valueOf(23_000_000_000)) + assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal.ZERO) + } + + @Test + fun `GIVEN Ethereum TokenCurrency approval fee WHEN loaded THEN returns Left DataError wrapping [REDACTED_TASK_KEY]`() = + runTest { + // Arrange + val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT) + val tokenFee = Fee.Ethereum.TokenCurrency( + amount = ethAmount(BigDecimal("0.001")), + gasLimit = BigInteger.valueOf(100_000), + coinPriceInToken = BigInteger.ONE, + feeTransferGasLimit = BigInteger.valueOf(50_000), + baseGas = BigInteger.valueOf(21_000), + ) + coEvery { + getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any()) + } returns TransactionFee.Single(normal = tokenFee).right() + + // Act — the patch throws IllegalStateException, but the fee load is wrapped in + // runSuspendCatching ([REDACTED_TASK_KEY]) so it is caught and converted to Left(DataError) + // instead of crashing the DEX swap flow. + val result = loadLimited(fromStatus) + + // Assert + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.DataError::class.java) + val cause = (error as GetFeeError.DataError).cause + assertThat(cause).isInstanceOf(IllegalStateException::class.java) + assertThat(cause?.message).contains("[REDACTED_TASK_KEY]") + } + } + + // endregion + + private suspend fun loadLimited(fromStatus: com.tangem.domain.swap.models.SwapCurrencyStatus) = + sut.loadIntegratedApprovalData( + fromStatus = fromStatus, + spenderAddress = SPENDER, + approveType = ApproveType.LIMITED, + approvalAmount = SWAP_AMOUNT, + ) + + /** Unwraps a Right result into its [TransactionFee], failing the test on Left. */ + private fun arrow.core.Either.feeOrFail(): TransactionFee { + assertThat(isRight()).isTrue() + return getOrNull()!!.approvalFee + } + + /** Unwraps a Right result into the `normal` leg of a [TransactionFee.Single], cast to [T]. */ + private inline fun arrow.core.Either.singleNormal(): T { + return (feeOrFail() as TransactionFee.Single).normal as T + } + + private fun ethAmount(value: BigDecimal, decimals: Int = 18): Amount = Amount( + currencySymbol = "ETH", + value = value, + decimals = decimals, + ) + private companion object { const val SPENDER = "0xSpender" const val CONTRACT = "0xContract" diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt index 5c08e72030..9a8b774a5c 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt @@ -39,6 +39,22 @@ object BlockchainFeeUtils { } } + fun TransactionFee.patchIntegratedApprovalPriorityFee(increaseBy: Int): TransactionFee { + val patchedFee = when (this) { + is TransactionFee.Choosable -> { + copy( + normal = normal.increaseGasPrice(increaseBy), + minimum = minimum.increaseGasPrice(increaseBy), + priority = priority.increaseGasPrice(increaseBy), + ) + } + is TransactionFee.Single -> copy( + normal = normal.increaseGasPrice(increaseBy), + ) + } + return patchedFee + } + private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee { return when (this) { is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") @@ -81,4 +97,40 @@ object BlockchainFeeUtils { is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") } } + + /** + * Increase gasPrice/maxFeePerGas for Fee.Ethereum + */ + private fun Fee.increaseGasPrice(percent: Int): Fee { + if (this !is Fee.Ethereum) return this + + return when (this) { + is Fee.Ethereum.EIP1559 -> { + val increasedGasPrice = maxFeePerGas.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT) + val increasedAmount = amount.copy( + value = gasLimit.toBigDecimal() + .multiply(increasedGasPrice.toBigDecimal()) + .movePointLeft(amount.decimals), + ) + copy( + amount = increasedAmount, + maxFeePerGas = increasedGasPrice, + priorityFee = priorityFee.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT), + ) + } + is Fee.Ethereum.Legacy -> { + val increasedGasPrice = gasPrice.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT) + val increasedAmount = amount.copy( + value = gasLimit.toBigDecimal() + .multiply(increasedGasPrice.toBigDecimal()) + .movePointLeft(amount.decimals), + ) + copy( + amount = increasedAmount, + gasPrice = increasedGasPrice, + ) + } + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") + } + } } \ No newline at end of file From 312f241f7cd5f51234c8ef4af86c21d0a705e9c6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 16:58:04 +0400 Subject: [PATCH 20/22] Updated on 2026-08-14 --- .../DefaultWalletManagersFacade.kt | 10 ++ .../walletmanager/WalletManagersFacade.kt | 2 + .../feature/swap/domain/SwapInteractorImpl.kt | 28 +++- .../SwapInteractorImplFindBestQuoteTest.kt | 139 ++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index cba4635b01..83e74f5040 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -476,6 +476,16 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + override suspend fun isSwapSpenderAllowed( + userWalletId: UserWalletId, + network: Network, + spenderAddress: String, + ): Boolean = withContext(dispatchers.io) { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + ?: return@withContext false + walletManager.isSwapSpenderAllowed(spenderAddress) + } + override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? { val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index aa21b96b61..fc7e661cfd 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -246,6 +246,8 @@ interface WalletManagersFacade { */ suspend fun getPsbtFee(userWalletId: UserWalletId, network: Network, psbtBase64: String): BigDecimal? + suspend fun isSwapSpenderAllowed(userWalletId: UserWalletId, network: Network, spenderAddress: String): Boolean + /** * Get requirements for asset(currency) * @return null if there's no requirement, otherwise [AssetRequirementsCondition]. diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9735d76df7..f301453ef5 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -131,11 +131,29 @@ internal class SwapInteractorImpl @Inject constructor( ConcurrentHashMap(), ) + private val yieldSwapAllowedRouters = newSetFromMap(ConcurrentHashMap()) + private fun hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String?) = integratedApprovalFallbackContexts.contains( IntegratedApprovalFallbackKey.of(fromSwapCurrencyStatus, spenderAddress), ) + private suspend fun isYieldSwapRouterAllowed( + fromSwapCurrencyStatus: SwapCurrencyStatus, + routerAddress: String, + ): Boolean { + val network = fromSwapCurrencyStatus.currency.network + val key = "${network.rawId}:${routerAddress.lowercase()}" + if (yieldSwapAllowedRouters.contains(key)) return true + val isAllowed = walletManagersFacade.isSwapSpenderAllowed( + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = network, + spenderAddress = routerAddress, + ) + if (isAllowed) yieldSwapAllowedRouters.add(key) + return isAllowed + } + override suspend fun getPair( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -304,7 +322,7 @@ internal class SwapInteractorImpl @Inject constructor( } } } - }.awaitAll().toMap() + }.awaitAll().filterNotNull().toMap() } } @@ -316,7 +334,7 @@ internal class SwapInteractorImpl @Inject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, expressOperationType: ExpressOperationType, - ): Pair { + ): Pair? { if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true && !swapFeatureToggles.isYieldSwapEnabled ) { @@ -366,6 +384,12 @@ internal class SwapInteractorImpl @Inject constructor( val dexRouterSpenderAddress = maybeQuote.getOrNull()?.allowanceContract + if (isYieldSwap && dexRouterSpenderAddress != null && + !isYieldSwapRouterAllowed(fromSwapCurrencyStatus, dexRouterSpenderAddress) + ) { + return null + } + val allowanceInfo = spenderAddress?.let { allowanceContract -> getAllowanceInfoUseCase( userWalletId = fromSwapCurrencyStatus.userWalletId, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index de553ebb28..dd3da80960 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -888,6 +888,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns yieldProxyAddress + coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) } returns true } @Test @@ -1131,6 +1132,144 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( } } + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class YieldSwapRouterAllowlist { + + private val yieldProxyAddress = "0xYieldModuleProxy" + private val yieldTokenContract = "0xTokenContract" + private val notAllowedRouter = "0xMoonPayRouter" + private val allowedRouter = "0xOneInchRouter" + + @BeforeEach + fun enableYieldSwap() { + every { swapFeatureToggles.isYieldSwapEnabled } returns true + coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns yieldProxyAddress + } + + private fun yieldTokenStatus(yieldActive: Boolean = true) = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = yieldTokenContract, + isCoin = false, + amount = BigDecimal("10"), + yieldSupplyActive = yieldActive, + yieldSupplyAllowedToSpend = true, + ) + + private fun stubDexQuote(providerId: String, router: String) { + coEvery { + repository.findBestQuote( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), toNetwork = any(), fromAmount = any(), + fromDecimals = any(), toDecimals = any(), + providerId = providerId, rateType = any(), + ) + } returns buildQuoteModel(allowanceContract = router).right() + } + + private fun stubExchangeData(providerId: String) { + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = providerId, rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns buildSwapDataModelDex().right() + } + + @Test + fun `should hide yield-swap DEX provider whose router is not allowed by the registry`() = runTest { + // Given — yield active, router NOT in the SwapExecutionRegistry (MoonPay/swaps.xyz) + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + stubDexQuote(dexProvider.providerId, notAllowedRouter) + coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), notAllowedRouter) } returns false + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = yieldTokenStatus(), + toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork), + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — provider is absent from the list + assertThat(result.containsKey(dexProvider)).isFalse() + assertThat(result).isEmpty() + } + + @Test + fun `should keep yield-swap DEX provider whose router is allowed by the registry`() = runTest { + // Given — yield active, router whitelisted (1inch) + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + stubDexQuote(dexProvider.providerId, allowedRouter) + stubExchangeData(dexProvider.providerId) + coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), allowedRouter) } returns true + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = yieldTokenStatus(), + toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork), + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then + assertThat(result.containsKey(dexProvider)).isTrue() + assertThat(result[dexProvider]).isNotNull() + } + + @Test + fun `should hide only the not-allowed router and keep the allowed one for yield swaps`() = runTest { + // Given — two DEX providers, only one router whitelisted + val allowedProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-allowed") + val blockedProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-blocked") + stubDexQuote(allowedProvider.providerId, allowedRouter) + stubDexQuote(blockedProvider.providerId, notAllowedRouter) + stubExchangeData(allowedProvider.providerId) + coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), allowedRouter) } returns true + coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), notAllowedRouter) } returns false + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = yieldTokenStatus(), + toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork), + providers = listOf(allowedProvider, blockedProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then + assertThat(result.containsKey(allowedProvider)).isTrue() + assertThat(result.containsKey(blockedProvider)).isFalse() + } + + @Test + fun `should not apply the registry filter to regular non-yield swaps`() = runTest { + // Given — yield NOT active; the registry verdict must be irrelevant for plain DEX swaps + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + stubDexQuote(dexProvider.providerId, notAllowedRouter) + stubExchangeData(dexProvider.providerId) + coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) } returns false + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = yieldTokenStatus(yieldActive = false), + toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork), + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — provider stays; the on-chain allowlist does not gate non-yield swaps + assertThat(result.containsKey(dexProvider)).isTrue() + coVerify(exactly = 0) { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) } + } + } + /** * Regular (non-yield) DEX swap with the integrated-approve toggle ON: the * `isAllowanceSatisfied` matrix in `manageDex`. From 28bc842e91f1ff201b68c338073f063c039259a9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 13:59:39 +0100 Subject: [PATCH 21/22] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index b298226c5c..d6232f85cd 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1587" +tangemBlockchainSdk = "releases-6.0-1588" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-6.0-626" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From e4ac94d5a08fa79bbb5f711042fd9cf8b317d39d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 14:56:12 +0000 Subject: [PATCH 22/22] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index d6232f85cd..8b7ac52f4f 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,16 +5,16 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1588" +tangemBlockchainSdk = "develop-1586" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-630" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ - - +tangemUsedeskSdk = "main-9" +#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^ [libraries] blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" } @@ -23,6 +23,9 @@ card-core = { module = "com.tangem.tangem-sdk-kotlin:core", version.ref = "tange hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" } hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" } +usedesk-chat-sdk = { module = "com.tangem.usedesk:chat-sdk", version.ref = "tangemUsedeskSdk" } +usedesk-chat-gui = { module = "com.tangem.usedesk:chat-gui", version.ref = "tangemUsedeskSdk" } + vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" } vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" } vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" }