Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 20:05:51 +03:00
commit d0b35bc331
680 changed files with 26556 additions and 2709 deletions

View file

@ -2,7 +2,7 @@
name: analyze-logs name: analyze-logs
description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation. description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation.
allowed-tools: Read, Grep allowed-tools: Read, Grep
argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] [--no-secrets-audit]
--- ---
Analyze the Tangem app user log file at path: `$ARGUMENTS` Analyze the Tangem app user log file at path: `$ARGUMENTS`
@ -108,12 +108,37 @@ Launch ALL Grep calls below in parallel. Steps 2+3 search the **full file** (dev
- `MainActivity.*onNewIntent` — deep link or push notification - `MainActivity.*onNewIntent` — deep link or push notification
- `CardSDK_Session.*start card session` — NFC session starts - `CardSDK_Session.*start card session` — NFC session starts
**Secrets & PII Audit (full file, head_limit: 20 each, -n: true):**
Skip this entire group if `--no-secrets-audit` is in arguments.
- API key leak in URL: `[?&](api[_-]?key|apiKey|access_token|token|secret)=(?!\*+)[^&\s]{8,}`
- Bearer token: `Bearer\s+[A-Za-z0-9._\-]{20,}`
- JWT: `eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+`
- Authorization header: `(?i)authorization:\s*\S+`
- Critical PII in JSON: `"(privateKey|mnemonic|seedPhrase|private_key|seed_phrase)"\s*:\s*"[^"]+"`
- card_public_key in JSON: `"card_public_key"\s*:\s*"[^"]{40,}"`
- FCM push token: `:APA91[A-Za-z0-9_\-]{100,}`
- xprv/tprv extended private key: `\b[xytzuv]prv[A-Za-z0-9]{100,}`
- Suspicious long hex in URL path: `https?://[^?\s]+/[A-Fa-f0-9]{32,}\b`
- Masking health check: count of `\*{6,}` — if 0 in a build that should mask, flag pipeline broken
**Error filtering:** When processing error results, skip these noisy matches: **Error filtering:** When processing error results, skip these noisy matches:
- `java.io.IOException: Canceled` — normal request cancellation - `java.io.IOException: Canceled` — normal request cancellation
- `HttpException(code=304` — HTTP "Not Modified" - `HttpException(code=304` — HTTP "Not Modified"
- Bare stacktrace lines starting with `\tat` - Bare stacktrace lines starting with `\tat`
- `<-- HTTP FAILED: java.io.IOException: Canceled` - `<-- HTTP FAILED: java.io.IOException: Canceled`
### Step 6.5: Masking Consistency Check
Skip if `--no-secrets-audit` in arguments. Run sequentially after the parallel batch (needs results from the masked-endpoint grep).
1. Grep `https?://[^/\s]+/[^\s*]*\*{6,}` (full file, head_limit: 50) — collect all URLs where a path segment is masked
2. For each unique `host + path-prefix-before-mask`, derive the prefix string
3. For each prefix, Grep the prefix followed by a non-`*` character (`<prefix>[^*\s]`, head_limit: 20)
- If hits found → masking inconsistency: same endpoint has both masked and unmasked variants
- Record the prefix, count of masked hits, count of unmasked hits, first unmasked line number
### Step 7: Deep Dive ### Step 7: Deep Dive
For each significant error found above: For each significant error found above:
@ -207,6 +232,37 @@ Structure your report EXACTLY as follows:
|------|-------|---------| |------|-------|---------|
(chronological: app starts, card sessions, navigation, errors, notable API calls) (chronological: app starts, card sessions, navigation, errors, notable API calls)
## Secrets & PII Audit
Omit this section entirely if `--no-secrets-audit` was passed.
### Health Check
- Total masked tokens (`******`) in log: **N**
- If N = 0 in a build expected to mask, flag: "masking pipeline may be broken"
### Confirmed Leaks (CRITICAL / HIGH)
| Line | Severity | Type | Matched (first 16 chars + `…`) | Context |
|------|----------|------|--------------------------------|---------|
### Masking Inconsistencies
| Endpoint Prefix | Masked Hits | Unmasked Hits | First Unmasked Line |
|-----------------|-------------|---------------|---------------------|
### Suspected Leaks (MEDIUM / LOW)
| Line | Severity | Type | Pattern Matched | Why Suspect |
|------|----------|------|-----------------|-------------|
**Severity legend:**
- **CRITICAL** — private key / mnemonic / xprv in clear text
- **HIGH** — API key / bearer / JWT / card_public_key visible
- **MEDIUM** — push token, card_id, persistent identifiers
- **LOW** — heuristic patterns that may be false positives (tx hash, content hash)
**Output rules:**
- Never include the full matched value — always truncate to 16 chars + `…`
- For LOW severity, add a "Why Suspect" column explaining typical false positives
- Skip matches from these known-public Tangem endpoints: `/v1/coins/settings`, `/v1/geo`, `/v1/currencies`, `/v1/hot_crypto`
## Analysis Summary ## Analysis Summary
(2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations. (2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations.
If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.) If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.)

View file

@ -9,6 +9,11 @@
"type": "stdio", "type": "stdio",
"command": "npx", "command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"] "args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
},
"notion": {
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"]
} }
} }
} }

View file

@ -283,6 +283,8 @@ dependencies {
implementation(projects.features.onboardingV2.impl) implementation(projects.features.onboardingV2.impl)
implementation(projects.features.stories.api) implementation(projects.features.stories.api)
implementation(projects.features.stories.impl) implementation(projects.features.stories.impl)
implementation(projects.features.survey.api)
implementation(projects.features.survey.impl)
implementation(projects.features.txhistory.api) implementation(projects.features.txhistory.api)
implementation(projects.features.txhistory.impl) implementation(projects.features.txhistory.impl)
implementation(projects.features.biometry.api) implementation(projects.features.biometry.api)

View file

@ -183,9 +183,11 @@ abstract class BaseTestCase : TestCase(
"GASLESS_APPROVAL_ENABLED" to true, "GASLESS_APPROVAL_ENABLED" to true,
"MAIN_SCREEN_QR_SCANNING_ENABLED" to true, "MAIN_SCREEN_QR_SCANNING_ENABLED" to true,
"ADD_AND_MANAGE_TOKENS_ENABLED" to true, "ADD_AND_MANAGE_TOKENS_ENABLED" to true,
"ASSETS_DISCOVERY_ENABLED" to true,
"VISA_ONBOARDING_ENABLED" to true, "VISA_ONBOARDING_ENABLED" to true,
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true, "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
"AND_15310_ADD_FUNDS_STAGE1" to true, "AND_15310_ADD_FUNDS_STAGE1" to true,
"APP_REDESIGN_ENABLED" to true,
) )
) )
} }

View file

@ -48,6 +48,10 @@ object TestConstants {
const val USER_TOKENS_API_SCENARIO = "user_tokens_api" const val USER_TOKENS_API_SCENARIO = "user_tokens_api"
const val REFERRAL_API_SCENARIO = "referral_api" const val REFERRAL_API_SCENARIO = "referral_api"
const val QUOTES_API_SCENARIO = "quotes_api" const val QUOTES_API_SCENARIO = "quotes_api"
const val CREATE_USER_WALLET_API_SCENARIO = "create_user_wallet_api"
const val WALLET_TOKENS_API_SCENARIO = "wallet_tokens_api"
const val MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO = "moralis_evm_token_balances_api"
const val PROVIDERS_API_SCENARIO = "networks_providers"
const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk" const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk"
const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " + const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " +
@ -60,6 +64,9 @@ object TestConstants {
"bread much nature basic fun iron benefit egg error prosper" "bread much nature basic fun iron benefit egg error prosper"
const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash" const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash"
const val SEED_PHRASE_HAPPY_PATH =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility" const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility"
const val TANGEM_PAY_ACCESS_CODE = "517384" const val TANGEM_PAY_ACCESS_CODE = "517384"
} }

View file

@ -115,5 +115,13 @@ private fun extractText(node: SemanticsNode): String? {
private fun parseVolume(node: SemanticsNode): Double? { private fun parseVolume(node: SemanticsNode): Double? {
val text = extractText(node) ?: return null val text = extractText(node) ?: return null
return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() val multiplier = when {
text.contains('T', ignoreCase = true) -> 1_000_000_000_000.0
text.contains('B', ignoreCase = true) -> 1_000_000_000.0
text.contains('M', ignoreCase = true) -> 1_000_000.0
text.contains('K', ignoreCase = true) -> 1_000.0
else -> 1.0
}
val number = text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() ?: return null
return number * multiplier
} }

View file

@ -11,6 +11,11 @@ fun KNode.clickWithAssertion() {
performClick() performClick()
} }
fun KNode.clickWhenEnabled() {
assertIsEnabled()
performClick()
}
fun KNode.assertTextContainsSafe( fun KNode.assertTextContainsSafe(
text: String, text: String,
substring: Boolean = false, substring: Boolean = false,

View file

@ -4,8 +4,6 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.wallet.R
import io.github.kakaocup.kakao.common.utilities.getResourceString
fun BaseTestCase.swipeVertical( fun BaseTestCase.swipeVertical(
direction: SwipeDirection, direction: SwipeDirection,
@ -31,21 +29,6 @@ fun BaseTestCase.pullToRefresh(steps: Int = 1000) {
) )
} }
fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) {
val searchBarText = device.uiDevice
.findObject(By.textContains(getResourceString(R.string.markets_search_header_title)))
val bounds = searchBarText.visibleBounds
val centerX = bounds.centerX()
val startY = bounds.centerY()
val endY = when (direction) {
SwipeDirection.UP -> 50
SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100
}
device.uiDevice.swipe(centerX, startY, centerX, endY, 100)
}
fun BaseTestCase.openTheAppFromRecents() { fun BaseTestCase.openTheAppFromRecents() {
device.uiDevice.waitForIdle() device.uiDevice.waitForIdle()

View file

@ -6,96 +6,35 @@ import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.checkSingleCurrencyMainScreen( fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) {
cardBlockchain: String,
cardTitle: String,
withTransactions: Boolean = false,
withWalletImage: Boolean = true
) {
step("Assert card title equal '$cardTitle'") { step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) } onMainScreen { walletNameText.assertTextEquals(cardTitle) }
} }
if (withWalletImage) {
step("Assert card image is displayed") { //TODO: create assertion method for checking images
onMainScreen { walletImage.assertIsDisplayed() }
}
} else {
step("Assert card image is not displayed") {
onMainScreen { walletImage.assertIsNotDisplayed() }
}
}
step("Assert 'Receive' button is displayed") {
onMainScreen { receiveButton.assertIsDisplayed() }
}
step("Assert 'Buy' button is displayed") { step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() } onMainScreen { buyButton.assertIsDisplayed() }
} }
step("Assert 'Send' button is displayed") {
onMainScreen { sendButton.assertIsDisplayed() }
}
step("Assert 'Sell' button is displayed") { step("Assert 'Sell' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() } onMainScreen { sellButton.assertIsDisplayed() }
} }
step("Assert 'Swap' button is not displayed") { step("Assert 'Swap' button is not displayed") {
onMainScreen { swapButton.assertIsNotDisplayed() } onMainScreen { swapButton.assertIsNotDisplayed() }
} }
step("Assert 'Market Price' on single card main screen is displayed") {
onMainScreen { marketPriceBlock().assertIsDisplayed() }
}
step("Assert 'Market Price' title equals $cardBlockchain Market Price") {
onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") }
}
step("Swipe up") { step("Swipe up") {
swipeVertical(SwipeDirection.UP) swipeVertical(SwipeDirection.UP)
} }
if (withTransactions) {
step("Assert 'Transactions' block is displayed") {
onMainScreen { transactionsExplorerText.assertIsDisplayed() }
}
step("Assert 'Transactions' title is displayed") {
onMainScreen { transactionsTitle.assertIsDisplayed() }
}
step("Assert 'Explorer' icon is displayed") {
onMainScreen { transactionsExplorerIcon.assertIsDisplayed() }
}
} else {
step("Assert empty 'Transactions' block is displayed") {
onMainScreen { emptyTransactionBlock.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block icon is displayed") {
onMainScreen { emptyTransactionBlockIcon.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block text is displayed") {
onMainScreen { emptyTransactionBlockText.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block 'Explore' button is displayed") {
onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() }
}
}
step("Assert 'Add & Manage' button is not displayed") { step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() } onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() }
} }
} }
fun BaseTestCase.checkMultiCurrencyMainScreen( fun BaseTestCase.checkMultiCurrencyMainScreen(
devicesCount: String,
cardTitle: String, cardTitle: String,
withWalletImage: Boolean = true
) { ) {
step("Assert card title equal '$cardTitle'") { step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) } onMainScreen { walletNameText.assertTextEquals(cardTitle) }
} }
if (withWalletImage) { step("Assert 'Buy' button is displayed") {
step("Assert card image is displayed") { onMainScreen { buyButton.assertIsDisplayed() }
onMainScreen { walletImage.assertIsDisplayed() }
}
} else {
step("Assert card image is not displayed") {
onMainScreen { walletImage.assertIsNotDisplayed() }
}
}
step("Assert devices count equal to '$devicesCount'") {
onMainScreen { walletDevicesCount.assertTextContains(devicesCount) }
} }
step("Assert 'Add funds' button is displayed") { step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() } onMainScreen { addFundsButton.assertIsDisplayed() }

View file

@ -1,28 +1,28 @@
package com.tangem.scenarios package com.tangem.scenarios
import androidx.compose.ui.test.ExperimentalTestApi
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import com.tangem.screens.onMarketsExchangesScreen import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen import com.tangem.screens.onMarketsScreen
import com.tangem.screens.onMarketsTokenDetailsScreen import com.tangem.screens.onMarketsTokenDetailsScreen
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: String) { fun BaseTestCase.openTokenDetailsFromMarketsScreen(blockchainName: String, tokenName: String) {
step("Open 'Markets' screen") { step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() } onMainScreen { searchThroughMarketPlaceholder.performClick() }
waitForIdle() waitForIdle()
} }
step("Click on 'Search' placeholder") {
onMarketsScreen { searchThroughMarketPlaceholder.performClick() }
}
step("Click on $blockchainName blockchain") { step("Click on $blockchainName blockchain") {
waitForIdle() waitForIdle()
onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() } onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() }
} }
step("Click on $tokenName token") { step("Click on 'In your portfolio' block") {
waitForIdle()
onMarketsTokenDetailsScreen { inYourPortfolioBlock.clickWithAssertion() }
}
step("Click on $tokenName token in 'Your portfolio' bottom sheet") {
waitForIdle() waitForIdle()
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() } onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
} }
@ -59,6 +59,7 @@ fun BaseTestCase.openMarketsScreen() {
} }
} }
@OptIn(ExperimentalTestApi::class)
fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) { fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) {
openMarketsScreen() openMarketsScreen()
if (shouldClickSeeAllButton) if (shouldClickSeeAllButton)
@ -69,9 +70,8 @@ fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAll
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle() waitForIdle()
} }
step("Scroll down") { step("Scroll to 'Listed on exchanges' block") {
swipeVertical(SwipeDirection.UP) onMarketsScreen { scrollToListedOnBlock() }
swipeVertical(SwipeDirection.UP)
} }
step("Click on 'Listed on exchanges' block") { step("Click on 'Listed on exchanges' block") {
onMarketsScreen { listedOnBlockContainer.performClick() } onMarketsScreen { listedOnBlockContainer.performClick() }

View file

@ -6,7 +6,6 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_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.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.screens.* import com.tangem.screens.*
@ -34,8 +33,11 @@ fun BaseTestCase.openSendScreen(
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
} }
@ -91,11 +93,11 @@ fun BaseTestCase.openSendAddressScreen(
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Assert 'Send' button is not dimmed") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().assertIsDimmed(false) } onTokenDetailsScreen { transferButton.clickWithAssertion() }
} }
step("Click on 'Send' button") { step("Click on 'Send' button in bottom sheet") {
onTokenDetailsScreen { sendButton().performClick() } onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Type '$inputAmount' in input text field") { step("Type '$inputAmount' in input text field") {
onSendScreen { onSendScreen {
@ -109,6 +111,13 @@ fun BaseTestCase.openSendAddressScreen(
step("Assert 'Send Address' container is displayed") { step("Assert 'Send Address' container is displayed") {
onSendAddressScreen { container.assertIsDisplayed() } onSendAddressScreen { container.assertIsDisplayed() }
} }
step("Wait for recipient list to load") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}.isSuccess
}
}
} }
fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) { fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) {
@ -244,8 +253,11 @@ fun BaseTestCase.selectTokenToSendViaSwap(
networkName: String, networkName: String,
networkType: String? = null, networkType: String? = null,
) { ) {
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Click on 'Swap to another token' button") { step("Click on 'Swap to another token' button") {
onSendScreen { swapToAnotherTokenButton.performClick() } onSendScreen { swapToAnotherTokenButton.performClick() }

View file

@ -10,6 +10,7 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS 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_LONG
import com.tangem.common.extensions.assertVisibility import com.tangem.common.extensions.assertVisibility
import com.tangem.common.extensions.clickWhenEnabled
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.isDisplayedSafely import com.tangem.common.extensions.isDisplayedSafely
import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.R as CoreUiR
@ -43,7 +44,7 @@ fun BaseTestCase.openSwapScreen(
} }
SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") { SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.clickWhenEnabled() }
} }
SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") { SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") {

View file

@ -0,0 +1,41 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddFundsBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val buyButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_buy)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val receiveButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_receive)))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_close)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -11,7 +11,10 @@ import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<AddTokenBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val title: KNode = child { val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE) hasTestTag(BaseBottomSheetTestTags.TITLE)
@ -23,6 +26,12 @@ class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions
hasText(getResourceString(R.string.common_add)) hasText(getResourceString(R.string.common_add))
useUnmergedTree = true useUnmergedTree = true
} }
val laterButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_later))
useUnmergedTree = true
}
} }
internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) = internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) =

View file

@ -4,7 +4,9 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.swipeUp
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.getQuantityString import com.tangem.common.extensions.getQuantityString
import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.extensions.hasLazyListItemPosition
@ -22,7 +24,7 @@ import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.R as CoreUiR
class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainScreenPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<MainScreenPageObject>(semanticsProvider = semanticsProvider) {
private val lazyList = KLazyListNode( private val lazyList = KLazyListNode(
@ -49,7 +51,8 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
val buyButton: KNode = child { val buyButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_buy)) hasAnyDescendant(withText(getResourceString(R.string.common_buy)))
useUnmergedTree = true
} }
val addFundsButton: KNode = child { val addFundsButton: KNode = child {
@ -59,22 +62,26 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
val sendButton: KNode = child { val sendButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_send)) hasAnyDescendant(withText(getResourceString(R.string.common_send)))
useUnmergedTree = true
} }
val receiveButton: KNode = child { val receiveButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_receive)) hasAnyDescendant(withText(getResourceString(R.string.common_receive)))
useUnmergedTree = true
} }
val sellButton: KNode = child { val sellButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_sell)) hasAnyDescendant(withText(getResourceString(R.string.common_sell)))
useUnmergedTree = true
} }
val swapButton: KNode = child { val swapButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap)) hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
} }
val walletNameText: KNode = child { val walletNameText: KNode = child {
@ -87,13 +94,37 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true useUnmergedTree = true
} }
val walletDevicesCount: KNode = child { /**
hasTestTag(MainScreenTestTags.DEVICES_COUNT) * Collapses the collapsing header via a touch-based swipe so that items near the bottom
* of the lazy list fall within screen bounds before programmatic childWith scroll.
* Required because TangemCollapsingTopBar places the body at y=collapsingHeight, which
* pushes lower list items off-screen when the header is expanded.
*/
private fun collapseHeader() {
screenContainer {
performTouchInput { swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f) }
}
}
val restoringProgressText: KNode = child {
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
useUnmergedTree = true
}
val walletImportedBanner: KNode = child {
hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)
useUnmergedTree = true
}
val walletImportedBannerCheckHereButton: KNode = child {
hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER))
hasText(getResourceString(CoreResR.string.main_manage_tokens))
useUnmergedTree = true useUnmergedTree = true
} }
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun marketPriceBlock(): LazyListItemNode { fun marketPriceBlock(): LazyListItemNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MarketPriceBlockTestTags.BLOCK) hasTestTag(MarketPriceBlockTestTags.BLOCK)
useUnmergedTree = true useUnmergedTree = true
@ -236,6 +267,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
*/ */
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun accountWithName(name: String): LazyListItemNode { fun accountWithName(name: String): LazyListItemNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(name)) hasAnyDescendant(withText(name))
@ -243,11 +275,21 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
} }
} }
@OptIn(ExperimentalTestApi::class)
fun tokenRowWithTitle(tokenTitle: String): LazyListItemNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
useUnmergedTree = true
}
}
/** /**
* Find token list item with title and address * Find token list item with title and address
*/ */
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun tokenWithTitleAndAddress(tokenTitle: String): KNode { fun tokenWithTitleAndAddress(tokenTitle: String): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle) hasText(tokenTitle)
@ -260,6 +302,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode { fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle) hasText(tokenTitle)
@ -272,6 +315,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun addAndManageButton(): KNode { fun addAndManageButton(): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON) hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON)
}.child<KNode> { }.child<KNode> {
@ -287,11 +331,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
} }
val searchThroughMarketPlaceholder: KNode = child { val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title)) hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true useUnmergedTree = true
} }
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
collapseHeader()
return lazyList.child { return lazyList.child {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyChild(withText(tokenNetwork)) hasAnyChild(withText(tokenNetwork))
@ -301,6 +346,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode { fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle) hasText(tokenTitle)
@ -335,6 +381,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
} }
} }
} }
fun assertTokensCount(expectedCount: Int) {
semanticsProvider
.onAllNodes(withTestTag(TokenElementsTestTags.TOKEN_PRICE))
.assertCountEquals(expectedCount)
}
} }
internal fun BaseTestCase.onMainScreen(function: MainScreenPageObject.() -> Unit) = internal fun BaseTestCase.onMainScreen(function: MainScreenPageObject.() -> Unit) =

View file

@ -2,11 +2,9 @@ package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -23,16 +21,15 @@ class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractions
fun allExchangeTypeNodes(): List<SemanticsNode> = fun allExchangeTypeNodes(): List<SemanticsNode> =
provider provider
.onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE)))) .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))
.fetchSemanticsNodes() .fetchSemanticsNodes()
fun allTrustScoreNodes(): List<SemanticsNode> = fun allTrustScoreNodes(): List<SemanticsNode> =
provider provider
.onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))) .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))
.fetchSemanticsNodes() .fetchSemanticsNodes()
val exchangesTitle: KNode = child { val exchangesTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.markets_token_details_exchanges_title)) hasText(getResourceString(R.string.markets_token_details_exchanges_title))
useUnmergedTree = true useUnmergedTree = true
} }

View file

@ -1,6 +1,8 @@
package com.tangem.screens package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX
import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseButtonTestTags
@ -15,9 +17,9 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString
class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<MarketsPageObject>(semanticsProvider = semanticsProvider) {
val addToPortfolioButton: KNode = child { val addButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT) hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add_to_portfolio)) hasText(getResourceString(R.string.common_add))
useUnmergedTree = true useUnmergedTree = true
} }
@ -31,7 +33,12 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
} }
val searchThroughMarketPlaceholder: KNode = child { val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title)) hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
}
val tokenDetailsContent: KNode = child {
hasTestTag(MarketsTestTags.TOKEN_DETAILS_CONTENT)
useUnmergedTree = true useUnmergedTree = true
} }
@ -41,7 +48,8 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
} }
val listedOnBlockContainer: KNode = child { val listedOnBlockContainer: KNode = child {
hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true) hasTestTag(MarketsTestTags.LISTED_ON_BLOCK)
useUnmergedTree = true
} }
val listedOnEmptyText: KNode = child { val listedOnEmptyText: KNode = child {
@ -60,6 +68,13 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(title) hasText(title)
} }
} }
@ExperimentalTestApi
fun scrollToListedOnBlock() {
tokenDetailsContent {
performScrollToNode(hasTestTag(MarketsTestTags.LISTED_ON_BLOCK))
}
}
} }
internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) = internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) =

View file

@ -3,14 +3,13 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasText as withText
import com.tangem.core.ui.R as CoreUiR
class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsTokenDetailsPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<MarketsTokenDetailsPageObject>(semanticsProvider = semanticsProvider) {
@ -20,11 +19,14 @@ class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractions
hasText(getResourceString(R.string.common_swap), substring = true) hasText(getResourceString(R.string.common_swap), substring = true)
} }
val inYourPortfolioBlock: KNode = child {
hasText(getResourceString(CoreUiR.string.markets_portfolio_block_subtitle), substring = true)
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode = child { fun tokenWithTitle(title: String): KNode = child {
hasAnyAncestor(withTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM))
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnySibling(withTestTag(TokenElementsTestTags.TOKEN_ICON))
hasAnyChild(withText(title)) hasAnyChild(withText(title))
hasClickAction()
useUnmergedTree = true useUnmergedTree = true
} }
} }

View file

@ -28,23 +28,18 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
useUnmergedTree = true useUnmergedTree = true
} }
private val topBarGroupButton: KNode = child { val organizeMenuButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON) hasTestTag(OrganizeTokensScreenTestTags.MENU_BUTTON)
useUnmergedTree = true useUnmergedTree = true
} }
val groupButton: KNode = topBarGroupButton.child { val groupButton: KNode = child {
hasText(getResourceString(R.string.organize_tokens_group)) hasText(getResourceString(R.string.organize_tokens_group))
useUnmergedTree = true useUnmergedTree = true
} }
val ungroupButton: KNode = topBarGroupButton.child {
hasText(getResourceString(R.string.organize_tokens_ungroup))
useUnmergedTree = true
}
val sortByBalanceButton: KNode = child { val sortByBalanceButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.SORT_BY_BALANCE_BUTTON) hasText(getResourceString(R.string.organize_tokens_sort_by_balance))
useUnmergedTree = true useUnmergedTree = true
} }
// endregion TopBar // endregion TopBar
@ -84,7 +79,7 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
return lazyList.child { return lazyList.child {
hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM) hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM)
hasAnyChild(withText(tokenNetwork)) hasAnyDescendant(withText(tokenNetwork))
useUnmergedTree = true useUnmergedTree = true
} }
} }

View file

@ -97,7 +97,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
): KNode = child { ): KNode = child {
hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM) hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM)
hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON)) hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON))
hasAnyDescendant(withText(recipientAddress)) hasAnyDescendant(withText(recipientAddress, substring = true))
hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT)) hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT))
useUnmergedTree = true useUnmergedTree = true
if (description != null) { if (description != null) {

View file

@ -1,20 +1,15 @@
package com.tangem.screens package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.test.NotificationTestTags
import com.tangem.core.ui.test.TokenDetailsScreenTestTags import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import com.tangem.features.tokendetails.impl.R import com.tangem.features.tokendetails.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasText as withText
@ -36,18 +31,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true useUnmergedTree = true
} }
val availableStakingBlockTitle: KNode = child { fun availableStakingBlockText(apy: String): KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE) hasText(getResourceString(R.string.token_details_earn_staking_subtitle, apy))
useUnmergedTree = true
}
val availableStakingBlockText: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TEXT)
useUnmergedTree = true
}
val availableStakingBlockCurrencyIcon: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_CURRENCY_ICON)
useUnmergedTree = true useUnmergedTree = true
} }
@ -62,69 +47,39 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true useUnmergedTree = true
} }
val stakingDot: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_DOT)
useUnmergedTree = true
}
val stakingTokenAmount: KNode = child { val stakingTokenAmount: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT)
useUnmergedTree = true useUnmergedTree = true
} }
val stakingChevronIcon: KNode = child { val stakingTitle: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_CHEVRON_ICON) hasText(getResourceString(R.string.common_staking))
useUnmergedTree = true
} }
val stakingTitle: KNode = child { val stakingEnabledTitle: KNode = child {
hasText(getResourceString(R.string.staking_native)) hasText(getResourceString(R.string.staking_enabled))
} }
val title: KNode = child { val title: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE)
} }
private val horizontalActionChips = KLazyListNode( val addFundsButton: KNode = child {
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseActionButtonsBlockTestTags.HORIZONTAL_ACTION_CHIPS) },
itemTypeBuilder = { itemType(::LazyListItemNode) },
positionMatcher = { position ->
SemanticsMatcher.expectValue(
LazyListItemPositionSemantics,
position
)
}
)
@OptIn(ExperimentalTestApi::class)
fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_receive)) hasAnyDescendant(withText(getResourceString(R.string.tangempay_card_details_add_funds)))
useUnmergedTree = true
} }
@OptIn(ExperimentalTestApi::class) val swapButton: KNode = child {
fun swapButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap)) hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
} }
@OptIn(ExperimentalTestApi::class) val transferButton: KNode = child {
fun sellButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_sell)) hasAnyDescendant(withText(getResourceString(R.string.common_transfer)))
} useUnmergedTree = true
@OptIn(ExperimentalTestApi::class)
fun buyButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_buy))
}
@OptIn(ExperimentalTestApi::class)
fun sendButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_send))
} }
fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child { fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child {
@ -204,7 +159,6 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON))
useUnmergedTree = true useUnmergedTree = true
} }
} }

View file

@ -0,0 +1,41 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class TransferBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TransferBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val sendButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_send)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val sellButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_sell)))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_close)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTransferBottomSheet(function: TransferBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -27,6 +27,7 @@ import com.tangem.screens.onSendScreen
import com.tangem.screens.onStoriesScreen import com.tangem.screens.onStoriesScreen
import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onMainScreenTopBar import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onTransferBottomSheet
import com.tangem.tap.domain.sdk.mocks.MockProvider import com.tangem.tap.domain.sdk.mocks.MockProvider
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.AllureId
@ -94,8 +95,11 @@ class FeedbackTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Click 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Type '$sendAmount' in input text field") { step("Type '$sendAmount' in input text field") {
onSendScreen { onSendScreen {

View file

@ -22,7 +22,8 @@ class OrganizeTokensTest : BaseTestCase() {
fun groupTokensTest() { fun groupTokensTest() {
setupHooks().run { setupHooks().run {
val tokenTitle = "Ethereum" val tokenTitle = "Ethereum"
val tokenNetwork = "Ethereum network" val networkTitleOrganize = "Ethereum"
val networkTitleMain = "Ethereum network"
step("Open 'Main Screen'") { step("Open 'Main Screen'") {
openMainScreen() openMainScreen()
@ -39,17 +40,20 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitle(tokenTitle).assertIsDisplayed() tokenWithTitle(tokenTitle).assertIsDisplayed()
} }
} }
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'Group' button") { step("Click 'Group' button") {
onOrganizeTokensScreen { groupButton.clickWithAssertion() } onOrganizeTokensScreen { groupButton.clickWithAssertion() }
} }
step("Assert tokens were grouped on 'Organize tokens' screen") { step("Assert tokens were grouped on 'Organize tokens' screen") {
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsDisplayed() }
} }
step("Click 'Apply' button") { step("Click 'Apply' button") {
onOrganizeTokensScreen { applyButton.clickWithAssertion() } onOrganizeTokensScreen { applyButton.clickWithAssertion() }
} }
step("Assert tokens were grouped on 'Main screen'") { step("Assert tokens were grouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsDisplayed() }
} }
step("Open 'Organize tokens' screen") { step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen() openOrganizeTokensScreen()
@ -60,17 +64,20 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitle(tokenTitle).assertIsDisplayed() tokenWithTitle(tokenTitle).assertIsDisplayed()
} }
} }
step("Click 'Ungroup' button") { step("Open organize menu") {
onOrganizeTokensScreen { ungroupButton.clickWithAssertion() } onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'Group' checkbox again to ungroup") {
onOrganizeTokensScreen { groupButton.clickWithAssertion() }
} }
step("Assert tokens were ungrouped on 'Organize tokens' screen") { step("Assert tokens were ungrouped on 'Organize tokens' screen") {
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() } onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsNotDisplayed() }
} }
step("Click 'Apply' button") { step("Click 'Apply' button") {
onOrganizeTokensScreen { applyButton.clickWithAssertion() } onOrganizeTokensScreen { applyButton.clickWithAssertion() }
} }
step("Assert tokens were ungrouped on 'Main screen'") { step("Assert tokens were ungrouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() } onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsNotDisplayed() }
} }
} }
} }
@ -185,6 +192,9 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed() tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed()
} }
} }
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'By Balance' button") { step("Click 'By Balance' button") {
onOrganizeTokensScreen { onOrganizeTokensScreen {
sortByBalanceButton.clickWithAssertion() sortByBalanceButton.clickWithAssertion()

View file

@ -35,11 +35,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(cardType) openMainScreen(cardType)
} }
step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") { step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") {
checkSingleCurrencyMainScreen( checkSingleCurrencyMainScreen(cardTitle = cardType.name)
cardBlockchain = cardBlockchain,
cardTitle = cardType.name,
withTransactions = true
)
} }
} }
} }
@ -57,7 +53,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(mockContent = cardType, isTwinsCard = true) openMainScreen(mockContent = cardType, isTwinsCard = true)
} }
step("Check 'Main' screen for '$cardName' $cardBlockchain card") { step("Check 'Main' screen for '$cardName' $cardBlockchain card") {
checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardName) checkSingleCurrencyMainScreen(cardTitle = cardName)
} }
} }
} }
@ -66,7 +62,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: Card with Secp256k1 curve") @DisplayName("Scan: Card with Secp256k1 curve")
@Test @Test
fun secpk1CurveCardScanTest() { fun secpk1CurveCardScanTest() {
val devicesCount = "1 device"
val cardType: MockContent = Secpk1CurveMockContent val cardType: MockContent = Secpk1CurveMockContent
val cardName = "Wallet" val cardName = "Wallet"
val card = "card with Secp256k1 curve" val card = "card with Secp256k1 curve"
@ -75,12 +70,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on $card") { step("Open 'Main Screen' on $card") {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for $card curve with devices count = '$devicesCount'") { step("Check 'Main' screen for $card curve") {
checkMultiCurrencyMainScreen( checkMultiCurrencyMainScreen(cardTitle = cardName)
devicesCount = devicesCount,
cardTitle = cardName,
withWalletImage = false
)
} }
} }
} }
@ -99,11 +90,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") { step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") {
checkSingleCurrencyMainScreen( checkSingleCurrencyMainScreen(cardTitle = cardName)
cardBlockchain = cardBlockchain,
cardTitle = cardName,
withWalletImage = false
)
} }
} }
} }
@ -112,7 +99,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Shiba' card") @DisplayName("Scan: 'Shiba' card")
@Test @Test
fun shibaCardScanTest() { fun shibaCardScanTest() {
val devicesCount = "2 devices"
val cardType: MockContent = ShibaMockContent val cardType: MockContent = ShibaMockContent
val cardName = "Wallet" val cardName = "Wallet"
val card = "Shiba" val card = "Shiba"
@ -121,8 +107,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card' card") { step("Open 'Main Screen' on '$card' card") {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") { step("Check 'Main' screen for '$card' card") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }
@ -131,7 +117,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Ring'") @DisplayName("Scan: 'Ring'")
@Test @Test
fun ringScanTest() { fun ringScanTest() {
val devicesCount = "3 devices"
val cardType: ProductType = ProductType.Ring val cardType: ProductType = ProductType.Ring
val cardName = "Wallet" val cardName = "Wallet"
val ring = "Ring" val ring = "Ring"
@ -140,8 +125,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$ring'") { step("Open 'Main Screen' on '$ring'") {
openMainScreen(productType = cardType) openMainScreen(productType = cardType)
} }
step("Check 'Main' screen for '$ring' with devices count = '$devicesCount'") { step("Check 'Main' screen for '$ring'") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }
@ -150,7 +135,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Wallet' card") @DisplayName("Scan: 'Wallet' card")
@Test @Test
fun walletCardScanTest() { fun walletCardScanTest() {
val devicesCount = "1 device"
val cardType: ProductType = ProductType.Wallet val cardType: ProductType = ProductType.Wallet
val cardName = "Wallet" val cardName = "Wallet"
@ -158,8 +142,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$cardName' card") { step("Open 'Main Screen' on '$cardName' card") {
openMainScreen(productType = cardType) openMainScreen(productType = cardType)
} }
step("Check 'Main' screen for '$cardName' card with devices count = '$devicesCount'") { step("Check 'Main' screen for '$cardName' card") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }
@ -168,7 +152,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Wallet 2' card") @DisplayName("Scan: 'Wallet 2' card")
@Test @Test
fun wallet2ScanTest() { fun wallet2ScanTest() {
val devicesCount = "2 devices"
val cardType: MockContent = Wallet2MockContent val cardType: MockContent = Wallet2MockContent
val cardName = "Wallet" val cardName = "Wallet"
val card = "Wallet 2" val card = "Wallet 2"
@ -177,8 +160,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card' card") { step("Open 'Main Screen' on '$card' card") {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") { step("Check 'Main' screen for '$card' card") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }
@ -187,7 +170,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: Card with 4.12 firmware") @DisplayName("Scan: Card with 4.12 firmware")
@Test @Test
fun firmware412CardScanTest() { fun firmware412CardScanTest() {
val devicesCount = "1 device"
val cardType: MockContent = Firmware412MockContent val cardType: MockContent = Firmware412MockContent
val cardName = "Tangem card" val cardName = "Tangem card"
val card = "card with 4.12 firmware" val card = "card with 4.12 firmware"
@ -196,8 +178,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card'") { step("Open 'Main Screen' on '$card'") {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for '$card' with devices count = '$devicesCount'") { step("Check 'Main' screen for '$card'") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }

View file

@ -56,20 +56,14 @@ class StakingTest : BaseTestCase() {
onTokenDetailsScreen { stakingBlock.assertIsDisplayed() } onTokenDetailsScreen { stakingBlock.assertIsDisplayed() }
} }
step("Assert 'Staking title' is displayed") { step("Assert 'Staking title' is displayed") {
onTokenDetailsScreen { stakingTitle.assertIsDisplayed() } onTokenDetailsScreen { stakingEnabledTitle.assertIsDisplayed() }
} }
step("Assert 'Staking fiat amount' is displayed") { step("Assert 'Staking fiat amount' is displayed") {
onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() } onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() }
} }
step("Assert 'Staking dot' is displayed") {
onTokenDetailsScreen { stakingDot.assertIsDisplayed() }
}
step("Assert 'Staking token amount' is displayed") { step("Assert 'Staking token amount' is displayed") {
onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() } onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() }
} }
step("Assert 'Staking block chevron icon' is displayed") {
onTokenDetailsScreen { stakingChevronIcon.assertIsDisplayed() }
}
} }
} }
@ -139,6 +133,7 @@ class StakingTest : BaseTestCase() {
val scenarioName = "staking_eth_pol_balances_android" val scenarioName = "staking_eth_pol_balances_android"
val scenarioState = "Started" val scenarioState = "Started"
val stakingAmount = "1" val stakingAmount = "1"
val stakingApy = "2.84%"
setupHooks( setupHooks(
additionalAfterSection = { additionalAfterSection = {
@ -172,13 +167,10 @@ class StakingTest : BaseTestCase() {
onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() } onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() }
} }
step("Assert 'Available staking block' title is displayed") { step("Assert 'Available staking block' title is displayed") {
onTokenDetailsScreen { availableStakingBlockTitle.assertIsDisplayed() } onTokenDetailsScreen { stakingTitle.assertIsDisplayed() }
} }
step("Assert 'Available staking block' text is displayed") { step("Assert 'Available staking block' text is displayed") {
onTokenDetailsScreen { availableStakingBlockText.assertIsDisplayed() } onTokenDetailsScreen { availableStakingBlockText(stakingApy).assertIsDisplayed() }
}
step("Assert 'Available staking block' currency icon is displayed") {
onTokenDetailsScreen { availableStakingBlockCurrencyIcon.assertIsDisplayed() }
} }
step("Click on 'Stake' button") { step("Click on 'Stake' button") {
onTokenDetailsScreen { stakeButton.clickWithAssertion() } onTokenDetailsScreen { stakeButton.clickWithAssertion() }

View file

@ -1,14 +1,10 @@
package com.tangem.tests package com.tangem.tests
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMainScreen
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.Allure.step
import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test import org.junit.Test

View file

@ -389,6 +389,9 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Click on 'Buy' button") { step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() } onMainScreen { buyButton.performClick() }
} }
step("Click on token: '$tokenTitle'") {
onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).performClick() }
}
step("Click on 'Confirm' button in 'Dialog'") { step("Click on 'Confirm' button in 'Dialog'") {
waitForIdle() waitForIdle()
onDialog { confirmButton.clickWithAssertion() } onDialog { confirmButton.clickWithAssertion() }

View file

@ -2,16 +2,17 @@ package com.tangem.tests.actionButtons
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.scenarios.checkQrCodeBottomSheetScenario import com.tangem.scenarios.checkQrCodeBottomSheetScenario
import com.tangem.scenarios.goToQrCodeBottomSheet import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import com.tangem.screens.onSwapStoriesScreen import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen import com.tangem.screens.onSwapTokenScreen
import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName import io.qameta.allure.kotlin.junit4.DisplayName
@ -37,20 +38,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle() waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
} }
step("Assert 'Receive' button is displayed") { step("Assert 'Add funds' button is displayed") {
onTokenDetailsScreen { receiveButton().assertIsDisplayed() } onTokenDetailsScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Buy' button is displayed") {
onTokenDetailsScreen { buyButton().assertIsDisplayed() }
}
step("Assert 'Send' button is displayed") {
onTokenDetailsScreen { sendButton().assertIsDisplayed() }
} }
step("Assert 'Swap' button is displayed") { step("Assert 'Swap' button is displayed") {
onTokenDetailsScreen { swapButton().assertIsDisplayed() } onTokenDetailsScreen { swapButton.assertIsDisplayed() }
} }
step("Assert 'Sell' button is displayed") { step("Assert 'Transfer' button is displayed") {
onTokenDetailsScreen { sellButton().assertIsDisplayed() } onTokenDetailsScreen { transferButton.assertIsDisplayed() }
}
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Assert 'Buy' button in bottom sheet is displayed") {
onAddFundsBottomSheet { buyButton.assertIsDisplayed() }
}
step("Assert 'Swap' button in bottom sheet is displayed") {
onAddFundsBottomSheet { swapButton.assertIsDisplayed() }
}
step("Assert 'Receive' button in bottom sheet is displayed") {
onAddFundsBottomSheet { receiveButton.assertIsDisplayed() }
}
step("Click on 'Close' button in bottom sheet") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button in bottom sheet is displayed") {
onTransferBottomSheet { sendButton.assertIsDisplayed() }
}
step("Assert 'Swap' button in bottom sheet is displayed") {
onTransferBottomSheet { swapButton.assertIsDisplayed() }
}
step("Assert 'Sell' button in bottom sheet is displayed") {
onTransferBottomSheet { sellButton.assertIsDisplayed() }
} }
} }
} }
@ -72,20 +94,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle() waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
} }
step("Assert 'Receive' button is not dimmed") { step("Assert 'Add funds' button is enabled") {
onTokenDetailsScreen { receiveButton().assertIsDimmed(false) } onTokenDetailsScreen { addFundsButton.assertIsEnabled() }
} }
step("Assert 'Buy' button is not dimmed") { step("Assert 'Swap' button is disabled") {
onTokenDetailsScreen { buyButton().assertIsDimmed(false) } onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
} }
step("Assert 'Send' button is not dimmed") { step("Assert 'Transfer' button is enabled") {
onTokenDetailsScreen { sendButton().assertIsDimmed(false) } onTokenDetailsScreen { transferButton.assertIsEnabled() }
} }
step("Assert 'Swap' button is dimmed") { step("Click on 'Add funds' button") {
onTokenDetailsScreen { swapButton().assertIsDimmed() } onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
} }
step("Assert 'Sell' button is dimmed") { step("Assert 'Buy' button in bottom sheet is enabled") {
onTokenDetailsScreen { sellButton().assertIsDimmed() } onAddFundsBottomSheet { buyButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
onAddFundsBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Receive' button in bottom sheet is enabled") {
onAddFundsBottomSheet { receiveButton.assertIsEnabled() }
}
step("Click on 'Close' button in bottom sheet") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button in bottom sheet is enabled") {
onTransferBottomSheet { sendButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
onTransferBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Sell' button in bottom sheet is disabled") {
onTransferBottomSheet { sellButton.assertIsNotEnabled() }
} }
} }
} }
@ -109,7 +152,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -140,8 +183,11 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle() waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
} }
step("Click on 'Receive' button") { step("Click on 'Add funds' button") {
onTokenDetailsScreen { receiveButton().performClick() } onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Click on 'Receive' button in bottom sheet") {
onAddFundsBottomSheet { receiveButton.clickWithAssertion() }
} }
step("Go to QR code bottom sheet") { step("Go to QR code bottom sheet") {
flakySafely(WAIT_UNTIL_TIMEOUT) { flakySafely(WAIT_UNTIL_TIMEOUT) {

View file

@ -1,42 +0,0 @@
package com.tangem.tests.balance
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class TotalBalanceLongTapTest : BaseTestCase() {
@Test
@AllureId("3965")
@DisplayName("Total balance: check long tap on block without biometry")
fun whenBiometryIsOffTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Long tap on total balance block") {
onMainScreen {
totalBalanceContainer.performTouchInput {
longClick()
}
}
}
step("Assert 'Rename' button is displayed") {
onMainScreen { totalBalanceMenuRenameWallet.assertIsDisplayed() }
}
step("Assert 'Delete' button is not displayed") {
onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() }
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.balance
import androidx.compose.ui.test.longClick import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.constants.TestConstants.TOTAL_BALANCE
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.* import com.tangem.common.extensions.*
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
@ -82,28 +83,27 @@ class TotalBalanceUpdateTest : BaseTestCase() {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
} }
step("Click on 'Add to portfolio' button") { step("Click on 'Add' button in 'Markets' bottom sheet") {
onMarketsScreen { addToPortfolioButton.clickWithAssertion() } onMarketsScreen { addButton.clickWithAssertion() }
} }
step("Click on main network") { step("Click on 'Add' button in 'Add token' bottom sheet") {
onMarketsScreen { mainNetworkSuffix.performClick() } flakySafely(WAIT_UNTIL_TIMEOUT) {
onAddTokenBottomSheet {
addButton.performClick()
} }
step("Click on 'Add' button") { onAddTokenBottomSheet { laterButton.assertIsDisplayed() }
onDialog { addButton.clickWithAssertion() }
} }
step("Assert 'Continue' is not displayed") {
onDialog { addButton.assertIsNotDisplayed() }
} }
step("Click on 'Later' button") { step("Click on 'Later' button") {
onDialog { laterButton.clickWithAssertion() } onAddTokenBottomSheet { laterButton.performClick() }
} }
step("Go back to 'Markets: tokens list'") { step("Press 'Back' button") {
waitForIdle() waitForIdle()
onMarketsScreen { topBarBackButton.clickWithAssertion() } device.uiDevice.pressBack()
} }
step("Close 'Markets screen'") { step("Press 'Back' button") {
onSearchBar { searchField.assertIsDisplayed() } waitForIdle()
swipeMarketsBlock(SwipeDirection.DOWN) device.uiDevice.pressBack()
} }
step("Assert $updatedBalance is displayed in total balance") { step("Assert $updatedBalance is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } onMainScreen { totalBalanceText.assertTextContains(updatedBalance) }

View file

@ -0,0 +1,251 @@
package com.tangem.tests.hotWallet
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.CREATE_USER_WALLET_API_SCENARIO
import com.tangem.common.constants.TestConstants.MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO
import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO
import com.tangem.common.constants.TestConstants.SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.SEED_PHRASE_HAPPY_PATH
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WALLET_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreenWithExistingHotWallet
import com.tangem.screens.*
import com.tangem.screens.accounts.onAccountDetailsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
import com.tangem.core.ui.R as CoreUiR
@HiltAndroidTest
class AssetsDiscoveryTest : BaseTestCase() {
private companion object {
const val DISCOVERY_TIMEOUT_MILLIS = 120_000L
const val SCENARIO_STATE_STARTED = "Started"
const val SCENARIO_STATE_EMPTY = "Empty"
const val SCENARIO_STATE_ALREADY_EXISTS = "AlreadyExists"
const val SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT = "AssetsDiscoveryRedirect"
const val SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH = "AssetsDiscoveryHappyPath"
const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES = "NonZeroEvmBalances"
const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW = "NonZeroEvmBalancesSlow"
val EXPECTED_DISCOVERED_TOKENS = listOf(
"Ethereum",
"Polygon",
"Tether",
)
val TOKENS_THAT_MUST_NOT_APPEAR = listOf(
"Solana",
"USDC",
)
val BACKEND_PRE_POPULATED_TOKENS = listOf(
"Bitcoin",
"Ethereum",
"Polygon",
)
}
@AllureId("9280")
@DisplayName("Hot wallet: new import — Discovery → Sync → Banner → Check here happy path")
@Test
fun newHotWalletImportHappyPathTest() {
val packageName = getTargetContext().packageName
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT)
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH)
setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES)
},
additionalAfterSection = {
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import a new hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH)
}
step("Assert 'Restoring' progress loader is shown (discovery is in flight)") {
onMainScreen { restoringProgressText.assertIsDisplayed() }
}
step("Wait for 'Wallet successfully imported' banner (discovery completes)") {
flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) {
onMainScreen { walletImportedBanner.assertIsDisplayed() }
}
}
step("Assert expected discovered tokens are visible in the assets list") {
onMainScreen {
EXPECTED_DISCOVERED_TOKENS.forEach { token ->
tokenRowWithTitle(token).assertIsDisplayed()
}
}
}
step("Tap 'Check here' (Manage tokens) on the banner") {
onMainScreen { walletImportedBannerCheckHereButton.clickWithAssertion() }
}
step("Assert 'Manage Tokens' screen is opened") {
onManageTokensScreen { searchField.assertIsDisplayed() }
}
step("Return to main screen") {
device.uiDevice.pressBack()
waitForIdle()
}
step("Assert banner is hidden after navigating into Manage Tokens") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
step("Force-close and re-launch the app") {
restartApp(packageName)
}
step("Assert banner is NOT shown again after relaunch") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
step("Assert previously discovered tokens still appear in the assets list") {
onMainScreen {
EXPECTED_DISCOVERED_TOKENS.forEach { token ->
tokenRowWithTitle(token).assertIsDisplayed()
}
}
}
step("Assert zero-balance and spam tokens are NOT shown in the assets list") {
onMainScreen {
TOKENS_THAT_MUST_NOT_APPEAR.forEach { token ->
assertTokenDoesNotExist(token)
}
}
}
}
}
@AllureId("9284")
@DisplayName("Hot wallet: token added manually during Discovery — no duplicate created")
@Test
fun manualTokenAddDuringDiscoveryNoDuplicateTest() {
val tetherTitle = "Tether"
val ethereumNetworkTitle = "ETHEREUM"
val accountName = getResourceString(CoreUiR.string.account_main_account_title)
val expectedTokensCount = 4
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT)
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH)
setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(
MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO,
state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW,
)
},
additionalAfterSection = {
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import a new hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH)
}
step("Assert 'Restoring' progress loader is shown (discovery is in flight)") {
onMainScreen { restoringProgressText.assertIsDisplayed() }
}
step("Open wallet details from top bar") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings'") {
onDetailsScreen { walletNameButton.performClick() }
}
step("Open account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).performClick() }
}
step("Open 'Manage Tokens' from account details") {
onAccountDetailsScreen { manageTokensButton.performClick() }
}
step("Search for '$tetherTitle' in Manage Tokens") {
onManageTokensScreen {
searchField.performClick()
searchField.performTextInput(tetherTitle)
}
device.uiDevice.pressBack()
waitForIdle()
}
step("Expand '$tetherTitle'") {
onManageTokensScreen { tokenItem(tetherTitle).clickWithAssertion() }
waitForIdle()
}
step("Enable the $ethereumNetworkTitle network") {
onManageTokensScreen { networkSwitch(ethereumNetworkTitle).clickWithAssertion() }
}
step("Save Manage Tokens changes") {
onManageTokensScreen { saveButton.clickWithAssertion() }
waitForIdle()
}
step("Navigate back to main screen") {
repeat(times = 3) {
device.uiDevice.pressBack()
waitForIdle()
}
}
step("Wait for 'Wallet successfully imported' banner (discovery completes after delay)") {
flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) {
onMainScreen { walletImportedBanner.assertIsDisplayed() }
}
}
step("Assert '$tetherTitle' is in the assets list (manual add + discovery merged)") {
onMainScreen { tokenRowWithTitle(tetherTitle).assertIsDisplayed() }
}
step("Assert assets list contains exactly $expectedTokensCount tokens (no duplicate after merge)") {
onMainScreen { assertTokensCount(expectedTokensCount) }
}
}
}
@AllureId("9282")
@DisplayName("Hot wallet: re-import existing wallet — 200 OK, no Discovery, tokens from backend")
@Test
fun reimportExistingHotWalletTest() {
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_ALREADY_EXISTS)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_EMPTY)
},
additionalAfterSection = {
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import an existing hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_12)
}
step("Assert tokens from backend are displayed immediately") {
BACKEND_PRE_POPULATED_TOKENS.forEach { token ->
onMainScreen { tokenRowWithTitle(token).assertIsDisplayed() }
}
}
step("Assert 'Restoring' loader is NOT displayed (discovery did not start)") {
onMainScreen { restoringProgressText.assertIsNotDisplayed() }
}
step("Assert 'Wallet successfully imported' banner is NOT displayed") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
}
}
}

View file

@ -2,6 +2,8 @@ package com.tangem.tests.main
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
@ -37,7 +39,7 @@ class MainScreenTest : BaseTestCase() {
} }
@AllureId("8748") @AllureId("8748")
@DisplayName("Main: check 'Organize tokens' button with single token no accounts") @DisplayName("Main: check 'Add & Manage' button with single token no accounts")
@Test @Test
fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() { fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() {
val scenarioState = "Cardano" val scenarioState = "Cardano"
@ -58,14 +60,14 @@ class MainScreenTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses() synchronizeAddresses()
} }
step("Assert 'Add & Manage' button is not displayed") { step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()} onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
} }
} }
} }
@AllureId("8749") @AllureId("8749")
@DisplayName("Main: check 'Organize tokens' button with single token two accounts") @DisplayName("Main: check 'Add & Manage' button with single token two accounts")
@Test @Test
fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() { fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() {
val scenarioState = "TwoAccountsSingleTokenEach" val scenarioState = "TwoAccountsSingleTokenEach"
@ -99,7 +101,7 @@ class MainScreenTest : BaseTestCase() {
} }
@AllureId("8750") @AllureId("8750")
@DisplayName("Main: check 'Organize tokens' button with multiple tokens two accounts") @DisplayName("Main: check 'Add & Manage' button with multiple tokens two accounts")
@Test @Test
fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() { fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() {
val scenarioState = "TwoAccountsMixed" val scenarioState = "TwoAccountsMixed"
@ -117,8 +119,11 @@ class MainScreenTest : BaseTestCase() {
step("Open 'Main Screen'") { step("Open 'Main Screen'") {
openMainScreen() openMainScreen()
} }
step("Swipe up") {
swipeVertical(SwipeDirection.UP, startHeightRatio = 0.6f, endHeightRatio = 0.1f)
}
step("Assert 'Add & Manage' button is displayed") { step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed()} onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
} }
} }
} }

View file

@ -16,7 +16,7 @@ import org.junit.Test
class WarningsTest : BaseTestCase() { class WarningsTest : BaseTestCase() {
@AllureId("184") @AllureId("184")
@DisplayName("Token list: hide token by long tap") @DisplayName("Warnings: missing address warning")
@Test @Test
fun checkUnavailableNetworksWarningTest() { fun checkUnavailableNetworksWarningTest() {
val scenarioState = "MissingDerivation" val scenarioState = "MissingDerivation"
@ -38,9 +38,6 @@ class WarningsTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses(isBalanceAvailable = false) synchronizeAddresses(isBalanceAvailable = false)
} }
step("Assert 'Missing addresses' notification icon is displayed") {
onMainScreen { missingAddressNotificationIcon.assertIsDisplayed() }
}
step("Assert 'Missing addresses' notification title is displayed") { step("Assert 'Missing addresses' notification title is displayed") {
onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() } onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() }
} }

View file

@ -1,5 +1,6 @@
package com.tangem.tests.markets package com.tangem.tests.markets
import androidx.compose.ui.test.ExperimentalTestApi
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig import com.tangem.common.annotations.ApiEnvConfig
@ -39,6 +40,7 @@ class MarketsExchangesTest : BaseTestCase() {
} }
} }
@OptIn(ExperimentalTestApi::class)
@Test @Test
@AllureId("56") @AllureId("56")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@ -60,9 +62,8 @@ class MarketsExchangesTest : BaseTestCase() {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle() waitForIdle()
} }
step("Scroll down") { step("Scroll to 'Listed on exchanges' block") {
swipeVertical(SwipeDirection.UP) onMarketsScreen { scrollToListedOnBlock() }
swipeVertical(SwipeDirection.UP)
} }
step("Assert 'Listed on exchanges' block has title") { step("Assert 'Listed on exchanges' block has title") {
onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() } onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() }

View file

@ -227,7 +227,7 @@ class RecentBlockTest : BaseTestCase() {
val sendAmount = "1" val sendAmount = "1"
val txHistoryScenarioState = "11OutgoingTransactions" val txHistoryScenarioState = "11OutgoingTransactions"
val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq" val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq"
val shortenedRecipientAddress = "DJ2TaZ5vvp3mBLugU...Li4uYaq123456789b" val longRecipientAddress = recipientAddressBase + "123456789b"
setupHooks( setupHooks(
additionalAfterSection = { additionalAfterSection = {
@ -261,7 +261,7 @@ class RecentBlockTest : BaseTestCase() {
checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1) checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1)
} }
step("Check recent address item №2") { step("Check recent address item №2") {
checkRecentAddressItem(address = shortenedRecipientAddress, description = recentTransactionAmount2) checkRecentAddressItem(address = longRecipientAddress, description = recentTransactionAmount2)
} }
step("Check recent address item №3") { step("Check recent address item №3") {
checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2) checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2)

View file

@ -246,8 +246,11 @@ class SendAddressScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
} }
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)

View file

@ -46,8 +46,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Type '$inputAmount' in input text field") { step("Type '$inputAmount' in input text field") {
onSendScreen { onSendScreen {
@ -123,8 +126,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Type '$inputAmount' in input text field") { step("Type '$inputAmount' in input text field") {
onSendScreen { onSendScreen {

View file

@ -281,13 +281,13 @@ class SendFeeScreenTest : BaseTestCase() {
fun checkNetworkFeeBottomSheetForBitcoinTest() { fun checkNetworkFeeBottomSheetForBitcoinTest() {
val tokenName = "Bitcoin" val tokenName = "Bitcoin"
val tokenAmount = "0.00000001" val tokenAmount = "0.00000001"
val feeAmount = "$2.86" val feeAmount = "$0.48"
val fiatFeeAmount = "$0.24" val fiatFeeAmount = "$0.24"
val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market)
val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast)
val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow)
val feeUpTo = getResourceString(R.string.send_max_fee) val feeUpTo = getResourceString(R.string.send_max_fee)
val feeUpToValue = "0.0000264 BTC" val feeUpToValue = "0.0000044 BTC"
val newFeeUpToValue = "0.0000022 BTC" val newFeeUpToValue = "0.0000022 BTC"
val satoshi = getResourceString(R.string.send_satoshi_per_byte_title) val satoshi = getResourceString(R.string.send_satoshi_per_byte_title)
val satoshiValue = "2" val satoshiValue = "2"

View file

@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO 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.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.checkSendWarning import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
import com.tangem.scenarios.openSendScreen import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen import com.tangem.screens.onSendScreen
@ -145,8 +147,10 @@ class KaspaWarningsTest : BaseTestCase() {
step("Type address in input text field") { step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
} }
step("Click on 'Next' button") { step("Click 'Next' button until 'Send Confirm' screen opens") {
onSendAddressScreen { nextButton.clickWithAssertion() } composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { openSendConfirmScreenViaNextButton() }.isSuccess
}
} }
step("Assert 'UTXO limit warning' is displayed") { step("Assert 'UTXO limit warning' is displayed") {
checkSendWarning( checkSendWarning(

View file

@ -3,8 +3,6 @@ package com.tangem.tests.swap
import androidx.compose.ui.test.longClick import androidx.compose.ui.test.longClick
import androidx.test.InstrumentationRegistry.getTargetContext import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertHasBadge
import com.tangem.common.extensions.restartApp import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
@ -18,101 +16,6 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class SwapStoriesTest : BaseTestCase() { class SwapStoriesTest : BaseTestCase() {
@AllureId("5453")
@DisplayName("Check 'Swap' button badge on 'Main' screen")
@Test
fun checkMainScreenSwapButtonBadgeTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
}
}
@AllureId("5454")
@DisplayName("Check 'Swap' button badge on token details screen")
@Test
fun checkTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
}
}
@AllureId("5455")
@DisplayName("Check 'Swap' button badge on token details in 'Market' screen")
@Test
fun checkMarketTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
}
}
}
@AllureId("5469") @AllureId("5469")
@DisplayName("Check unavailable swap stories on 'Main' screen") @DisplayName("Check unavailable swap stories on 'Main' screen")
@Test @Test
@ -136,9 +39,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses() synchronizeAddresses()
} }
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false) openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false)
} }
@ -155,9 +55,6 @@ class SwapStoriesTest : BaseTestCase() {
waitForIdle() waitForIdle()
onMainScreen { swapButton.assertIsDisplayed() } onMainScreen { swapButton.assertIsDisplayed() }
} }
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true) openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true)
} }
@ -192,9 +89,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
} }
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
} }
@ -207,13 +101,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Restart app") { step("Restart app") {
restartApp(packageName) restartApp(packageName)
} }
step("Assert 'Swap' button has badge") {
waitForIdle()
flakySafely(WAIT_UNTIL_TIMEOUT) {
composeTestRule.mainClock.advanceTimeBy(500)
onMainScreen { swapButton.assertHasBadge() }
}
}
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
} }
@ -228,8 +115,6 @@ class SwapStoriesTest : BaseTestCase() {
val scenarioErrorState = "Error" val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName val packageName = getTargetContext().packageName
val tokenName = "Ethereum" val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks( setupHooks(
additionalBeforeAppLaunchSection = { additionalBeforeAppLaunchSection = {
@ -246,16 +131,15 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses() synchronizeAddresses()
} }
step("Open 'Markets' token details screen for token '$tokenName'") { step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
} }
step("Assert 'Swap' button has not badge") { step("Assert 'Swap' button is displayed") {
waitForIdle() waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } onTokenDetailsScreen { swapButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
} }
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
} }
step("Click on 'Close' button") { step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() } onSwapTokenScreen { closeButton.performClick() }
@ -266,16 +150,12 @@ class SwapStoriesTest : BaseTestCase() {
step("Restart app") { step("Restart app") {
restartApp(packageName) restartApp(packageName)
} }
step("Open 'Markets' token details screen for token '$tokenName'") { step("Assert 'Swap' button is displayed") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
waitForIdle() waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } onTokenDetailsScreen { swapButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
} }
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = true) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
} }
} }
} }
@ -331,11 +211,8 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
} }
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Click on 'Swap' button on 'Token details' screen") { step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Check stories changes") { step("Check stories changes") {
checkStoriesChanges() checkStoriesChanges()
@ -369,11 +246,11 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses() synchronizeAddresses()
} }
step("Open 'Markets' token details screen for token '$tokenName'") { step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
} }
step("Click on 'Swap' button on 'Markets' token details screen") { step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Check stories changes") { step("Check stories changes") {
checkStoriesChanges() checkStoriesChanges()
@ -388,7 +265,7 @@ class SwapStoriesTest : BaseTestCase() {
onSwapTokenScreen { closeButton.performClick() } onSwapTokenScreen { closeButton.performClick() }
} }
step("Open 'Swap' screen without stories") { step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
} }
} }
} }
@ -433,6 +310,17 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on 'Close' button") { step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() } onSwapTokenScreen { closeButton.performClick() }
} }
step("Long click on token with name: '$tokenName' again to reopen actions menu") {
waitForIdle()
onMainScreen {
tokenWithTitleAndAddress(tokenName).performTouchInput {
longClick(
position = center,
durationMillis = 1000L,
)
}
}
}
step("Open 'Swap' screen without stories") { step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false) openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false)
} }

View file

@ -50,7 +50,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() } onTokenDetailsScreen { title.assertIsDisplayed() }
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -147,7 +147,7 @@ class SwapTokenScreenTest : BaseTestCase() {
disableMobileData() disableMobileData()
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -201,7 +201,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() } onTokenDetailsScreen { title.assertIsDisplayed() }
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -304,7 +304,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() } onTokenDetailsScreen { title.assertIsDisplayed() }
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -510,7 +510,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() }
} }
step("Assert 'Swap' button is not dimmed. Swap available") { step("Assert 'Swap' button is not dimmed. Swap available") {
onTokenDetailsScreen { swapButton().assertIsDimmed(false) } onTokenDetailsScreen { swapButton.assertIsEnabled() }
} }
step("Press 'Back' button") { step("Press 'Back' button") {
device.uiDevice.pressBack() device.uiDevice.pressBack()
@ -519,7 +519,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() }
} }
step("Assert 'Swap' button is dimmed") { step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed(true) } onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
} }
step("Press 'Back' button") { step("Press 'Back' button") {
device.uiDevice.pressBack() device.uiDevice.pressBack()
@ -528,7 +528,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() }
} }
step("Assert 'Swap' button is dimmed") { step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed(true) } onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
} }
} }
} }

View file

@ -210,6 +210,17 @@
android:scheme="tangem" /> android:scheme="tangem" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="survey"
android:scheme="tangem" />
</intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View file

@ -5,6 +5,7 @@ import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.config.environment.EnvironmentConfig
@ -49,4 +50,6 @@ interface ApplicationEntryPoint {
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor
fun getDeviceKeyManager(): DeviceKeyManager
} }

View file

@ -21,6 +21,7 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.common.LogConfig import com.tangem.domain.common.LogConfig
import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
@ -92,6 +93,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val sendTransactionSignerInfoInterceptor private val sendTransactionSignerInfoInterceptor
get() = entryPoint.getSendTransactionSignerInfoInterceptor() get() = entryPoint.getSendTransactionSignerInfoInterceptor()
private val deviceKeyManager: DeviceKeyManager
get() = entryPoint.getDeviceKeyManager()
// endregion // endregion
private val appScope = MainScope() private val appScope = MainScope()
@ -132,6 +136,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
} }
fun init() { fun init() {
appScope.launch {
deviceKeyManager.generateIfMissing()
}
walletsRepository = entryPoint.getWalletsRepository() walletsRepository = entryPoint.getWalletsRepository()
apiConfigsManager.initialize() apiConfigsManager.initialize()

View file

@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.SignIn import com.tangem.core.analytics.models.event.SignIn
import com.tangem.domain.card.analytics.IntroductionProcess import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
class HotWalletContextInterceptor( class HotWalletContextInterceptor(
val parent: ParamsInterceptor? = null, val parent: ParamsInterceptor? = null,
@ -18,6 +19,7 @@ class HotWalletContextInterceptor(
is SignIn.ButtonAddWallet, is SignIn.ButtonAddWallet,
is SignIn.ButtonUnlockAllWithBiometric, is SignIn.ButtonUnlockAllWithBiometric,
is IntroductionProcess.ButtonScanCard, is IntroductionProcess.ButtonScanCard,
is TokenScreenAnalyticsEvent.ButtonQuickTopUp,
-> false -> false
is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll
else -> true else -> true

View file

@ -6,6 +6,8 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.tangem.common.routing.DeepLinkScheme import com.tangem.common.routing.DeepLinkScheme
import com.tangem.common.uri.ExternalUrlValidator import com.tangem.common.uri.ExternalUrlValidator
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
@ -17,6 +19,7 @@ import com.tangem.utils.logging.TangemLogger
internal class DefaultDeeplinkLauncher( internal class DefaultDeeplinkLauncher(
private val context: Context, private val context: Context,
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : DeeplinkLauncher { ) : DeeplinkLauncher {
override fun launch(link: String) { override fun launch(link: String) {
@ -58,7 +61,26 @@ internal class DefaultDeeplinkLauncher(
} }
private fun launchDeepLink(uri: Uri) { private fun launchDeepLink(uri: Uri) {
context.startActivity(createDeepLinkIntent(uri)) val intent = createDeepLinkIntent(uri)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
TangemLogger.i(
"""
No match found for deep link
|- Received URI: $uri
""".trimIndent(),
)
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = UnresolvedDeeplinkException(uri),
params = mapOf(
"uri_scheme" to uri.scheme.orEmpty(),
"uri_host" to uri.host.orEmpty(),
),
),
)
}
} }
private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply { private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply {
@ -66,3 +88,6 @@ internal class DefaultDeeplinkLauncher(
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
} }
internal class UnresolvedDeeplinkException(uri: Uri) :
RuntimeException("Deeplink has no matching activity: scheme=${uri.scheme}, host=${uri.host}")

View file

@ -4,15 +4,20 @@ import android.app.Application
import com.chuckerteam.chucker.api.ChuckerInterceptor import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.tangem.Log import com.tangem.Log
import com.tangem.TangemSdkLogger import com.tangem.TangemSdkLogger
import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.common.LogConfig import com.tangem.domain.common.LogConfig
import com.tangem.operations.attestation.api.TangemApiServiceSettings import com.tangem.operations.attestation.api.TangemApiServiceSettings
import com.tangem.utils.JsonStringValuesExtractor
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.BuildConfig import com.tangem.wallet.BuildConfig
import kotlinx.serialization.json.Json
/** /**
* Owns all app-startup wiring of the logging subsystem in a single place: * Owns all app-startup wiring of the logging subsystem in a single place:
@ -23,12 +28,15 @@ import com.tangem.wallet.BuildConfig
* @property appLogsStore app logs store used by file-based writer and the network logs save * @property appLogsStore app logs store used by file-based writer and the network logs save
* interceptor * interceptor
* @property tangemSdkLogger Card SDK logger registered with [Log.addLogger] * @property tangemSdkLogger Card SDK logger registered with [Log.addLogger]
* @property environmentConfig source of [BlockchainSdkConfig] used to build the blockchain
* URL masker
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
class TangemLoggingInitializer( class TangemLoggingInitializer(
private val appLogsStore: AppLogsStore, private val appLogsStore: AppLogsStore,
private val tangemSdkLogger: TangemSdkLogger, private val tangemSdkLogger: TangemSdkLogger,
private val environmentConfig: EnvironmentConfig,
) { ) {
fun initAppLogging() { fun initAppLogging() {
@ -64,6 +72,13 @@ class TangemLoggingInitializer(
} }
add(createNetworkLoggingInterceptor()) add(createNetworkLoggingInterceptor())
add(ChuckerInterceptor(application)) add(ChuckerInterceptor(application))
add(
NetworkLogsSaveInterceptor(
appLogsStore = appLogsStore,
sensitiveUrlMasker = createBlockchainSensitiveUrlMasker(),
shouldCheckResponseBodySize = true,
),
)
} }
TangemApiServiceSettings.addInterceptors( TangemApiServiceSettings.addInterceptors(
@ -77,4 +92,16 @@ class TangemLoggingInitializer(
}.toTypedArray(), }.toTypedArray(),
) )
} }
private fun createBlockchainSensitiveUrlMasker(): SensitiveUrlMasker {
val json = Json.encodeToJsonElement(
BlockchainSdkConfig.serializer(),
environmentConfig.blockchainSdkConfig,
)
// Drop URL-shaped values (e.g. public endpoint URLs from BlockchainSdkConfig like
// kaspaSecondaryApiUrl); they are not secrets and would obscure unrelated requests in logs.
val values = JsonStringValuesExtractor.extract(json)
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
return SensitiveUrlMasker(values)
}
} }

View file

@ -5,12 +5,10 @@ import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.domain.card.BuildConfig import com.tangem.domain.card.BuildConfig
import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler import com.tangem.tap.domain.visa.VisaCardScanHandler
@ -34,8 +32,6 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory: VisaCardActivationTask.Factory, visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles, onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
blockchainToDeriveFinder: BlockchainToDeriveFinder,
analyticsErrorHandler: AnalyticsErrorHandler, analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository, cardRepository: CardRepository,
): TangemSdkManager { ): TangemSdkManager {
@ -49,8 +45,6 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory = visaCardActivationTaskFactory, visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles, onboardingV2FeatureToggles = onboardingV2FeatureToggles,
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
blockchainToDeriveFinder = blockchainToDeriveFinder,
analyticsErrorHandler = analyticsErrorHandler, analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository, cardRepository = cardRepository,
) )

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di package com.tangem.tap.di
import android.content.Context import android.content.Context
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.tap.common.deeplink.DefaultDeeplinkLauncher import com.tangem.tap.common.deeplink.DefaultDeeplinkLauncher
import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.navigation.finisher.AppFinisher
@ -55,7 +56,10 @@ internal interface UtilsModule {
@Provides @Provides
@Singleton @Singleton
fun provideDeeplinkLauncher(@ApplicationContext context: Context, urlOpener: UrlOpener): DeeplinkLauncher = fun provideDeeplinkLauncher(
DefaultDeeplinkLauncher(context, urlOpener) @ApplicationContext context: Context,
urlOpener: UrlOpener,
analyticsExceptionHandler: AnalyticsExceptionHandler,
): DeeplinkLauncher = DefaultDeeplinkLauncher(context, urlOpener, analyticsExceptionHandler)
} }
} }

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.data package com.tangem.tap.di.data
import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.blockchain.common.logging.BlockchainSDKLogger
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.tap.common.log.TangemBlockchainSDKLogger import com.tangem.tap.common.log.TangemBlockchainSDKLogger
import com.tangem.tap.common.log.TangemCardSDKLogger import com.tangem.tap.common.log.TangemCardSDKLogger
@ -17,10 +18,14 @@ internal object TangemLoggingModule {
@Provides @Provides
@Singleton @Singleton
fun provideLoggingInitializer(appLogsStore: AppLogsStore): TangemLoggingInitializer { fun provideLoggingInitializer(
appLogsStore: AppLogsStore,
environmentConfig: EnvironmentConfig,
): TangemLoggingInitializer {
return TangemLoggingInitializer( return TangemLoggingInitializer(
appLogsStore = appLogsStore, appLogsStore = appLogsStore,
tangemSdkLogger = TangemCardSDKLogger(appLogsStore), tangemSdkLogger = TangemCardSDKLogger(appLogsStore),
environmentConfig = environmentConfig,
) )
} }

View file

@ -78,6 +78,16 @@ internal object YieldSupplyDomainModule {
) )
} }
@Provides
@Singleton
fun provideWrapYieldSwapCallDataWithUpgradeUseCase(
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
): WrapYieldSwapCallDataWithUpgradeUseCase {
return WrapYieldSwapCallDataWithUpgradeUseCase(
yieldSupplyTransactionRepository = yieldSupplyTransactionRepository,
)
}
@Provides @Provides
@Singleton @Singleton
fun provideYieldSupplyGetProtocolBalanceUseCase( fun provideYieldSupplyGetProtocolBalanceUseCase(

View file

@ -27,7 +27,6 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
@ -58,6 +57,7 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.tap.domain.visa.VisaCardScanHandler import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R import com.tangem.wallet.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -73,8 +73,6 @@ internal class DefaultTangemSdkManager(
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
private val analyticsErrorHandler: AnalyticsErrorHandler, private val analyticsErrorHandler: AnalyticsErrorHandler,
private val cardRepository: CardRepository, private val cardRepository: CardRepository,
) : TangemSdkManager { ) : TangemSdkManager {
@ -145,12 +143,10 @@ internal class DefaultTangemSdkManager(
runTaskAsyncReturnOnMain( runTaskAsyncReturnOnMain(
runnable = ScanProductTask( runnable = ScanProductTask(
card = null, card = null,
blockchainToDeriveFinder = blockchainToDeriveFinder,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository, allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
visaCardScanHandler = visaCardScanHandler, visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this, visaCoroutineScope = this,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled,
onboardingV2FeatureToggles = onboardingV2FeatureToggles, onboardingV2FeatureToggles = onboardingV2FeatureToggles,
cardRepository = cardRepository, cardRepository = cardRepository,
), ),
@ -242,6 +238,7 @@ internal class DefaultTangemSdkManager(
Analytics.send(event = analyticsEvent.withParams(params.toMap())) Analytics.send(event = analyticsEvent.withParams(params.toMap()))
} }
.doOnFailure { tangemError -> .doOnFailure { tangemError ->
TangemLogger.e("scanProduct failed: code=${tangemError.code}, message=${tangemError.customMessage}")
(tangemError as? TangemSdkError)?.let { error -> (tangemError as? TangemSdkError)?.let { error ->
Analytics.sendErrorEvent(TangemSdkErrorEvent(error)) Analytics.sendErrorEvent(TangemSdkErrorEvent(error))
} }
@ -470,7 +467,6 @@ internal class DefaultTangemSdkManager(
runnable = FinalizeTwinTask( runnable = FinalizeTwinTask(
twinPublicKey = secondCardPublicKey, twinPublicKey = secondCardPublicKey,
issuerKeys = issuerKeyPair, issuerKeys = issuerKeyPair,
isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled,
cardRepository = cardRepository, cardRepository = cardRepository,
), ),
cardId = cardId, cardId = cardId,

View file

@ -1,74 +0,0 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.tap.features.demo.DemoHelper
import javax.inject.Inject
/**
* Finder of blockchains to derive.
* Returns only saved, default or demo blockchains without any additional logic
* (no cardano/ethereum additions or unnecessary blockchain removals).
*/
class BlockchainToDeriveFinder @Inject constructor(
private val walletAccountsFetcher: WalletAccountsFetcher,
) {
suspend fun find(card: CardDTO): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = card.derivationStyleProvider.getDerivationStyle()
val blockchains = getBlockchains(userWalletId).ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
return blockchains
}
private suspend fun getBlockchains(userWalletId: UserWalletId): Set<BlockchainToDerive> {
return walletAccountsFetcher.getSaved(userWalletId)?.accounts.orEmpty()
.flatMap { accountDTO ->
accountDTO.tokens.orEmpty()
.filter { it.contractAddress == null }
}
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath) ?: return@mapNotNull null
BlockchainToDerive(blockchain, derivationPath)
}
.toSet()
}
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): Set<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): Set<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): Set<BlockchainToDerive> {
return mapNotNullTo(hashSetOf()) { blockchain ->
val derivationPath = blockchain.derivationPath(derivationStyle) ?: return@mapNotNullTo null
BlockchainToDerive(blockchain, derivationPath)
}
}
}

View file

@ -12,8 +12,6 @@ import com.tangem.common.extensions.*
import com.tangem.common.tlv.Tlv import com.tangem.common.tlv.Tlv
import com.tangem.common.tlv.TlvDecoder import com.tangem.common.tlv.TlvDecoder
import com.tangem.crypto.CryptoUtils import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.card.common.TapWorkarounds.isExcluded import com.tangem.domain.card.common.TapWorkarounds.isExcluded
import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
@ -32,25 +30,21 @@ import com.tangem.operations.PreflightReadMode
import com.tangem.operations.ScanTask import com.tangem.operations.ScanTask
import com.tangem.operations.backup.PrimaryCard import com.tangem.operations.backup.PrimaryCard
import com.tangem.operations.backup.StartPrimaryCardLinkingTask import com.tangem.operations.backup.StartPrimaryCardLinkingTask
import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask
import com.tangem.operations.files.ReadFilesTask import com.tangem.operations.files.ReadFilesTask
import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand
import com.tangem.tap.domain.TapSdkError import com.tangem.tap.domain.TapSdkError
import com.tangem.tap.domain.visa.VisaCardScanHandler import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.mainScope import com.tangem.tap.mainScope
import com.tangem.tap.scope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Suppress("LongParameterList") @Suppress("LongParameterList")
internal class ScanProductTask( internal class ScanProductTask(
private val card: Card?, private val card: Card?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val visaCardScanHandler: VisaCardScanHandler?, private val visaCardScanHandler: VisaCardScanHandler?,
private val visaCoroutineScope: CoroutineScope?, private val visaCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
private val shouldCheckIsAlreadyActivated: Boolean, private val shouldCheckIsAlreadyActivated: Boolean,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository, private val cardRepository: CardRepository,
override val allowsRequestAccessCodeFromRepository: Boolean = false, override val allowsRequestAccessCodeFromRepository: Boolean = false,
) : CardSessionRunnable<ScanResponse> { ) : CardSessionRunnable<ScanResponse> {
@ -80,8 +74,6 @@ internal class ScanProductTask(
session = session, session = session,
cardDto = cardDto, cardDto = cardDto,
scanWalletProcessor = ScanWalletProcessor( scanWalletProcessor = ScanWalletProcessor(
blockchainToDeriveFinder = blockchainToDeriveFinder,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
cardRepository = cardRepository, cardRepository = cardRepository,
), ),
callback = callback, callback = callback,
@ -92,8 +84,6 @@ internal class ScanProductTask(
val commandProcessor = when { val commandProcessor = when {
cardDto.isTangemTwins -> ScanTwinProcessor() cardDto.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor( else -> ScanWalletProcessor(
blockchainToDeriveFinder = blockchainToDeriveFinder,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
cardRepository = cardRepository, cardRepository = cardRepository,
) )
} }
@ -102,8 +92,8 @@ internal class ScanProductTask(
is CompletionResult.Success -> ScanTask().run(session) { scanTaskResult -> is CompletionResult.Success -> ScanTask().run(session) { scanTaskResult ->
when (scanTaskResult) { when (scanTaskResult) {
is CompletionResult.Success -> { is CompletionResult.Success -> {
// it needed because processorResult.data.card doesn't contains attestation result // It's needed because processorResult.data.card doesn't contain the attestation
// and CardWallet.derivedKeys // result or the existing CardWallet.derivedKeys read from the card.
val processorScanResponseWithNewCard = processorResult.data.copy( val processorScanResponseWithNewCard = processorResult.data.copy(
card = CardDTO(scanTaskResult.data), card = CardDTO(scanTaskResult.data),
) )
@ -176,8 +166,6 @@ internal class ScanProductTask(
} }
private class ScanWalletProcessor( private class ScanWalletProcessor(
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository, private val cardRepository: CardRepository,
) : ProductCommandProcessor<ScanResponse> { ) : ProductCommandProcessor<ScanResponse> {
@ -281,48 +269,34 @@ private class ScanWalletProcessor(
when (linkingResult) { when (linkingResult) {
is CompletionResult.Success -> { is CompletionResult.Success -> {
primaryCard = linkingResult.data primaryCard = linkingResult.data
deriveKeysIfNeeded(card, session, callback) completeScan(card, session, callback)
} }
is CompletionResult.Failure -> { is CompletionResult.Failure -> {
deriveKeysIfNeeded(card, session, callback) completeScan(card, session, callback)
} }
} }
} }
} else { } else {
deriveKeysIfNeeded(card, session, callback) completeScan(card, session, callback)
} }
} }
} }
private fun deriveKeysIfNeeded( // Keys are no longer derived during scan: default derivations are created up front in
// CreateProductWalletTask, and derivations for additional tokens are handled by
// DefaultColdMapDerivationsRepository when the user explicitly adds a token.
private fun completeScan(
card: CardDTO, card: CardDTO,
session: CardSession, session: CardSession,
callback: (result: CompletionResult<ScanResponse>) -> Unit, callback: (result: CompletionResult<ScanResponse>) -> Unit,
) { ) {
val productType = getWalletProductType(card)
scope.launch {
val scanResponse = ScanResponse( val scanResponse = ScanResponse(
card = card, card = card,
productType = productType, productType = getWalletProductType(card),
walletData = session.environment.walletData, walletData = session.environment.walletData,
primaryCard = primaryCard, primaryCard = primaryCard,
) )
val derivations = collectDerivations(card, scanResponse)
if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) {
callback(CompletionResult.Success(scanResponse)) callback(CompletionResult.Success(scanResponse))
return@launch
}
DeriveMultipleWalletPublicKeysTask(derivations).run(session) { result ->
when (result) {
is CompletionResult.Success -> {
val response = scanResponse.copy(derivedKeys = result.data.entries)
callback(CompletionResult.Success(response))
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
}
}
} }
private fun getWalletProductType(card: CardDTO): ProductType { private fun getWalletProductType(card: CardDTO): ProductType {
@ -334,17 +308,6 @@ private class ScanWalletProcessor(
else -> ProductType.Wallet else -> ProductType.Wallet
} }
} }
private suspend fun collectDerivations(
card: CardDTO,
scanResponse: ScanResponse,
): Map<ByteArrayKey, List<DerivationPath>> {
val blockchains = blockchainToDeriveFinder
?.find(card)
?: return emptyMap()
return MissedDerivationsFinder(scanResponse, isDynamicAddressesEnabled).findByBlockchainsToDerive(blockchains)
}
} }
@Suppress("MagicNumber") @Suppress("MagicNumber")

View file

@ -13,7 +13,6 @@ import com.tangem.tap.domain.tasks.product.ScanProductTask
class FinalizeTwinTask( class FinalizeTwinTask(
private val twinPublicKey: ByteArray, private val twinPublicKey: ByteArray,
private val issuerKeys: KeyPair, private val issuerKeys: KeyPair,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository, private val cardRepository: CardRepository,
) : CardSessionRunnable<ScanResponse> { ) : CardSessionRunnable<ScanResponse> {
@ -31,11 +30,9 @@ class FinalizeTwinTask(
is CompletionResult.Success -> is CompletionResult.Success ->
ScanProductTask( ScanProductTask(
card = readResult.data, card = readResult.data,
blockchainToDeriveFinder = null,
visaCardScanHandler = null, visaCardScanHandler = null,
visaCoroutineScope = null, visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false, shouldCheckIsAlreadyActivated = false,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
onboardingV2FeatureToggles = null, onboardingV2FeatureToggles = null,
cardRepository = cardRepository, cardRepository = cardRepository,
).run(session, callback) ).run(session, callback)

View file

@ -325,11 +325,7 @@ internal class DefaultUserWalletsListRepository(
sensitiveInformationRepository.getAll(listOf(encryptionKey)) sensitiveInformationRepository.getAll(listOf(encryptionKey))
.doOnSuccess { sensitiveInfo -> .doOnSuccess { sensitiveInfo ->
updateWallets { wallets -> updateWallets { wallets ->
// It is necessary to update derivations because when scanning we obtain the missing keys wallets?.updateWith(walletIdToSensitiveInformation = sensitiveInfo)
wallets?.updateWith(
walletIdToSensitiveInformation = sensitiveInfo,
walletIdToDerivedKeys = mapOf(userWallet.walletId to scanResponse.derivedKeys),
)
} }
trackSignInEvent(userWallet, AnalyticsParam.SignInType.Card) trackSignInEvent(userWallet, AnalyticsParam.SignInType.Card)
} }

View file

@ -1,10 +1,8 @@
package com.tangem.tap.domain.userWalletList.utils package com.tangem.tap.domain.userWalletList.utils
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.domain.userWalletList.model.UserWalletPublicInformation import com.tangem.tap.domain.userWalletList.model.UserWalletPublicInformation
import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation
@ -74,10 +72,7 @@ internal fun List<UserWalletPublicInformation>.toUserWallets(): List<UserWallet>
return this.map { it.toUserWallet() } return this.map { it.toUserWallet() }
} }
internal fun UserWallet.updateWith( internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInformation): UserWallet {
sensitiveInformation: UserWalletSensitiveInformation,
derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap>?,
): UserWallet {
return when (this) { return when (this) {
is UserWallet.Cold -> { is UserWallet.Cold -> {
copy( copy(
@ -85,7 +80,6 @@ internal fun UserWallet.updateWith(
card = scanResponse.card.copy( card = scanResponse.card.copy(
wallets = requireNotNull(sensitiveInformation.wallets), wallets = requireNotNull(sensitiveInformation.wallets),
), ),
derivedKeys = derivedKeys ?: scanResponse.derivedKeys,
// visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus, // visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus,
), ),
) )
@ -98,17 +92,14 @@ internal fun UserWallet.updateWith(
internal fun List<UserWallet>.updateWith( internal fun List<UserWallet>.updateWith(
walletIdToSensitiveInformation: Map<UserWalletId, UserWalletSensitiveInformation>, walletIdToSensitiveInformation: Map<UserWalletId, UserWalletSensitiveInformation>,
walletIdToDerivedKeys: Map<UserWalletId, Map<KeyWalletPublicKey, ExtendedPublicKeysMap>>? = null,
): List<UserWallet> { ): List<UserWallet> {
return if (walletIdToSensitiveInformation.isEmpty()) { return if (walletIdToSensitiveInformation.isEmpty()) {
this this
} else { } else {
this.map { wallet -> this.map { wallet ->
val sensitiveInformation = walletIdToSensitiveInformation[wallet.walletId] val sensitiveInformation = walletIdToSensitiveInformation[wallet.walletId]
val derivedKeys = walletIdToDerivedKeys?.get(wallet.walletId)
if (sensitiveInformation != null) { if (sensitiveInformation != null) {
wallet.updateWith(sensitiveInformation, derivedKeys) wallet.updateWith(sensitiveInformation)
} else { } else {
wallet wallet
} }

View file

@ -9,7 +9,6 @@ import com.tangem.feature.stories.api.StoriesComponent
import com.tangem.feature.usedesk.api.UsedeskComponent import com.tangem.feature.usedesk.api.UsedeskComponent
import com.tangem.feature.walletsettings.component.WalletSettingsComponent import com.tangem.feature.walletsettings.component.WalletSettingsComponent
import com.tangem.features.account.AccountCreateEditComponent import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.createwalletselection.CreateWalletSelectionComponent import com.tangem.features.createwalletselection.CreateWalletSelectionComponent
@ -21,6 +20,7 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.home.api.HomeComponent import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.* import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent import com.tangem.features.kyc.KycComponent
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensMode
@ -112,9 +112,9 @@ internal class ChildFactory @Inject constructor(
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory, private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory, private val kycComponentFactory: KycComponent.Factory,
private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val feedEntryComponentFactory: FeedEntryComponent.Factory, private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val addFundsComponentFactory: AddFundsComponent.Factory,
) { ) {
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
@ -216,6 +216,7 @@ internal class ChildFactory @Inject constructor(
userWalletId = route.userWalletId, userWalletId = route.userWalletId,
cryptoCurrency = route.currency, cryptoCurrency = route.currency,
source = route.source, source = route.source,
initialFiatAmount = route.initialFiatAmount,
), ),
componentFactory = onrampComponentFactory, componentFactory = onrampComponentFactory,
) )
@ -234,13 +235,6 @@ internal class ChildFactory @Inject constructor(
componentFactory = buyCryptoComponentFactory, componentFactory = buyCryptoComponentFactory,
) )
} }
is AppRoute.AddFunds -> {
createComponentChild(
context = context,
params = AddFundsComponent.Params(userWalletId = route.userWalletId),
componentFactory = addFundsComponentFactory,
)
}
is AppRoute.SellCrypto -> { is AppRoute.SellCrypto -> {
createComponentChild( createComponentChild(
context = context, context = context,
@ -701,6 +695,13 @@ internal class ChildFactory @Inject constructor(
componentFactory = kycComponentFactory, componentFactory = kycComponentFactory,
) )
} }
is AppRoute.Survey -> {
createComponentChild(
context = context,
params = SurveyComponent.Params(token = route.token, displayId = route.displayId),
componentFactory = surveyComponentFactory,
)
}
is AppRoute.YieldSupplyEntry -> { is AppRoute.YieldSupplyEntry -> {
createComponentChild( createComponentChild(
context = context, context = context,

View file

@ -19,6 +19,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler
import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
@ -62,6 +63,7 @@ internal class DeepLinkFactory @Inject constructor(
private val newsDeepLink: NewsDeepLinkHandler.Factory, private val newsDeepLink: NewsDeepLinkHandler.Factory,
private val earnDeepLink: EarnDeepLinkHandler.Factory, private val earnDeepLink: EarnDeepLinkHandler.Factory,
private val yieldDeepLink: YieldDeepLinkHandler.Factory, private val yieldDeepLink: YieldDeepLinkHandler.Factory,
private val surveyDeepLink: SurveyDeepLinkHandler.Factory,
) { ) {
private val permittedAppRoute = MutableStateFlow(false) private val permittedAppRoute = MutableStateFlow(false)
@ -175,6 +177,7 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams)
else -> { else -> {
TangemLogger.i( TangemLogger.i(
""" """

View file

@ -1,252 +0,0 @@
package com.tangem.tap.domain.tasks.product
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BlockchainToDeriveFinderTest {
private val walletAccountsFetcher = mockk<WalletAccountsFetcher>()
private val finder = BlockchainToDeriveFinder(
walletAccountsFetcher = walletAccountsFetcher,
)
@AfterEach
fun tearDown() {
clearMocks(walletAccountsFetcher)
}
@Test
fun `GIVEN card is not HD wallet THEN return empty set`() = runTest {
// Arrange
val card = mockk<CardDTO> {
every { this@mockk.settings.isHDWalletAllowed } returns false
}
// Act
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN card has empty wallets THEN return empty set`() = runTest {
// Arrange
val card = mockk<CardDTO> {
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.wallets } returns emptyList()
}
// Act
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN saved bitcoin THEN return only bitcoin`() = runTest {
// Arrange
val card = createCardDTO()
val response = createResponse(Blockchain.Bitcoin)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store and common demo card THEN return demo blockchains`() = runTest {
// Arrange
val demoCardId = "AC01000000045754"
val card = createCardDTO(cardId = demoCardId)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.Dogecoin),
createExpected(Blockchain.Solana),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store and DE00 demo card THEN return demo blockchains`() = runTest {
// Arrange
val demoCardId = "DE00"
val card = createCardDTO(cardId = demoCardId)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.Dogecoin),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store THEN return default blockchains`() = runTest {
// Arrange
val card = createCardDTO()
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN saved cardano THEN return only cardano`() = runTest {
// Arrange
val card = createCardDTO()
val response = createResponse(Blockchain.Cardano)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Cardano),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN saved eth-like blockchains THEN return all saved blockchains without filtering`() = runTest {
// Arrange
val card = createCardDTO()
val blockchains = listOf(Blockchain.Ethereum, Blockchain.BSC, Blockchain.Polygon)
val response = createResponse(*blockchains.toTypedArray())
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = blockchains.mapTo(hashSetOf(), ::createExpected)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
private fun createCardDTO(cardId: String = "0001", batchId: String = "AC10"): CardDTO {
val wallet = mockk<CardDTO.Wallet> {
every { this@mockk.publicKey } returns byteArrayOf(0)
}
return mockk<CardDTO> {
every { this@mockk.cardId } returns cardId
every { this@mockk.batchId } returns batchId
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.settings.isKeysImportAllowed } returns true
every { this@mockk.firmwareVersion } returns CardDTO.FirmwareVersion(
major = 6,
minor = 33,
patch = 0,
type = com.tangem.common.card.FirmwareVersion.FirmwareType.Release,
)
every { this@mockk.wallets } returns listOf(wallet)
}
}
private fun createResponse(vararg blockchains: Blockchain): GetWalletAccountsResponse {
val tokens = blockchains.map { blockchain ->
mockk<UserTokensResponse.Token> {
every { this@mockk.networkId } returns blockchain.toNetworkId()
every { this@mockk.derivationPath } returns blockchain.getDerivationPath().rawPath
every { this@mockk.contractAddress } returns null
}
}
val account = mockk<WalletAccountDTO> {
every { this@mockk.tokens } returns tokens
}
return mockk {
every { this@mockk.accounts } returns listOf(account)
}
}
private fun createExpected(
blockchain: Blockchain,
derivationPath: DerivationPath = blockchain.getDerivationPath(),
): BlockchainToDerive {
return BlockchainToDerive(blockchain = blockchain, derivationPath = derivationPath)
}
private fun Blockchain.getDerivationPath(): DerivationPath {
return derivationPath(DerivationStyle.V3)!!
}
private companion object {
// for byteArrayOf(0)
val userWalletId = UserWalletId("41448576B8DA24C7D8F5F0F79863D20D7D8312A7F9E50D3248304136DDB7AAD7")
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHand
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
@ -99,6 +100,10 @@ class DeepLinkFactoryTest {
every { create(any()) } returns mockk() every { create(any()) } returns mockk()
} }
private val surveyDeepLinkFactory = mockk<SurveyDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val earnDeepLinkFactory = mockk<EarnDeepLinkHandler.Factory>(relaxed = true) { private val earnDeepLinkFactory = mockk<EarnDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk() every { create(any()) } returns mockk()
} }
@ -140,6 +145,7 @@ class DeepLinkFactoryTest {
newsDeepLink = newsDeepLinkFactory, newsDeepLink = newsDeepLinkFactory,
earnDeepLink = earnDeepLinkFactory, earnDeepLink = earnDeepLinkFactory,
yieldDeepLink = yieldDeepLinkFactory, yieldDeepLink = yieldDeepLinkFactory,
surveyDeepLink = surveyDeepLinkFactory,
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)

View file

@ -59,11 +59,6 @@ sealed class AppRoute(val path: String) : Route {
@Serializable @Serializable
data object Wallet : AppRoute(path = "/wallet") data object Wallet : AppRoute(path = "/wallet")
@Serializable
data class AddFunds(
val userWalletId: UserWalletId,
) : AppRoute(path = "/add_funds/${userWalletId.stringValue}")
@Serializable @Serializable
data class CurrencyDetails( data class CurrencyDetails(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
@ -297,6 +292,7 @@ sealed class AppRoute(val path: String) : Route {
val source: OnrampSource, val source: OnrampSource,
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
val currency: CryptoCurrency, val currency: CryptoCurrency,
val initialFiatAmount: SerializedBigDecimal? = null,
) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams { ) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer()) override fun getBundle(): Bundle = bundle(serializer())
} }
@ -497,6 +493,9 @@ sealed class AppRoute(val path: String) : Route {
@Serializable @Serializable
data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc")
@Serializable
data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey")
@Serializable @Serializable
data class YieldSupplyEntry( data class YieldSupplyEntry(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,

View file

@ -87,6 +87,10 @@ sealed class DeepLinkRoute {
data object PayAppMain : DeepLinkRoute() { data object PayAppMain : DeepLinkRoute() {
override val host: String = "pay-app-main" override val host: String = "pay-app-main"
} }
data object Survey : DeepLinkRoute() {
override val host: String = "survey"
}
} }
enum class DeepLinkScheme(val scheme: String) { enum class DeepLinkScheme(val scheme: String) {

View file

@ -34,7 +34,7 @@ class TokenActionsHandler @AssistedInject constructor(
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>, @Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit, @Assisted private val onHandleQuickAction: (action: HandledQuickAction, shouldDismiss: Boolean) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender, private val messageSender: UiMessageSender,
) { ) {
@ -49,6 +49,18 @@ class TokenActionsHandler @AssistedInject constructor(
action = action, action = action,
cryptoCurrencyData = cryptoCurrencyData, cryptoCurrencyData = cryptoCurrencyData,
), ),
when (action) {
TokenActionsBSContentUM.Action.Receive,
TokenActionsBSContentUM.Action.CopyAddress,
TokenActionsBSContentUM.Action.Sell,
-> false
TokenActionsBSContentUM.Action.Send,
TokenActionsBSContentUM.Action.Stake,
TokenActionsBSContentUM.Action.YieldMode,
TokenActionsBSContentUM.Action.Buy,
TokenActionsBSContentUM.Action.Exchange,
-> true
},
) )
val userWallet = cryptoCurrencyData.userWallet val userWallet = cryptoCurrencyData.userWallet
if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return
@ -164,7 +176,7 @@ class TokenActionsHandler @AssistedInject constructor(
interface Factory { interface Factory {
fun create( fun create(
currentAppCurrency: Provider<AppCurrency>, currentAppCurrency: Provider<AppCurrency>,
onHandleQuickAction: (HandledQuickAction) -> Unit, onHandleQuickAction: (HandledQuickAction, shouldDismiss: Boolean) -> Unit,
): TokenActionsHandler ): TokenActionsHandler
} }

View file

@ -4,13 +4,7 @@ import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -22,6 +16,7 @@ import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.innerShadow import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.graphics.shadow.Shadow
@ -36,22 +31,15 @@ import com.tangem.common.ui.earn.EarnBlockUM.Type
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.ds.button.TangemButton import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.TangemRowContainer import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.core.res.R as CoreResR import com.tangem.core.res.R as CoreResR
private const val TINTED_BACKGROUND_ALPHA = 0.1f private const val TINTED_BACKGROUND_ALPHA = 0.1f
@ -74,7 +62,7 @@ fun EarnBlock(state: EarnBlockUM, modifier: Modifier = Modifier) {
@Composable @Composable
private fun EarnBlockLoading(modifier: Modifier = Modifier) { private fun EarnBlockLoading(modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(TangemTheme.dimens2.x4) val shape = RoundedCornerShape(TangemTheme.dimens2.x5)
TangemRowContainer( TangemRowContainer(
modifier = modifier modifier = modifier
.clip(shape) .clip(shape)
@ -91,13 +79,13 @@ private fun EarnBlockLoading(modifier: Modifier = Modifier) {
RectangleShimmer( RectangleShimmer(
modifier = Modifier modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP) .layoutId(TangemRowLayoutId.START_TOP)
.size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5), .size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4),
radius = TangemTheme.dimens2.x2, radius = TangemTheme.dimens2.x2,
) )
RectangleShimmer( RectangleShimmer(
modifier = Modifier modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM) .layoutId(TangemRowLayoutId.START_BOTTOM)
.size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4), .size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5),
radius = TangemTheme.dimens2.x2, radius = TangemTheme.dimens2.x2,
) )
}, },
@ -106,7 +94,7 @@ private fun EarnBlockLoading(modifier: Modifier = Modifier) {
@Composable @Composable
private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) { private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(TangemTheme.dimens2.x4) val shape = RoundedCornerShape(TangemTheme.dimens2.x5)
val clickModifier = state.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier val clickModifier = state.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
@ -114,7 +102,7 @@ private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Mo
modifier = modifier modifier = modifier
.clip(shape) .clip(shape)
.then(clickModifier.backgroundModifier(state.type, state.backgroundUM, shape)), .then(clickModifier.backgroundModifier(state.type, state.backgroundUM, shape)),
contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), contentPadding = PaddingValues(all = TangemTheme.dimens2.x4),
content = { content = {
EarnBlockIcon( EarnBlockIcon(
type = state.type, type = state.type,
@ -193,17 +181,23 @@ private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, o
} }
is EarnBlockUM.TrailingUM.Balance -> { is EarnBlockUM.TrailingUM.Balance -> {
if (!trailingUM.isBalanceHidden) { if (!trailingUM.isBalanceHidden) {
val fiatModifier = Modifier.layoutId(TangemRowLayoutId.END_TOP).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_FIAT_AMOUNT) else it
}
val cryptoModifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) else it
}
Text( Text(
text = trailingUM.fiatValue.resolveAnnotatedReference(), text = trailingUM.fiatValue.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16, style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary, color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), modifier = fiatModifier,
) )
Text( Text(
text = trailingUM.cryptoValue.resolveReference(), text = trailingUM.cryptoValue.resolveReference(),
style = TangemTheme.typography2.captionMedium12, style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary, color = TangemTheme.colors2.text.neutral.secondary,
modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), modifier = cryptoModifier,
) )
} }
} }
@ -361,7 +355,7 @@ private val EarnBlockUM.TitleUM.Style.textStyle: TextStyle
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
get() = when (this) { get() = when (this) {
EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodyMedium16
EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12
} }
@ -369,7 +363,7 @@ private val EarnBlockUM.SubtitleUM.Style.textStyle: TextStyle
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
get() = when (this) { get() = when (this) {
EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodyMedium16
EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12
} }
// endregion // endregion
@ -410,12 +404,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid
iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40), iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM( titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_stake), text = resourceReference(CoreResR.string.common_stake),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Disabled, tone = EarnBlockUM.TitleUM.Tone.Disabled,
), ),
subtitleUM = EarnBlockUM.SubtitleUM.Text( subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.staking_notification_network_error_text), text = resourceReference(CoreResR.string.staking_notification_network_error_text),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled, tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
), ),
trailingUM = null, trailingUM = null,
@ -426,12 +420,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40), iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM( titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_staking), text = resourceReference(CoreResR.string.common_staking),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary, tone = EarnBlockUM.TitleUM.Tone.Primary,
), ),
subtitleUM = EarnBlockUM.SubtitleUM.Text( subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stringReference("Average APR 5.24%"), text = stringReference("Average APR 5.24%"),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled, tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
), ),
trailingUM = EarnBlockUM.TrailingUM.Button( trailingUM = EarnBlockUM.TrailingUM.Button(
@ -445,12 +439,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40), iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM( titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_enabled), text = resourceReference(CoreResR.string.staking_enabled),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary, tone = EarnBlockUM.TitleUM.Tone.Primary,
), ),
subtitleUM = EarnBlockUM.SubtitleUM.Text( subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stringReference("$ 12.34 rewards"), text = stringReference("$ 12.34 rewards"),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent, tone = EarnBlockUM.SubtitleUM.Tone.Accent,
), ),
trailingUM = EarnBlockUM.TrailingUM.Balance( trailingUM = EarnBlockUM.TrailingUM.Balance(
@ -475,14 +469,14 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
id = CoreResR.string.yield_module_token_details_earn_notification_subtitle, id = CoreResR.string.yield_module_token_details_earn_notification_subtitle,
formatArgs = wrappedList("5.24"), formatArgs = wrappedList("5.24"),
), ),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary, tone = EarnBlockUM.TitleUM.Tone.Primary,
), ),
subtitleUM = EarnBlockUM.SubtitleUM.Text( subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference( text = resourceReference(
CoreResR.string.yield_module_token_details_earn_notification_description, CoreResR.string.yield_module_token_details_earn_notification_description,
), ),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent, tone = EarnBlockUM.SubtitleUM.Tone.Accent,
), ),
trailingUM = EarnBlockUM.TrailingUM.Button( trailingUM = EarnBlockUM.TrailingUM.Button(
@ -497,7 +491,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM( titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.yield_module_transaction_enter), text = resourceReference(CoreResR.string.yield_module_transaction_enter),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary, tone = EarnBlockUM.TitleUM.Tone.Primary,
), ),
subtitleUM = EarnBlockUM.SubtitleUM.Text( subtitleUM = EarnBlockUM.SubtitleUM.Text(
@ -505,7 +499,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
id = CoreResR.string.yield_module_average_apy, id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"), formatArgs = wrappedList("5.24"),
), ),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent, tone = EarnBlockUM.SubtitleUM.Tone.Accent,
), ),
trailingUM = EarnBlockUM.TrailingUM.Button( trailingUM = EarnBlockUM.TrailingUM.Button(
@ -521,7 +515,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM( titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode), text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary, tone = EarnBlockUM.TitleUM.Tone.Primary,
iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Warning), iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Warning),
), ),
@ -530,7 +524,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
id = CoreResR.string.yield_module_average_apy, id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"), formatArgs = wrappedList("5.24"),
), ),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent, tone = EarnBlockUM.SubtitleUM.Tone.Accent,
), ),
trailingUM = EarnBlockUM.TrailingUM.Button( trailingUM = EarnBlockUM.TrailingUM.Button(
@ -546,7 +540,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM( titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode), text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary, tone = EarnBlockUM.TitleUM.Tone.Primary,
iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Info), iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Info),
), ),
@ -555,7 +549,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
id = CoreResR.string.yield_module_average_apy, id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"), formatArgs = wrappedList("5.24"),
), ),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent, tone = EarnBlockUM.SubtitleUM.Tone.Accent,
), ),
trailingUM = EarnBlockUM.TrailingUM.Button( trailingUM = EarnBlockUM.TrailingUM.Button(
@ -571,12 +565,12 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM( titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode), text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary, tone = EarnBlockUM.TitleUM.Tone.Primary,
), ),
subtitleUM = EarnBlockUM.SubtitleUM.Text( subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.common_enabling), text = resourceReference(CoreResR.string.common_enabling),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent, tone = EarnBlockUM.SubtitleUM.Tone.Accent,
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive), loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive),
), ),
@ -589,12 +583,12 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_yield_disabling_40), iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_yield_disabling_40),
titleUM = EarnBlockUM.TitleUM( titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode), text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large, style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary, tone = EarnBlockUM.TitleUM.Tone.Primary,
), ),
subtitleUM = EarnBlockUM.SubtitleUM.Text( subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.common_disabling), text = resourceReference(CoreResR.string.common_disabling),
style = EarnBlockUM.SubtitleUM.Style.Small, style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled, tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted), loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted),
), ),

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.R import com.tangem.common.ui.R
@ -35,6 +36,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -78,7 +80,8 @@ private fun ExpressTransactionItem(
.clip(TangemTheme.shapes.roundedCornersXMedium) .clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors2.surface.level3) .background(TangemTheme.colors2.surface.level3)
.clickable(onClick = info.onClick) .clickable(onClick = info.onClick)
.padding(TangemTheme.dimens2.x4), .padding(TangemTheme.dimens2.x4)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM),
) { ) {
TitleRow( TitleRow(
title = info.title.resolveReference(), title = info.title.resolveReference(),
@ -104,7 +107,9 @@ private fun TitleRow(title: String, infoIconRes: Int?, infoIconTint: Color?) {
text = title, text = title,
style = TangemTheme.typography2.bodyMedium16, style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary, color = TangemTheme.colors3.text.primary,
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE),
) )
if (infoIconRes != null && infoIconTint != null) { if (infoIconRes != null && infoIconTint != null) {
Icon( Icon(
@ -126,32 +131,42 @@ private fun AmountsRow(info: ExpressTransactionStateInfoUM) {
CurrencyIcon( CurrencyIcon(
state = info.fromCurrencyIcon, state = info.fromCurrencyIcon,
shouldDisplayNetwork = false, shouldDisplayNetwork = false,
modifier = Modifier.size(TangemTheme.dimens.size18), modifier = Modifier
.size(TangemTheme.dimens.size18)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON),
) )
EllipsisText( EllipsisText(
text = info.fromAmount.resolveReference(), text = info.fromAmount.resolveReference(),
style = TangemTheme.typography2.bodyMedium16, style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary, color = TangemTheme.colors3.text.primary,
ellipsis = TextEllipsis.OffsetEnd(info.fromAmountSymbol.length), ellipsis = TextEllipsis.OffsetEnd(info.fromAmountSymbol.length),
modifier = Modifier.weight(weight = 1f, fill = false), modifier = Modifier
.weight(weight = 1f, fill = false)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_AMOUNT),
) )
Icon( Icon(
painter = painterResource(R.drawable.ic_forward_24), painter = painterResource(R.drawable.ic_forward_24),
contentDescription = null, contentDescription = null,
tint = TangemTheme.colors3.icon.tertiary, tint = TangemTheme.colors3.icon.tertiary,
modifier = Modifier.size(TangemTheme.dimens.size18), modifier = Modifier
.size(TangemTheme.dimens.size18)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON),
) )
CurrencyIcon( CurrencyIcon(
state = info.toCurrencyIcon, state = info.toCurrencyIcon,
shouldDisplayNetwork = false, shouldDisplayNetwork = false,
modifier = Modifier.size(TangemTheme.dimens.size18), modifier = Modifier
.size(TangemTheme.dimens.size18)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON),
) )
EllipsisText( EllipsisText(
text = info.toAmount.resolveReference(), text = info.toAmount.resolveReference(),
style = TangemTheme.typography2.bodyMedium16, style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary, color = TangemTheme.colors3.text.primary,
ellipsis = TextEllipsis.OffsetEnd(info.toAmountSymbol.length), ellipsis = TextEllipsis.OffsetEnd(info.toAmountSymbol.length),
modifier = Modifier.weight(weight = 1f, fill = false), modifier = Modifier
.weight(weight = 1f, fill = false)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT),
) )
} }
} }

View file

@ -14,10 +14,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.ds.row.TangemRowContainer import com.tangem.core.ui.ds.row.TangemRowContainer
@ -61,15 +64,14 @@ fun TokenActionRow(
val accentColor = accentColor(isEnabled) val accentColor = accentColor(isEnabled)
TangemRowContainer( TangemRowContainer(
modifier = modifier modifier = modifier
.background( .clip(RoundedCornerShape(TangemTheme.dimens2.x5))
color = TangemTheme.colors2.surface.level3, .background(color = TangemTheme.colors2.surface.level3)
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
)
.clickableWithHaptic( .clickableWithHaptic(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
hapticManager = hapticManager, hapticManager = hapticManager,
), )
.semantics { if (!isEnabled) disabled() },
) { ) {
LeadingIcon(iconRes = iconRes, accentColor = accentColor) LeadingIcon(iconRes = iconRes, accentColor = accentColor)
Text( Text(

View file

@ -328,6 +328,7 @@ sealed class AnalyticsParam {
const val WALLET_TYPE = "Wallet Type" const val WALLET_TYPE = "Wallet Type"
const val BACKUPED = "Backuped" const val BACKUPED = "Backuped"
const val MEMO = "Memo" const val MEMO = "Memo"
const val VALUE = "Value"
} }
} }

View file

@ -17,7 +17,7 @@
}, },
{ {
"name": "APP_REDESIGN_ENABLED", "name": "APP_REDESIGN_ENABLED",
"version": "undefined" "version": "5.40"
}, },
{ {
"name": "GASLESS_APPROVAL_ENABLED", "name": "GASLESS_APPROVAL_ENABLED",
@ -55,6 +55,10 @@
"name": "WALLET_CONNECT_BITCOIN_ENABLED", "name": "WALLET_CONNECT_BITCOIN_ENABLED",
"version": "undefined" "version": "undefined"
}, },
{
"name": "TWI_1326_YIELD_MODE_SWAP_ENABLED",
"version": "undefined"
},
{ {
"name": "ADDRESS_SYNC_ENABLED", "name": "ADDRESS_SYNC_ENABLED",
"version": "undefined" "version": "undefined"
@ -64,7 +68,7 @@
"version": "undefined" "version": "undefined"
}, },
{ {
"name": "SWAP_INTEGRATED_APPROVE", "name": "AND_15120_SWAP_INTEGRATED_APPROVE",
"version": "undefined" "version": "undefined"
}, },
{ {
@ -106,5 +110,21 @@
{ {
"name": "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED", "name": "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED",
"version": "5.39" "version": "5.39"
},
{
"name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED",
"version": "undefined"
},
{
"name": "AND_15482_SURVEYSPARROW_ENABLED",
"version": "undefined"
},
{
"name": "AND_15258_QUICK_TOP_UP_ENABLED",
"version": "undefined"
},
{
"name": "AND_15368_VISA_PAY_REDESIGN",
"version": "undefined"
} }
] ]

View file

@ -0,0 +1,14 @@
package com.tangem.core.configtoggle.feature
/**
* Feature toggle information exposed by [MutableFeatureTogglesManager].
*
* @property name raw toggle name
* @property version release version from local config ("undefined" for permanently disabled toggles)
* @property isEnabled current toggle state (may differ from default if overridden locally)
*/
data class FeatureToggleInfo(
val name: String,
val version: String,
val isEnabled: Boolean,
)

View file

@ -2,6 +2,9 @@ package com.tangem.core.configtoggle.feature
import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.FeatureToggles
/** Version value marking a feature toggle that has no planned release (permanently disabled). */
const val DISABLED_FEATURE_TOGGLE_VERSION = "undefined"
/** /**
* Component for getting information about the availability of feature toggles * Component for getting information about the availability of feature toggles
* *

View file

@ -10,8 +10,8 @@ interface MutableFeatureTogglesManager : FeatureTogglesManager {
/** Check if the current state of the feature toggles matches the local config state. */ /** Check if the current state of the feature toggles matches the local config state. */
fun isMatchLocalConfig(): Boolean fun isMatchLocalConfig(): Boolean
/** Get feature toggles */ /** Get feature toggles with version info */
fun getFeatureToggles(): Map<String, Boolean> fun getFeatureToggles(): List<FeatureToggleInfo>
/** Change availability [isEnabled] of toggle with name [name] */ /** Change availability [isEnabled] of toggle with name [name] */
suspend fun changeToggle(name: String, isEnabled: Boolean) suspend fun changeToggle(name: String, isEnabled: Boolean)

View file

@ -2,14 +2,16 @@ package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureToggleInfo
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.utils.toTableString import com.tangem.core.configtoggle.utils.toTableString
import com.tangem.core.configtoggle.version.VersionProvider import com.tangem.core.configtoggle.version.VersionProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.properties.Delegates
/** /**
* Feature toggles manager implementation in dev or mocked build * Feature toggles manager implementation in dev or mocked build
@ -24,54 +26,62 @@ internal class DevFeatureTogglesManager(
private val featureTogglesLocalStorage: LocalTogglesStorage, private val featureTogglesLocalStorage: LocalTogglesStorage,
) : MutableFeatureTogglesManager { ) : MutableFeatureTogglesManager {
private val fileFeatureTogglesMap: Map<String, Boolean> = getFileFeatureToggles() private val fileFeatureToggles: List<FeatureToggleInfo> = buildFileFeatureToggles()
@Suppress("DoubleMutabilityForCollection") private val currentToggles: MutableStateFlow<List<FeatureToggleInfo>> = MutableStateFlow(buildInitialToggles())
private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
init { override fun isFeatureEnabled(toggle: FeatureToggles): Boolean =
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() } currentToggles.value.any { it.name == toggle.rawName && it.isEnabled }
featureTogglesMap = fileFeatureTogglesMap
.mapValues { resultToggle ->
savedFeatureToggles[resultToggle.key] ?: resultToggle.value
}
.toMutableMap()
}
override fun isFeatureEnabled(toggle: FeatureToggles): Boolean = featureTogglesMap[toggle.rawName] == true
@VisibleForTesting(otherwise = VisibleForTesting.NONE) @VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun isFeatureEnabledByName(name: String): Boolean = featureTogglesMap[name] == true fun isFeatureEnabledByName(name: String): Boolean = currentToggles.value.any { it.name == name && it.isEnabled }
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap override fun getFeatureToggles(): List<FeatureToggleInfo> = currentToggles.value
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap override fun isMatchLocalConfig(): Boolean =
currentToggles.value.associateBy { it.name } == fileFeatureToggles.associateBy { it.name }
override suspend fun changeToggle(name: String, isEnabled: Boolean) { override suspend fun changeToggle(name: String, isEnabled: Boolean) {
featureTogglesMap[name] ?: return if (currentToggles.value.none { it.name == name }) return
featureTogglesMap[name] = isEnabled currentToggles.update { toggles ->
featureTogglesLocalStorage.store(value = featureTogglesMap) toggles.map { toggle ->
if (toggle.name == name) toggle.copy(isEnabled = isEnabled) else toggle
}
}
featureTogglesLocalStorage.store(value = currentToggles.value.toAvailabilityMap())
} }
override suspend fun recoverLocalConfig() { override suspend fun recoverLocalConfig() {
featureTogglesMap = fileFeatureTogglesMap.toMutableMap() currentToggles.value = fileFeatureToggles
featureTogglesLocalStorage.store(value = fileFeatureTogglesMap) featureTogglesLocalStorage.store(value = currentToggles.value.toAvailabilityMap())
} }
override fun toString(): String { override fun toString(): String {
return featureTogglesMap.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName) return currentToggles.value.toAvailabilityMap()
} .toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName)
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return featureTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
} }
@VisibleForTesting(otherwise = VisibleForTesting.NONE) @VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setFeatureToggles(map: MutableMap<String, Boolean>) { fun setFeatureToggles(map: MutableMap<String, Boolean>) {
featureTogglesMap = map currentToggles.value = map.map { (name, isEnabled) ->
val version = fileFeatureToggles.firstOrNull { it.name == name }?.version.orEmpty()
FeatureToggleInfo(name = name, version = version, isEnabled = isEnabled)
} }
}
private fun buildInitialToggles(): List<FeatureToggleInfo> {
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() }
return fileFeatureToggles.map { it.copy(isEnabled = savedFeatureToggles[it.name] ?: it.isEnabled) }
}
private fun buildFileFeatureToggles(): List<FeatureToggleInfo> {
val rawToggles = featureTogglesProvider.getToggles()
val availability = rawToggles.defineTogglesAvailability(appVersion = versionProvider.get())
return rawToggles.map { (name, version) ->
FeatureToggleInfo(name = name, version = version, isEnabled = availability.getValue(name))
}
}
private fun List<FeatureToggleInfo>.toAvailabilityMap(): Map<String, Boolean> =
associate { it.name to it.isEnabled }
} }

View file

@ -1,6 +0,0 @@
package com.tangem.core.configtoggle.feature.impl
internal object FeatureTogglesConstants {
const val LOCAL_CONFIG_PATH: String = "configs/feature_toggles_config"
}

View file

@ -10,7 +10,7 @@ import com.tangem.utils.logging.TangemLogger
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
internal class Version private constructor(value: String) : Comparable<Version> { class Version private constructor(value: String) : Comparable<Version> {
private val major: Int private val major: Int
private val minor: Int private val minor: Int

View file

@ -1,5 +1,7 @@
package com.tangem.core.configtoggle.version package com.tangem.core.configtoggle.version
import com.tangem.core.configtoggle.feature.DISABLED_FEATURE_TOGGLE_VERSION
/** /**
* Version contract to evaluate availability of feature toggle * Version contract to evaluate availability of feature toggle
* *
@ -7,8 +9,6 @@ package com.tangem.core.configtoggle.version
*/ */
internal object VersionAvailabilityContract { internal object VersionAvailabilityContract {
private const val DISABLED_FEATURE_TOGGLE_VERSION = "undefined"
/** Evaluate availability of feature toggles using [currentVersion] and [localVersion] */ /** Evaluate availability of feature toggles using [currentVersion] and [localVersion] */
operator fun invoke(currentVersion: String, localVersion: String): Boolean { operator fun invoke(currentVersion: String, localVersion: String): Boolean {
if (localVersion == DISABLED_FEATURE_TOGGLE_VERSION) return false if (localVersion == DISABLED_FEATURE_TOGGLE_VERSION) return false

View file

@ -50,7 +50,6 @@ internal class FeatureTogglesNamingConventionTest {
"SOLANA_TX_HISTORY_ENABLED", "SOLANA_TX_HISTORY_ENABLED",
"STAKING_ETH_ENABLED", "STAKING_ETH_ENABLED",
"SWAP_AB_ENABLED", "SWAP_AB_ENABLED",
"SWAP_INTEGRATED_APPROVE",
"USEDESK_ENABLED", "USEDESK_ENABLED",
"VIRTUAL_ACCOUNTS_ENABLED", "VIRTUAL_ACCOUNTS_ENABLED",
"VISA_ONBOARDING_ENABLED", "VISA_ONBOARDING_ENABLED",

View file

@ -1,6 +1,7 @@
package com.tangem.core.configtoggle.manager package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth import com.google.common.truth.Truth
import com.tangem.core.configtoggle.feature.FeatureToggleInfo
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.storage.LocalTogglesStorage
@ -28,6 +29,14 @@ internal class DevFeatureTogglesManagerTest {
version != "undefined" && !appVersion.isNullOrEmpty() version != "undefined" && !appVersion.isNullOrEmpty()
} }
private fun Map<String, Boolean>.toToggleInfoList(): List<FeatureToggleInfo> = map { (name, isEnabled) ->
FeatureToggleInfo(
name = name,
version = testToggles.getValue(name),
isEnabled = isEnabled,
)
}
@Nested @Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization { inner class Initialization {
@ -60,7 +69,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert // Assert
val expected = savedToggles val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected) Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder { coVerifyOrder {
versionProvider.get() versionProvider.get()
@ -86,7 +95,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert // Assert
val expected = savedToggles val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected) Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder { coVerifyOrder {
versionProvider.get() versionProvider.get()
@ -112,7 +121,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert // Assert
val expected = savedToggles val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected) Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder { coVerifyOrder {
versionProvider.get() versionProvider.get()
@ -159,7 +168,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert // Assert
val expected = getExpectedFileToggles(appVersion) val expected = getExpectedFileToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected) Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder { coVerifyOrder {
versionProvider.get() versionProvider.get()
@ -185,7 +194,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert // Assert
val expected = fileToggles val expected = fileToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected) Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder { coVerifyOrder {
versionProvider.get() versionProvider.get()
@ -302,6 +311,59 @@ internal class DevFeatureTogglesManagerTest {
} }
} }
@Test
fun `isMatchLocalConfig is true when toggles match but order differs`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
// Same toggles and values as the local config, but in reversed order
val reorderedToggles = getExpectedFileToggles(appVersion = "1.0.0")
.entries.reversed()
.associate { it.key to it.value }
.toMutableMap()
val manager = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).apply {
setFeatureToggles(reorderedToggles)
}
// Act
val actual = manager.isMatchLocalConfig()
// Assert
Truth.assertThat(actual).isTrue()
}
@Test
fun `isMatchLocalConfig is false when a value differs despite reversed order`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
// Reversed order AND one toggle value flipped → must not match
val reorderedChangedToggles = getExpectedFileToggles(appVersion = "1.0.0")
.entries.reversed()
.associate { it.key to it.value }
.toMutableMap()
.apply { this["ENABLED_TOGGLE"] = false }
val manager = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).apply {
setFeatureToggles(reorderedChangedToggles)
}
// Act
val actual = manager.isMatchLocalConfig()
// Assert
Truth.assertThat(actual).isFalse()
}
private fun provideTestModels(): List<IsMatchLocalConfigModel> { private fun provideTestModels(): List<IsMatchLocalConfigModel> {
val appVersion = "1.0.0" val appVersion = "1.0.0"
val fileToggles = getExpectedFileToggles(appVersion) val fileToggles = getExpectedFileToggles(appVersion)
@ -369,7 +431,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert // Assert
val expected = fileToggles + savedToggles val expected = fileToggles + savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected) Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder { coVerifyOrder {
versionProvider.get() versionProvider.get()
@ -405,7 +467,7 @@ internal class DevFeatureTogglesManagerTest {
val actual = manager.getFeatureToggles() val actual = manager.getFeatureToggles()
// Assert // Assert
Truth.assertThat(actual).containsExactlyEntriesIn(model.expectedToggles) Truth.assertThat(actual).containsExactlyElementsIn(model.expectedToggles.toToggleInfoList())
coVerifyOrder { coVerifyOrder {
versionProvider.get() versionProvider.get()
@ -478,7 +540,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert // Assert
val expected = getExpectedFileToggles(appVersion) val expected = getExpectedFileToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected) Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder { coVerifyOrder {
versionProvider.get() versionProvider.get()

View file

@ -95,6 +95,7 @@ dependencies {
implementation(deps.kotlin.coroutines) implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2) implementation(deps.kotlin.coroutines.rx2)
implementation(deps.kotlin.datetime) implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
/** Logging */ /** Logging */

View file

@ -0,0 +1,45 @@
package com.tangem.datasource.api.auth
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.datasource.api.common.response.ApiResponse
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Tangem Auth Service API (JWT session tokens / DPoP interceptor / refresh rotation)
*/
interface AuthApi {
/**
* Request authentication nonce.
*
* Generates a nonce bound to the device public key for the authentication flow.
*/
@POST("api/v1/auth/nonce/auth")
suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Authenticate device.
*
* Authenticates a previously registered device using a device-key signature. Issues a new
* JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after
* registration uses this endpoint.
*/
@POST("api/v1/auth/authenticate")
suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse>
/**
* Refresh tokens.
*
* Rotates the refresh token and issues a new access token. Uses refresh-token rotation
* with family-based reuse detection replaying a consumed token revokes the entire token
* family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`).
*/
@POST("api/v1/auth/refresh")
@RequiresSessionAuth
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
}

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.auth
/**
* Marks a Retrofit endpoint as requiring an authenticated session (DPoP, see
* [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)).
*
* Read at runtime by the session-auth interceptor: only methods
* carrying this annotation receive `Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`
* headers; unannotated methods (e.g. public nonce endpoints) pass through unchanged.
*
* Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the
* same on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionAuth

View file

@ -0,0 +1,46 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Authentication request — authenticates a previously registered device. */
@JsonClass(generateAdapter = true)
data class AuthApiRequest(
/** Signed authentication payload. */
@Json(name = "payload") val payload: AuthenticationPayload,
/** EC signature over the authentication payload, signed by the device private key (Base64). */
@Json(name = "signature") val signature: String,
)
/** Signed authentication payload — the data that is signed by the device private key. */
@JsonClass(generateAdapter = true)
data class AuthenticationPayload(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
/** Deciphered nonce value from the nonce endpoint. */
@Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
) {
/** Device metadata collection. */
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@Json(name = "deviceModel") val deviceModel: String?,
/** Operating system (`android` / `ios`). */
@Json(name = "os") val os: String,
/** OS version string (e.g. `17.4.1`). */
@Json(name = "osVersion") val osVersion: String?,
/** Application version (e.g. `5.8.0`). */
@Json(name = "appVersion") val appVersion: String?,
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
@Json(name = "userAgent") val userAgent: String?,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
)
}

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for nonce generation (auth, upgrade, wallet flows). */
@JsonClass(generateAdapter = true)
data class NonceApiRequest(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token refresh request. */
@JsonClass(generateAdapter = true)
data class RefreshApiRequest(
/** Refresh token from a previous token response. */
@Json(name = "refreshToken") val refreshToken: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Ciphered nonce response. */
@JsonClass(generateAdapter = true)
data class NonceApiResponse(
/** RSA-OAEP ciphered nonce value (Base64). */
@Json(name = "cipheredNonce") val cipheredNonce: String,
/** Nonce expiration timestamp (ISO-8601). */
@Json(name = "expiresAt") val expiresAt: String,
)

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with
* `Content-Type: application/problem+json` on every 4xx / 5xx response.
*/
@JsonClass(generateAdapter = true)
data class ProblemDetailResponse(
/** URI identifying the problem type. */
@Json(name = "type") val type: String,
/** Short human-readable summary (e.g. `"Too Many Requests"`). */
@Json(name = "title") val title: String,
/** HTTP status code. */
@Json(name = "status") val status: Int,
/** Human-readable explanation. */
@Json(name = "detail") val detail: String?,
/** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */
@Json(name = "instance") val instance: String?,
/** Application-specific error code. */
@Json(name = "code") val code: String?,
/** Retry delay for rate limiting (`429`). */
@Json(name = "retryAfterSeconds") val retryAfterSeconds: Int?,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token response — contains JWT access token and optional refresh token. */
@JsonClass(generateAdapter = true)
data class TokenApiResponse(
/** JWT access token (HMAC-SHA256 signed). */
@Json(name = "accessToken") val accessToken: String,
/** Access token expiration timestamp (ISO-8601). */
@Json(name = "accessTokenExpiresAt") val accessTokenExpiresAt: String,
/** Refresh token for token rotation. `null` for ORANGE tier (requires device challenge each time). */
@Json(name = "refreshToken") val refreshToken: String?,
/** Refresh token expiration timestamp (ISO-8601). `null` iff [refreshToken] is `null`. */
@Json(name = "refreshTokenExpiresAt") val refreshTokenExpiresAt: String?,
/** List of wallet IDs bound to this device. */
@Json(name = "walletIds") val walletIds: List<String>,
)

View file

@ -33,6 +33,7 @@ sealed class ApiConfig {
News, News,
GaslessTxService, GaslessTxService,
SurveySparrow, SurveySparrow,
Auth,
} }
private fun initializeId(): ID { private fun initializeId(): ID {
@ -49,6 +50,7 @@ sealed class ApiConfig {
is News -> ID.News is News -> ID.News
is GaslessTxService -> ID.GaslessTxService is GaslessTxService -> ID.GaslessTxService
is SurveySparrow -> ID.SurveySparrow is SurveySparrow -> ID.SurveySparrow
is Auth -> ID.Auth
} }
} }

View file

@ -0,0 +1,59 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
/**
* Tangem Auth Service [ApiConfig] endpoints for device registration, authentication,
* nonce issuance, refresh token rotation, and JWKS publication.
*/
internal class Auth : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = DEV_BASE_URL,
headers = emptyMap(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = MOCK_BASE_URL,
headers = emptyMap(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = PROD_BASE_URL,
headers = emptyMap(),
)
private companion object {
// TODO Replace with real Auth Service hosts once the backend team confirms deployment.
// Swagger currently only declares `http://localhost:8080` for local development.
// [REDACTED_JIRA]
private const val DEV_BASE_URL = "http://localhost:8080/"
private const val MOCK_BASE_URL = "http://localhost:8080/"
private const val PROD_BASE_URL = "http://localhost:8080/"
}
}

View file

@ -97,6 +97,12 @@ interface TangemPayApi {
@Body body: ReissueCardRequest, @Body body: ReissueCardRequest,
): ApiResponse<ReissueCardResponse> ): ApiResponse<ReissueCardResponse>
@POST("v1/customer/card/close")
suspend fun closeCard(
@Header("Authorization") authHeader: String,
@Body body: CloseCardRequest,
): ApiResponse<CloseCardResponse>
@POST("v1/customer/card/withdraw/data") @POST("v1/customer/card/withdraw/data")
suspend fun getWithdrawData( suspend fun getWithdrawData(
@Header("Authorization") authHeader: String, @Header("Authorization") authHeader: String,

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CloseCardRequest(
@Json(name = "card_id") val cardId: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CloseCardResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: OrderResponse.Result.Status,
)
}

View file

@ -113,4 +113,10 @@ internal object ApiConfigsModule {
fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig { fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig {
return SurveySparrow(environmentConfig) return SurveySparrow(environmentConfig)
} }
@Provides
@IntoSet
fun provideAuthConfig(): ApiConfig {
return Auth()
}
} }

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfig
@ -208,6 +209,15 @@ internal object NetworkModule {
) )
} }
@Provides
@Singleton
fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Auth,
applyTimeoutAnnotations = false,
)
}
@Provides @Provides
@Singleton @Singleton
fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi { fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi {

View file

@ -3,8 +3,10 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayCloseCardStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.datasource.local.visa.TangemPayReissueCardStore import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -32,4 +34,12 @@ internal object TangemPayStoresModule {
prefs = prefs, prefs = prefs,
) )
} }
@Provides
@Singleton
fun provideTangemPayCloseCardStore(prefs: AppPreferencesStore): TangemPayCloseCardStore {
return DefaultTangemPayCloseCardStore(
prefs = prefs,
)
}
} }

View file

@ -14,6 +14,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -26,6 +27,11 @@ internal object ConfigModule {
return GeneratedEnvironmentConfigConverter.convert() return GeneratedEnvironmentConfigConverter.convert()
} }
@Provides
@Singleton
@Named("authServiceKey")
fun provideAuthServiceKey(environmentConfig: EnvironmentConfig): String? = environmentConfig.authServiceKey
@Provides @Provides
@Singleton @Singleton
fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage { fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage {

View file

@ -16,11 +16,15 @@ import com.tangem.datasource.api.utils.ConnectTimeout
import com.tangem.datasource.api.utils.ReadTimeout import com.tangem.datasource.api.utils.ReadTimeout
import com.tangem.datasource.api.utils.WriteTimeout import com.tangem.datasource.api.utils.WriteTimeout
import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.datasource.utils.addHeaders import com.tangem.datasource.utils.addHeaders
import com.tangem.utils.JsonStringValuesExtractor
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Invocation import retrofit2.Invocation
@ -41,6 +45,7 @@ import javax.inject.Singleton
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
@Suppress("LongParameterList")
@Singleton @Singleton
internal class RetrofitApiBuilder @Inject constructor( internal class RetrofitApiBuilder @Inject constructor(
private val apiConfigs: ApiConfigs, private val apiConfigs: ApiConfigs,
@ -49,10 +54,20 @@ internal class RetrofitApiBuilder @Inject constructor(
private val analyticsErrorHandler: AnalyticsErrorHandler, private val analyticsErrorHandler: AnalyticsErrorHandler,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val appLogsStore: AppLogsStore, private val appLogsStore: AppLogsStore,
private val environmentConfig: EnvironmentConfig,
) { ) {
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls() private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
private val sensitiveUrlMasker: SensitiveUrlMasker by lazy {
val json = Json.encodeToJsonElement(EnvironmentConfig.serializer(), environmentConfig)
// Drop URL-shaped values (e.g. public endpoint URLs from config); they are not secrets
// and would obscure unrelated requests in logs.
val values = JsonStringValuesExtractor.extract(json)
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
SensitiveUrlMasker(values)
}
/** /**
* Builds a Retrofit API instance for the specified API configuration ID * Builds a Retrofit API instance for the specified API configuration ID
* *
@ -179,7 +194,7 @@ internal class RetrofitApiBuilder @Inject constructor(
private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder { private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder {
return addInterceptor( return addInterceptor(
interceptor = NetworkLogsSaveInterceptor(appLogsStore), interceptor = NetworkLogsSaveInterceptor(appLogsStore, sensitiveUrlMasker),
) )
} }

View file

@ -4,7 +4,10 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.datasource.local.config.environment.models.ExpressModel import com.tangem.datasource.local.config.environment.models.ExpressModel
import com.tangem.datasource.local.config.environment.models.P2PKeys import com.tangem.datasource.local.config.environment.models.P2PKeys
import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class EnvironmentConfig( data class EnvironmentConfig(
val moonPayApiKey: String = "", val moonPayApiKey: String = "",
val moonPayApiSecretKey: String = "", val moonPayApiSecretKey: String = "",
@ -32,5 +35,7 @@ data class EnvironmentConfig(
val gaslessTxApiKey: String? = null, val gaslessTxApiKey: String? = null,
val customerIoCdpApiKey: String? = null, val customerIoCdpApiKey: String? = null,
val surveySparrowToken: String? = null, val surveySparrowToken: String? = null,
@Transient
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null, val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
val authServiceKey: String? = null,
) )

View file

@ -56,6 +56,7 @@ internal object GeneratedEnvironmentConfigConverter {
customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey, customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey,
surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey, surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey,
surveySparrowSwapRating = createSurveySparrowSwapRating(), surveySparrowSwapRating = createSurveySparrowSwapRating(),
authServiceKey = null, // TODO: provide service key [REDACTED_JIRA]
) )
} }

Some files were not shown because too many files have changed in this diff Show more