Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 13:39:30 +03:00
commit 92849d43c2
1407 changed files with 64092 additions and 11990 deletions

View file

@ -125,6 +125,7 @@ dependencies {
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
implementation(projects.domain.appUpdate)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory)
@ -192,6 +193,7 @@ dependencies {
implementation(projects.libs.tangemSdkApi)
implementation(projects.data.account)
implementation(projects.data.addressBook)
implementation(projects.data.appCurrency)
implementation(projects.data.appTheme)
implementation(projects.data.balanceHiding)
@ -199,6 +201,7 @@ dependencies {
implementation(projects.data.card)
implementation(projects.data.common)
implementation(projects.data.settings)
implementation(projects.data.appUpdate)
implementation(projects.data.tokens)
implementation(projects.data.assetsdiscovery)
implementation(projects.data.txhistory)
@ -264,6 +267,8 @@ dependencies {
implementation(projects.features.details.impl)
implementation(projects.features.disclaimer.api)
implementation(projects.features.disclaimer.impl)
implementation(projects.features.forceUpdate.api)
implementation(projects.features.forceUpdate.impl)
implementation(projects.features.pushNotifications.api)
implementation(projects.features.pushNotifications.impl)
implementation(projects.features.pushNotificationSettings.api)
@ -332,6 +337,8 @@ dependencies {
implementation(projects.features.yieldSupply.impl)
implementation(projects.features.approval.api)
implementation(projects.features.approval.impl)
implementation(projects.features.forYou.api)
implementation(projects.features.forYou.impl)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
@ -391,6 +398,7 @@ dependencies {
kapt(deps.hilt.compilerx)
/** Other libraries */
implementation(deps.arrow.fx)
implementation(deps.kotlin.immutable.collections)
implementation(deps.material)
implementation(deps.googlePlay.review)

View file

@ -184,9 +184,7 @@ abstract class BaseTestCase : TestCase(
toggleStates = mapOf(
"SWAP_REDESIGN_ENABLED" to false,
"ACCOUNTS_FEATURE_ENABLED" to true,
"GASLESS_APPROVAL_ENABLED" to true,
"MAIN_SCREEN_QR_SCANNING_ENABLED" to true,
"ADD_AND_MANAGE_TOKENS_ENABLED" to true,
"ASSETS_DISCOVERY_ENABLED" to true,
"VISA_ONBOARDING_ENABLED" to true,
// Version-gated toggles released in versions <= 6.0 — forced on so tests run against the actual
@ -195,20 +193,13 @@ abstract class BaseTestCase : TestCase(
// 5.37
"HEDERA_ERC20_ENABLED" to true,
// 5.39
"STAKING_ETH_ENABLED" to true,
"DYNAMIC_ADDRESSES_ENABLED" to true,
"SOLANA_TX_HISTORY_ENABLED" to true,
"SOLANA_SCALED_UI_AMOUNT_ENABLED" to true,
"SWAP_AB_ENABLED" to true,
"AND_15310_ADD_FUNDS_STAGE1" to true,
"AND_15009_SWAP_PROVIDER_FILTER_ENABLED" to true,
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
"AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED" to true,
"AND_15103_SWAP_RATE_EXPERIENCE_ENABLED" to true,
"AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true,
"TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED" to true,
// 5.39.2
"AND_15154_YIELD_PROMO_ENABLED" to true,
// 5.40
"TWI_1377_MANAGE_FUNDS" to true,
// 6.0

View file

@ -46,6 +46,7 @@ object TestConstants {
const val ALLURE_LABEL_VALUE = "Kaspresso"
const val USER_TOKENS_API_SCENARIO = "user_tokens_api"
const val COINS_API_SCENARIO = "coins_api"
const val REFERRAL_API_SCENARIO = "referral_api"
const val QUOTES_API_SCENARIO = "quotes_api"
const val CREATE_USER_WALLET_API_SCENARIO = "create_user_wallet_api"

View file

@ -0,0 +1,20 @@
package com.tangem.common.extensions
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.performClick
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AppSettingsScreenTestTags
import com.tangem.core.ui.test.TokenDetailsTopBarTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.test.TopNavigationTestTags
fun BaseTestCase.tapBackButton() {
waitForIdle()
composeTestRule.onNode(
hasTestTag(TopNavigationTestTags.BACK_BUTTON)
or hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
or hasTestTag(TokenDetailsTopBarTestTags.BACK_BUTTON)
or hasTestTag(AppSettingsScreenTestTags.BACK_BUTTON),
useUnmergedTree = true,
).performClick()
}

View file

@ -42,6 +42,17 @@ object AddressComparisonHelper {
assertTrue(report, false)
}
fun derivationPathForBlockchain(json: String, blockchain: String): String {
val array = JSONArray(json)
for (i in 0 until array.length()) {
val entry = array.getJSONObject(i)
if (entry.getString("blockchain").equals(blockchain, ignoreCase = true)) {
return entry.getString("derivationPath").trim()
}
}
error("No entry with blockchain '$blockchain' found in addresses JSON")
}
private fun parseAndNormalize(json: String): List<AddressEntry> {
val array = JSONArray(json)
val entries = mutableListOf<AddressEntry>()

View file

@ -4,6 +4,8 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
fun getClipboardText(context: Context): String? {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@ -27,12 +29,15 @@ fun clearClipboard(
clipboard.clearPrimaryClip()
}
// Kotlin's assert() is a no-op on device (JVM assertions disabled) — use JUnit asserts here.
fun assertClipboardTextEquals(
expected: String,
context: Context = ApplicationProvider.getApplicationContext()
) {
assertEquals("Clipboard text mismatch", expected, getClipboardText(context))
}
fun assertClipboardIsEmpty(context: Context = ApplicationProvider.getApplicationContext()) {
val actual = getClipboardText(context)
assert(actual == expected) {
"Clipboard text mismatch.\nExpected: '$expected'\nActual: '$actual'"
}
assertTrue("Expected empty clipboard but was: '$actual'", actual.isNullOrEmpty())
}

View file

@ -1,6 +1,5 @@
package com.tangem.scenarios
import android.view.KeyEvent
import androidx.test.core.app.ApplicationProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
@ -57,31 +56,6 @@ fun BaseTestCase.verifyAddresses(seedPhrase: String, apiAddressesJson: String) {
}
}
private const val TESTER_MENU_MAX_ATTEMPTS = 3
/**
* Presses 'Volume Down' twice to open tester menu.
* Retries up to [TESTER_MENU_MAX_ATTEMPTS] times if the menu doesn't appear.
*/
private fun BaseTestCase.openTesterMenu() {
repeat(TESTER_MENU_MAX_ATTEMPTS) { attempt ->
waitForIdle()
device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
val opened = runCatching {
onTesterMenuScreen { addressesInfoButton.assertIsDisplayed() }
}.isSuccess
if (opened) {
TangemLogger.i("Tester menu opened on attempt ${attempt + 1}")
return
}
TangemLogger.w("Tester menu not opened on attempt ${attempt + 1}, retrying...")
}
error("Failed to open tester menu after $TESTER_MENU_MAX_ATTEMPTS attempts")
}
/**
* Polls [walletManagersStore] until the wallet manager count stops growing for [WALLET_MANAGERS_SETTLE_MS].

View file

@ -57,6 +57,17 @@ fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String)
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Wait until the '$tokenName' fee is loaded and 'Apply' is enabled") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendFeeSelectorBottomSheet {
networkFeeTitle.assertIsDisplayed()
feeTokenItem(tokenName).assertIsDisplayed()
applyButton.assertIsEnabled()
}
}.isSuccess
}
}
}
/**

View file

@ -0,0 +1,154 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.performTextInputInChunks
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.screens.accounts.onAccountDetailsScreen
import com.tangem.screens.onAddCustomTokenScreen
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDialog
import com.tangem.screens.onManageTokensScreen
import com.tangem.screens.onWalletSettingsScreen
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import kotlinx.coroutines.runBlocking
import com.tangem.core.res.R as CoreResR
private fun mainAccountName(): String = getResourceString(CoreResR.string.account_main_account_title)
// flakySafely is unavailable in BaseTestCase extensions — wait until the assertion/action stops throwing.
private fun BaseTestCase.awaitSuccess(block: () -> Unit) {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT) { runCatching(block).isSuccess }
}
fun BaseTestCase.openManageTokens(accountName: String = mainAccountName()) {
openWalletSettingsScreen()
openAccountDetails(accountName)
step("Click on 'Manage tokens' button") {
onAccountDetailsScreen { manageTokensButton.clickWithAssertion() }
}
}
fun BaseTestCase.openAddCustomToken(accountName: String = mainAccountName()) {
openManageTokens(accountName)
step("Click on 'Add custom token' button") {
onManageTokensScreen { addCustomTokenButton.clickWithAssertion() }
}
}
fun BaseTestCase.addCustomTokenWithCustomDerivation(network: String, contract: String, derivationPath: String) {
step("Click on network: '$network'") {
awaitSuccess { onAddCustomTokenScreen { scrollToNetwork(network) } }
onAddCustomTokenScreen { networkRow(network).performClick() }
}
step("Enter contract address: '$contract'") {
awaitSuccess { onAddCustomTokenScreen { contractAddressField.assertExists() } }
onAddCustomTokenScreen { contractAddressField.performTextInputInChunks(contract) }
}
step("Click on 'Derivation path' field") {
awaitSuccess { onAddCustomTokenScreen { derivationSelectorField.assertExists() } }
onAddCustomTokenScreen { derivationSelectorField.performClick() }
}
step("Click on 'Custom derivation' button") {
awaitSuccess { onAddCustomTokenScreen { customDerivationButton.assertIsDisplayed() } }
onAddCustomTokenScreen { customDerivationButton.performClick() }
}
step("Enter custom derivation path: '$derivationPath'") {
awaitSuccess { onDialog { inputField.assertIsDisplayed() } }
onDialog { inputField.performTextInputInChunks(derivationPath) }
awaitSuccess { onDialog { okButton.assertIsEnabled() } }
onDialog { okButton.performClick() }
}
step("Click on 'Add token' button") {
awaitSuccess { onAddCustomTokenScreen { addTokenButton.assertIsEnabled() } }
onAddCustomTokenScreen { addTokenButton.performClick() }
}
navigateBackToMainFromManageTokens()
}
private const val MAX_BACKS_TO_MANAGE_TOKENS = 5
// The device-backs to close the sheet vary — press back until Manage Tokens is shown, then fail loudly.
private fun BaseTestCase.closeAddCustomTokenSheet() {
repeat(MAX_BACKS_TO_MANAGE_TOKENS) {
if (runCatching { onManageTokensScreen { searchField.assertIsDisplayed() } }.isSuccess) return
device.uiDevice.pressBack()
waitForIdle()
}
onManageTokensScreen { searchField.assertIsDisplayed() }
}
private fun BaseTestCase.navigateBackToWalletSettings() {
step("Click on 'Manage tokens' screen 'Back' button") {
waitForIdle()
onManageTokensScreen { topAppBarBackButton.performClick() }
}
step("Click on 'Account details' screen 'Back' button") {
waitForIdle()
onAccountDetailsScreen { topAppBarBackButton.performClick() }
}
}
fun BaseTestCase.forgetCurrentWalletAndReArmForNextScan() {
step("Close 'Add custom token' bottom sheet") { closeAddCustomTokenSheet() }
navigateBackToWalletSettings()
step("Click on 'Forget wallet' button") {
waitForIdle()
onWalletSettingsScreen {
scrollToForgetWallet()
forgetWalletButton.clickWithAssertion()
}
}
step("Confirm forgetting the wallet") {
awaitSuccess { onDialog { forgetButton.assertIsDisplayed() } }
onDialog { forgetButton.clickWithAssertion() }
}
// Markets tooltip is a one-time app-level flag; re-arm it so the next scan's openMainScreen can dismiss it.
step("Re-arm the markets tooltip for the next scan") {
runBlocking {
appPreferencesStore.editData { it.set(PreferencesKeys.SHOULD_SHOW_MARKETS_TOOLTIP_KEY, value = true) }
}
}
}
fun BaseTestCase.assertDerivationPathsInSelector(networkToOpen: String, vararg expectedPaths: Pair<String, String>) {
step("Click on network: '$networkToOpen'") {
awaitSuccess { onAddCustomTokenScreen { scrollToNetwork(networkToOpen) } }
onAddCustomTokenScreen { networkRow(networkToOpen).performClick() }
}
step("Click on 'Derivation path' field") {
awaitSuccess { onAddCustomTokenScreen { derivationSelectorField.assertExists() } }
onAddCustomTokenScreen { derivationSelectorField.performClick() }
}
expectedPaths.forEach { (networkId, path) ->
step("Assert '$networkId' derivation path is '$path'") {
awaitSuccess { onAddCustomTokenScreen { scrollToDerivationRow(networkId) } }
onAddCustomTokenScreen { derivationPath(networkId, path).assertIsDisplayed() }
}
}
}
fun BaseTestCase.toggleTokenNetworkInManageTokens(tokenTitle: String, networkTitle: String) {
step("Click on token: '$tokenTitle'") {
awaitSuccess { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
onManageTokensScreen { tokenItem(tokenTitle).performClick() }
}
step("Click on '$networkTitle' switch") {
awaitSuccess { onManageTokensScreen { networkSwitch(networkTitle).assertIsDisplayed() } }
onManageTokensScreen { networkSwitch(networkTitle).performClick() }
}
}
fun BaseTestCase.navigateBackToMainFromManageTokens() {
navigateBackToWalletSettings()
step("Click on 'Wallet settings' screen 'Back' button") {
waitForIdle()
onWalletSettingsScreen { topAppBarBackButton.performClick() }
}
step("Click on 'Details' screen 'Back' button") {
waitForIdle()
onDetailsScreen { topAppBarBackButton.performClick() }
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onReferralProgramScreen
import com.tangem.screens.onWalletSettingsScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.referralTakeParticipate() {
step("Open 'Details' screen") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.clickWithAssertion() }
}
step("Open 'Referral program' screen") {
onWalletSettingsScreen { referralProgramButton.clickWithAssertion() }
}
step("Tap 'Participate'") {
onReferralProgramScreen { participateButton.clickWithAssertion() }
}
step("Verify referral code is displayed") {
onReferralProgramScreen { personalCodeCard.assertIsDisplayed() }
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.scenarios
import android.view.KeyEvent
import com.tangem.common.BaseTestCase
import com.tangem.screens.onTesterMenuScreen
import com.tangem.utils.logging.TangemLogger
private const val TESTER_MENU_MAX_ATTEMPTS = 3
/**
* Opens the tester menu by sending two 'Volume Down' key events in a single shell command.
* VolumeButtonDoublePressObserver needs two ACTION_DOWN within 300ms; two separate pressKeyCode
* calls are too slow, but both events in one `input keyevent` invocation arrive back-to-back within
* the window. Retries up to [TESTER_MENU_MAX_ATTEMPTS] times if it doesn't appear.
*/
fun BaseTestCase.openTesterMenu() {
repeat(TESTER_MENU_MAX_ATTEMPTS) { attempt ->
waitForIdle()
val volumeDown = KeyEvent.KEYCODE_VOLUME_DOWN
device.uiDevice.executeShellCommand("input keyevent $volumeDown $volumeDown")
val opened = runCatching {
onTesterMenuScreen { addressesInfoButton.assertIsDisplayed() }
}.isSuccess
if (opened) {
TangemLogger.i("Tester menu opened on attempt ${attempt + 1}")
return
}
TangemLogger.w("Tester menu not opened on attempt ${attempt + 1}, retrying...")
}
error("Failed to open tester menu after $TESTER_MENU_MAX_ATTEMPTS attempts")
}

View file

@ -0,0 +1,67 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.performScrollToNode
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AddCustomTokenScreenTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
class AddCustomTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddCustomTokenPageObject>(semanticsProvider = semanticsProvider) {
val selectorList: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.SELECTOR_LIST)
}
fun networkRow(networkName: String): KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.networkRow(networkName))
}
@OptIn(ExperimentalTestApi::class)
fun scrollToNetwork(networkName: String) = selectorList {
performScrollToNode(withTestTag(AddCustomTokenScreenTestTags.networkRow(networkName)))
}
@OptIn(ExperimentalTestApi::class)
fun scrollToDerivationRow(networkId: String) = selectorList {
performScrollToNode(withTestTag(AddCustomTokenScreenTestTags.derivationRow(networkId)))
}
fun derivationPath(networkId: String, path: String): KNode = child {
useUnmergedTree = true
hasTestTag(AddCustomTokenScreenTestTags.derivationRow(networkId))
hasAnyDescendant(withText(path))
}
val contractAddressField: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.CONTRACT_ADDRESS_FIELD)
}
val derivationSelectorField: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.DERIVATION_SELECTOR_FIELD)
}
val customDerivationButton: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.CUSTOM_DERIVATION_BUTTON)
}
val warningNotification: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.WARNING_NOTIFICATION)
}
val addTokenButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(CoreResR.string.common_add_token))
}
}
internal fun BaseTestCase.onAddCustomTokenScreen(function: AddCustomTokenPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -14,6 +14,10 @@ class AppSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
hasTestTag(AppSettingsScreenTestTags.CURRENCY_BUTTON)
useUnmergedTree = true
}
val backButton: KNode = child {
hasTestTag(AppSettingsScreenTestTags.BACK_BUTTON)
}
}
internal fun BaseTestCase.onAppSettingsScreen(function: AppSettingsPageObject.() -> Unit) =

View file

@ -14,6 +14,10 @@ import androidx.compose.ui.test.hasText as withText
class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DetailsPageObject>(semanticsProvider = semanticsProvider) {
val screenContainer: KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_CONTAINER)
}
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}

View file

@ -2,6 +2,7 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.BaseDialogTestTags
@ -10,6 +11,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DialogPageObject>(semanticsProvider = semanticsProvider) {
@ -18,6 +20,12 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasTestTag(BaseDialogTestTags.CONTAINER)
}
fun containerWithText(text: String): KNode = child {
hasTestTag(BaseDialogTestTags.CONTAINER)
hasAnyDescendant(withText(text = text, substring = true))
useUnmergedTree = true
}
val title: KNode = child {
hasTestTag(BaseDialogTestTags.TITLE)
}
@ -87,6 +95,11 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(getResourceString(R.string.common_ok))
}
val forgetButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(CoreResR.string.common_forget))
}
val changeButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_change))

View file

@ -6,6 +6,7 @@ import com.tangem.common.R
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.ManageTokensScreenTestTags
import com.tangem.core.ui.test.SearchBarTestTags
import com.tangem.core.ui.test.SwitchTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
@ -17,6 +18,7 @@ import androidx.compose.ui.test.hasText as withText
import androidx.compose.ui.test.hasAnySibling as withAnySibling
import androidx.compose.ui.test.hasAnyDescendant as withAnyDescendant
import androidx.compose.ui.test.hasAnyAncestor as withAnyAncestor
import com.tangem.core.res.R as CoreResR
class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ManageTokensPageObject>(semanticsProvider = semanticsProvider) {
@ -35,11 +37,43 @@ class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
}
val searchClearButton: KNode = child {
hasTestTag(SearchBarTestTags.CLEAR_BUTTON)
}
val addCustomTokenButton: KNode = child {
hasTestTag(TopAppBarTestTags.MORE_BUTTON)
}
fun tokenItem(tokenName: String): KNode = child {
hasTestTag(ManageTokensScreenTestTags.TOKEN_ITEM)
hasText(tokenName)
}
fun networkStandard(networkName: String, standard: String): KNode = child {
useUnmergedTree = true
addSemanticsMatcher(
withText(standard).and(
withAnyAncestor(
withTestTag(ManageTokensScreenTestTags.NETWORK_NAME)
.and(withAnyDescendant(withText(networkName, ignoreCase = true))),
),
),
)
}
fun networkName(networkName: String): KNode = child {
useUnmergedTree = true
addSemanticsMatcher(
withTestTag(ManageTokensScreenTestTags.NETWORK_NAME)
.and(withAnyDescendant(withText(networkName))),
)
}
val contractAddressCopiedMessage: KNode = child {
hasText(getResourceString(CoreResR.string.contract_address_copied_message))
}
fun networkSwitch(networkName: String): KNode = child {
useUnmergedTree = true
addSemanticsMatcher(

View file

@ -78,6 +78,11 @@ class ReferralProgramPageObject(semanticsProvider: SemanticsNodeInteractionsProv
hasTestTag(BaseButtonTestTags.TEXT)
useUnmergedTree = true
}
val personalCodeCard: KNode = child {
hasTestTag(ReferralProgramScreenTestTags.PERSONAL_CODE_CARD)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onReferralProgramScreen(function: ReferralProgramPageObject.() -> Unit) =

View file

@ -114,6 +114,12 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
fun tokenTitle(name: String): KNode = child {
hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE)
hasAnyDescendant(withText(text = name, substring = true))
useUnmergedTree = true
}
fun networkFeeNotificationMessage(
currencyName: String,
networkName: String,

View file

@ -59,15 +59,15 @@ class AppCurrencyTest : BaseTestCase() {
onAppSettingsScreen { currencyButton.assertIsDisplayed() }
}
}
step("Return to 'Details' screen") {
waitForIdle()
device.uiDevice.pressBack()
step("Return to 'Details' screen via 'Back' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onAppSettingsScreen { backButton.clickWithAssertion() }
onDetailsScreen { screenContainer.assertIsDisplayed() }
}
}
step("Return to 'Main' screen via 'Back' button") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Main' screen is opened") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
onMainScreen { screenContainer.assertIsDisplayed() }
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.addFunds
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.assertTextContainsSafe
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddTokenBottomSheet
@ -11,6 +12,7 @@ import com.tangem.screens.onBuyTokenDetailsScreen
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.Issue
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@ -83,4 +85,24 @@ class BuyTest : BaseTestCase() {
}
}
}
@AllureId("3613")
@DisplayName("On-ramp Buy: S2C card doesn't have Buy and Sell options")
@Test
@Issue("[REDACTED_TASK_KEY]")
fun buyAndSellIsNotAvailableForS2CCardTest() {
setupHooks().run {
step("Open 'Main' screen") {
openMainScreen(productType = ProductType.Start2Coin)
}
step("Verify 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Verify Buy/Sell action buttons are hidden") {
onMainScreen {
buyButton.assertDoesNotExist()
}
}
}
}
}

View file

@ -0,0 +1,251 @@
package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.COINS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.performTextInputInChunks
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.addCustomTokenWithCustomDerivation
import com.tangem.scenarios.assertDerivationPathsInSelector
import com.tangem.scenarios.forgetCurrentWalletAndReArmForNextScan
import com.tangem.scenarios.navigateBackToMainFromManageTokens
import com.tangem.scenarios.openAddCustomToken
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddCustomTokenScreen
import com.tangem.screens.onMainScreen
import com.tangem.tap.domain.sdk.mocks.content.Wallet1LegacyDerivationMockContent
import com.tangem.tap.domain.sdk.mocks.content.Wallet2NoEd25519Slip0010MockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertFalse
import org.junit.Test
@HiltAndroidTest
class AddCustomTokenTest : BaseTestCase() {
private val richState = "ManageTokensRich"
private val tokenTitle = "Tether"
private val ethereumNetwork = "Ethereum"
private val solanaNetwork = "Solana"
private val bitcoinNetworkId = "bitcoin"
private val ethereumClassicNetworkId = "ethereum-classic"
private val ethContract = "0xdac17f958d2ee523a2206206994597c13d831ec7"
private val solanaContract = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
private val customDerivationPath = "m/44'/60'/0'/0/1"
@AllureId("772")
@DisplayName("Add custom token: added token appears on Main")
@Test
fun addCustomTokenAppearsOnMainTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") {
flakySafely { onMainScreen { synchronizeAddressesButton.assertIsDisplayed() } }
synchronizeAddresses(assertBalance = false)
}
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Click on network: '$ethereumNetwork'") {
flakySafely { onAddCustomTokenScreen { scrollToNetwork(ethereumNetwork) } }
onAddCustomTokenScreen { networkRow(ethereumNetwork).performClick() }
}
step("Enter contract address: '$ethContract'") {
flakySafely { onAddCustomTokenScreen { contractAddressField.assertExists() } }
// Chunked input spans several validation ticks — the model drops the first sampled form change.
onAddCustomTokenScreen { contractAddressField.performTextInputInChunks(ethContract) }
}
step("Click on 'Add token' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onAddCustomTokenScreen { addTokenButton.assertIsEnabled() } }
onAddCustomTokenScreen { addTokenButton.performClick() }
}
step("Navigate back to 'Main Screen'") { navigateBackToMainFromManageTokens() }
step("Assert token: '$tokenTitle' is displayed on Main") {
flakySafely { onMainScreen { tokenWithTitleAndAddress(tokenTitle).assertIsDisplayed() } }
}
}
}
@AllureId("775")
@DisplayName("Add custom token: custom derivation path is accepted")
@Test
fun addCustomTokenWithCustomDerivationTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") {
flakySafely { onMainScreen { synchronizeAddressesButton.assertIsDisplayed() } }
synchronizeAddresses(assertBalance = false)
}
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Add custom token with custom derivation on '$ethereumNetwork'") {
addCustomTokenWithCustomDerivation(
network = ethereumNetwork,
contract = ethContract,
derivationPath = customDerivationPath,
)
}
step("Assert token: '$tokenTitle' is displayed on Main") {
flakySafely { onMainScreen { tokenWithTitleAndAddress(tokenTitle).assertIsDisplayed() } }
}
}
}
@AllureId("771")
@DisplayName("Add custom token: custom derivation indicator is shown on Main")
@Test
fun customDerivationIndicatorOnMainTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") {
flakySafely { onMainScreen { synchronizeAddressesButton.assertIsDisplayed() } }
synchronizeAddresses(assertBalance = false)
}
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Add custom token with custom derivation on '$ethereumNetwork'") {
addCustomTokenWithCustomDerivation(
network = ethereumNetwork,
contract = ethContract,
derivationPath = customDerivationPath,
)
}
step("Assert custom derivation indicator for '$tokenTitle' is displayed on Main") {
flakySafely { onMainScreen { tokenWithCustomDerivationIcon(tokenTitle).assertIsDisplayed() } }
}
}
}
@AllureId("770")
@DisplayName("Add custom token: derivation field is available for a non-EVM network")
@Test
fun derivationAvailableForNonEvmNetworkTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Click on network: '$solanaNetwork'") {
flakySafely { onAddCustomTokenScreen { scrollToNetwork(solanaNetwork) } }
onAddCustomTokenScreen { networkRow(solanaNetwork).performClick() }
}
step("Assert 'Derivation path' field is available") {
flakySafely { onAddCustomTokenScreen { derivationSelectorField.assertExists() } }
}
}
}
@AllureId("769")
@DisplayName("Add custom token: Solana token on a modern card shows no unsupported warning")
@Test
fun solanaTokenModernCardNoWarningTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Click on network: '$solanaNetwork'") {
flakySafely { onAddCustomTokenScreen { scrollToNetwork(solanaNetwork) } }
onAddCustomTokenScreen { networkRow(solanaNetwork).performClick() }
}
step("Enter contract address: '$solanaContract'") {
flakySafely { onAddCustomTokenScreen { contractAddressField.assertExists() } }
onAddCustomTokenScreen { contractAddressField.performTextInputInChunks(solanaContract) }
}
step("Assert 'Add token' button is enabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onAddCustomTokenScreen { addTokenButton.assertIsEnabled() } }
}
step("Assert unsupported-token warning is not displayed") {
onAddCustomTokenScreen { warningNotification.assertDoesNotExist() }
}
}
}
@AllureId("777")
@DisplayName("Add custom token: network with a missing curve is not offered")
@Test
fun missingCurveNetworkNotOfferedTest() {
val bitcoinNetwork = "Bitcoin"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen(mockContent = Wallet2NoEd25519Slip0010MockContent) }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Assert supported network '$bitcoinNetwork' is displayed") {
flakySafely { onAddCustomTokenScreen { scrollToNetwork(bitcoinNetwork) } }
}
step("Assert '$solanaNetwork' network is not displayed for the missing-curve card") {
onAddCustomTokenScreen { selectorList.assertIsDisplayed() }
val solanaOffered = runCatching { onAddCustomTokenScreen { scrollToNetwork(solanaNetwork) } }.isSuccess
assertFalse("'$solanaNetwork' should not be offered for a missing-curve card", solanaOffered)
}
}
}
@AllureId("776")
@DisplayName("Add custom token: derivation paths match the card version (V1/V2/V3)")
@Test
fun derivationPathsMatchCardVersionTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Assert derivation paths for a legacy-batch V1 Wallet card") {
openMainScreen(mockContent = Wallet1LegacyDerivationMockContent)
openAddCustomToken()
assertDerivationPathsInSelector(
ethereumNetwork,
bitcoinNetworkId to "m/44'/0'/0'/0/0",
ethereumClassicNetworkId to "m/44'/61'/0'/0/0",
)
forgetCurrentWalletAndReArmForNextScan()
}
step("Assert derivation paths for a V2 Wallet card") {
openMainScreen()
openAddCustomToken()
assertDerivationPathsInSelector(
ethereumNetwork,
bitcoinNetworkId to "m/44'/0'/0'/0/0",
ethereumClassicNetworkId to "m/44'/60'/0'/0/0",
)
forgetCurrentWalletAndReArmForNextScan()
}
step("Assert derivation paths for a V3 Wallet 2 card") {
openMainScreen(productType = ProductType.Wallet2)
openAddCustomToken()
assertDerivationPathsInSelector(
ethereumNetwork,
bitcoinNetworkId to "m/84'/0'/0'/0/0",
ethereumClassicNetworkId to "m/44'/61'/0'/0/0",
)
}
}
}
}

View file

@ -0,0 +1,250 @@
package com.tangem.tests.main
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.performTextInput
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.COINS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.assertClipboardIsEmpty
import com.tangem.common.utils.assertClipboardTextEquals
import com.tangem.common.utils.clearClipboard
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openManageTokens
import com.tangem.scenarios.toggleTokenNetworkInManageTokens
import com.tangem.screens.onDialog
import com.tangem.screens.onManageTokensScreen
import com.tangem.tap.domain.sdk.mocks.content.Firmware451MockContent
import com.tangem.tap.domain.sdk.mocks.content.Wallet2NoEd25519Slip0010MockContent
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.res.R as CoreResR
@HiltAndroidTest
class ManageTokensTest : BaseTestCase() {
private val richState = "ManageTokensRich"
private val solanaTokenTitle = "USD Coin"
private val solanaNetworkTitle = "SOLANA"
private val solanaName = "Solana"
@AllureId("765")
@DisplayName("Manage tokens: network standard labels are shown for token networks")
@Test
fun networkStandardLabelsTest() {
val tokenTitle = "Tether"
val ethereum = "Ethereum"
val bnbSmartChain = "BNB Smart Chain"
val tron = "Tron"
val erc20 = "ERC20"
val bep20 = "BEP20"
val trc20 = "TRC20"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Click on token: '$tokenTitle'") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
onManageTokensScreen { tokenItem(tokenTitle).performClick() }
}
step("Assert '$ethereum' network standard '$erc20' is displayed") {
flakySafely {
onManageTokensScreen { networkStandard(networkName = ethereum, standard = erc20).assertIsDisplayed() }
}
}
step("Assert '$bnbSmartChain' network standard '$bep20' is displayed") {
flakySafely {
onManageTokensScreen {
networkStandard(networkName = bnbSmartChain, standard = bep20).assertIsDisplayed()
}
}
}
step("Assert '$tron' network standard '$trc20' is displayed") {
flakySafely {
onManageTokensScreen { networkStandard(networkName = tron, standard = trc20).assertIsDisplayed() }
}
}
}
}
@AllureId("667")
@DisplayName("Manage tokens: search by name and ticker filters the list")
@Test
fun searchByNameAndTickerTest() {
val tokenTitle = "Tether"
val nameQuery = "Tether"
val tickerQuery = "USDT"
val emptyQuery = "Zzqnotoken"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Search by name: '$nameQuery'") {
onManageTokensScreen {
searchField.performClick()
searchField.performTextInput(nameQuery)
}
}
step("Assert token: '$tokenTitle' is displayed") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
}
step("Clear search field") {
onManageTokensScreen { searchClearButton.clickWithAssertion() }
}
step("Search by ticker: '$tickerQuery'") {
onManageTokensScreen { searchField.performTextInput(tickerQuery) }
}
step("Assert token: '$tokenTitle' is displayed") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
}
step("Clear search field") {
onManageTokensScreen { searchClearButton.clickWithAssertion() }
}
step("Search by unknown query: '$emptyQuery'") {
onManageTokensScreen { searchField.performTextInput(emptyQuery) }
}
step("Assert token: '$tokenTitle' is not displayed") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertDoesNotExist() } }
}
}
}
@AllureId("763")
@DisplayName("Manage tokens: enabling Solana network on a modern card shows no warning")
@Test
fun solanaNetworkNoWarningTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Enable '$solanaNetworkTitle' network for token: '$solanaTokenTitle'") {
toggleTokenNetworkInManageTokens(tokenTitle = solanaTokenTitle, networkTitle = solanaNetworkTitle)
}
step("Assert hide-token alert is not displayed") {
waitForIdle()
onDialog { dialogContainer.assertDoesNotExist() }
}
}
}
@AllureId("719")
@DisplayName("Manage tokens: long tap on a token network copies the contract address, main network does not")
@Test
fun copyContractAddressOnNetworkLongTapTest() {
val tokenTitle = "Tether"
val tokenNetworkTitle = "ETHEREUM"
val coinTitle = "Bitcoin"
val coinNetworkTitle = "BITCOIN"
val contractAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Click on token: '$tokenTitle'") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
onManageTokensScreen { tokenItem(tokenTitle).performClick() }
}
step("Long click on '$tokenNetworkTitle' network row") {
flakySafely { onManageTokensScreen { networkName(tokenNetworkTitle).assertIsDisplayed() } }
onManageTokensScreen {
networkName(tokenNetworkTitle).performTouchInput {
longClick(position = center, durationMillis = 1000L)
}
}
}
step("Assert contract-address-copied message is displayed") {
flakySafely { onManageTokensScreen { contractAddressCopiedMessage.assertIsDisplayed() } }
}
step("Assert clipboard contains '$tokenTitle' contract address") {
assertClipboardTextEquals(contractAddress)
}
step("Clear clipboard") { clearClipboard() }
step("Click on token: '$coinTitle'") {
flakySafely { onManageTokensScreen { tokenItem(coinTitle).assertIsDisplayed() } }
onManageTokensScreen { tokenItem(coinTitle).performClick() }
}
step("Long click on '$coinNetworkTitle' main network row") {
flakySafely { onManageTokensScreen { networkName(coinNetworkTitle).assertIsDisplayed() } }
onManageTokensScreen {
networkName(coinNetworkTitle).performTouchInput {
longClick(position = center, durationMillis = 1000L)
}
}
}
step("Assert clipboard is empty after long click on main network") {
waitForIdle()
assertClipboardIsEmpty()
}
}
}
@AllureId("737")
@DisplayName("Manage tokens: enabling Solana on an old-firmware card shows firmware-limitation warning")
@Test
fun solanaFirmwareLimitationWarningTest() {
val warningMessage = getResourceString(CoreResR.string.alert_manage_tokens_unsupported_message, solanaName)
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen(mockContent = Firmware451MockContent) }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Enable '$solanaNetworkTitle' network for token: '$solanaTokenTitle'") {
toggleTokenNetworkInManageTokens(tokenTitle = solanaTokenTitle, networkTitle = solanaNetworkTitle)
}
step("Assert firmware-limitation warning is displayed") {
flakySafely { onDialog { containerWithText(warningMessage).assertIsDisplayed() } }
}
}
}
@AllureId("767")
@DisplayName("Manage tokens: enabling Solana on a card missing its curve shows unsupported-curve warning")
@Test
fun solanaUnsupportedCurveWarningTest() {
val warningMessage =
getResourceString(CoreResR.string.alert_manage_tokens_unsupported_curve_message, solanaName)
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen(mockContent = Wallet2NoEd25519Slip0010MockContent) }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Enable '$solanaNetworkTitle' network for token: '$solanaTokenTitle'") {
toggleTokenNetworkInManageTokens(tokenTitle = solanaTokenTitle, networkTitle = solanaNetworkTitle)
}
step("Assert unsupported-curve warning is displayed") {
flakySafely { onDialog { containerWithText(warningMessage).assertIsDisplayed() } }
}
}
}
}

View file

@ -0,0 +1,178 @@
package com.tangem.tests.referral
import androidx.test.core.app.ApplicationProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.tapBackButton
import com.tangem.common.utils.AddressComparisonHelper
import com.tangem.common.utils.getClipboardText
import com.tangem.common.utils.resetWireMockScenarios
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openTesterMenu
import com.tangem.scenarios.referralTakeParticipate
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onTesterMenuScreen
import com.tangem.screens.onWalletSettingsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertEquals
import org.junit.Test
@HiltAndroidTest
class ReferralTest : BaseTestCase() {
@AllureId("3630")
@DisplayName("Referral program: Token and Blockchain added on Main screen after participation")
@Test
fun referralTokenAndBlockchainAddedAfterParticipationTest() {
val tokenNetwork = "Tron"
val token = "Tether"
setupHooks(
additionalAfterSection = {
resetWireMockScenarios()
}
).run {
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Verify network $tokenNetwork and token $token is not displaying") {
onMainScreen {
assertTokenDoesNotExist(tokenNetwork)
assertTokenDoesNotExist(token)
}
}
step("Take participate in Referral program") {
referralTakeParticipate()
}
step("Return to the 'Main' screen") {
tapBackButton()
onWalletSettingsScreen { screenContainer.assertIsDisplayed() }
tapBackButton()
onDetailsScreen { screenContainer.assertIsDisplayed() }
tapBackButton()
onMainScreen { screenContainer.assertIsDisplayed() }
}
step("Verify network $tokenNetwork and token $token is displayed on 'Main' screen") {
onMainScreen {
tokenWithTitleAndAddress(tokenNetwork)
tokenWithTitleAndAddress(token)
}
}
}
}
@AllureId("10098")
@DisplayName("Referral program: Token added on Main screen after participation")
@Test
fun referralTokenAddedAfterParticipationTest() {
val userWalletScenarioName = "user_tokens_api"
val userWalletState = "Tron"
val tokenNetwork = "Tron"
val token = "Tether"
setupHooks(
additionalAfterSection = {
resetWireMockScenarios()
}
).run {
step("Set wiremock scenario: $userWalletScenarioName to state $userWalletState") {
setWireMockScenarioState(scenarioName = userWalletScenarioName, state = userWalletState)
}
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Verify token $token is not displaying") {
onMainScreen { assertTokenDoesNotExist(token) }
}
step("Verify network $tokenNetwork is displaying") {
onMainScreen { tokenWithTitleAndAddress(tokenNetwork) }
}
step("Take participate in Referral program") {
referralTakeParticipate()
}
step("Return to the 'Main' screen") {
tapBackButton()
onWalletSettingsScreen { screenContainer.assertIsDisplayed() }
tapBackButton()
onDetailsScreen { screenContainer.assertIsDisplayed() }
tapBackButton()
onMainScreen { screenContainer.assertIsDisplayed() }
}
step("Verify $token is displayed on 'Main' screen") {
onMainScreen { tokenWithTitleAndAddress(token) }
}
}
}
@AllureId("3629")
@DisplayName("Referral program: Participating unavailable for No-Wallet cards")
@Test
fun referralUnavailableForNoWalletCardsTest() {
setupHooks().run {
step("Open 'Main' screen") {
openMainScreen(productType = ProductType.Note)
}
step("Open 'Details' screen") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.clickWithAssertion() }
}
step("Verify 'Referral program' button does not displaying") {
onWalletSettingsScreen { referralProgramButton.assertDoesNotExist() }
}
}
}
@AllureId("3636")
@DisplayName("Referral program: Verify wallet derivation")
@Test
fun referralVerifyWalletDerivationTest() {
val tokenNetwork = "Tron"
val expectedDerivationPath = "m/44'/195'/0'/0/0"
setupHooks(
additionalAfterSection = {
resetWireMockScenarios()
}
).run {
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Take participate in Referral program") {
referralTakeParticipate()
}
step("Open Debug menu") {
openTesterMenu()
}
step("Open 'Addresses info' screen") {
onTesterMenuScreen { addressesInfoButton.clickWithAssertion() }
}
step("Verify $tokenNetwork derivation path is '$expectedDerivationPath'") {
onTesterMenuScreen { jsonTab.clickWithAssertion() }
onTesterMenuScreen { copyButton.clickWithAssertion() }
val addressesJson = getClipboardText(ApplicationProvider.getApplicationContext())
?: error("Clipboard is empty after copying addresses")
val actualDerivationPath =
AddressComparisonHelper.derivationPathForBlockchain(addressesJson, tokenNetwork)
assertEquals(expectedDerivationPath, actualDerivationPath)
}
}
}
}

View file

@ -132,9 +132,9 @@ class GaslessSendTest : BaseTestCase() {
@DisplayName("Gasless: completed gasless transaction is shown in token transaction history")
@Test
fun checkGaslessTransactionInHistoryTest() {
val sentAmount = "1.00"
val operationAmount = "1.00"
val gaslessFeeAmount = "0.10"
val sentTitle = getResourceString(R.string.common_sent)
val operationTitle = getResourceString(R.string.transaction_history_operation)
val gaslessFeeTitle = getResourceString(R.string.gasless_transaction_fee)
setupHooks(
@ -166,13 +166,13 @@ class GaslessSendTest : BaseTestCase() {
onTxHistoryScreen { transactionItem(gaslessFeeTitle).assertIsDisplayed() }
}
}
step("Assert '$sentTitle' transaction is displayed") {
onTxHistoryScreen { transactionItem(sentTitle).assertIsDisplayed() }
step("Assert '$operationTitle' transaction is displayed") {
onTxHistoryScreen { transactionItem(operationTitle).assertIsDisplayed() }
}
step("Assert '$sentTitle' amount '$sentAmount' is displayed in '$currencySymbol'") {
step("Assert '$operationTitle' amount '$operationAmount' is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(sentTitle).assertTextContains(sentAmount, substring = true)
transactionCurrency(sentTitle).assertTextEquals(currencySymbol)
transactionAmount(operationTitle).assertTextContains(operationAmount, substring = true)
transactionCurrency(operationTitle).assertTextEquals(currencySymbol)
}
}
step("Assert gasless '$gaslessFeeTitle' amount '$gaslessFeeAmount' is displayed in '$currencySymbol'") {

View file

@ -0,0 +1,123 @@
package com.tangem.tests.send.reasonBlock
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet
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
@HiltAndroidTest
class ReasonBlockTest : BaseTestCase() {
@AllureId("3616")
@DisplayName("Reason block: Send is unavailable if user has pending transaction")
@Test
fun reasonBlockSendUnavailableWithPendingTransactionTest() {
val txHistoryScenarioName = "dogecoin_tx_history"
val txHistoryState = "EmptyWithPendingTransaction"
val walletsScenarioName = "user_tokens_api"
val walletsState = "Dogecoin"
val token = "Dogecoin"
val reasonText = getResourceString(R.string.token_button_unavailability_reason_pending_transaction_send)
.substringBefore("%s")
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(txHistoryScenarioName)
resetWireMockScenarioState(walletsScenarioName)
}
).run {
step("Set Wiremock scenario: $txHistoryScenarioName to state $txHistoryState") {
setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryState)
}
step("Set Wiremock scenario: $walletsScenarioName to state $walletsState") {
setWireMockScenarioState(scenarioName = walletsScenarioName, state = walletsState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name $token") {
onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Verify 'Send' button is disabled") {
onTransferBottomSheet { sendButton.assertIsNotEnabled() }
}
step("Click on 'Send' button") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Assert pending-transaction reason dialog is displayed") {
onDialog { containerWithText(reasonText).assertIsDisplayed() }
}
}
}
@AllureId("3615")
@DisplayName("Reason block: Token withdrawal is unavailable if there are no fee coverage")
@Test
fun reasonBlockTokenWithdrawalUnavailableWithoutFeeCoverage() {
val userWalletsScenarioName = "user_tokens_api"
val userWalletsState = "SolanaUSDC"
val solBalanceScenarioName = "solana_get_account_info_recipient"
val solBalanceState = "ZeroBalance"
val token = "USDC"
val feeCurrencyName = "Solana"
val feeCurrencySymbol = "SOL"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(userWalletsScenarioName)
resetWireMockScenarioState(solBalanceScenarioName)
}
).run {
step("Set Wiremock scenario: $userWalletsScenarioName to state $userWalletsState") {
setWireMockScenarioState(scenarioName = userWalletsScenarioName, state = userWalletsState)
}
step("Set Wiremock scenario: $solBalanceScenarioName to state $solBalanceState") {
setWireMockScenarioState(scenarioName = solBalanceScenarioName, state = solBalanceState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name $token") {
onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() }
}
step("Assert 'Insufficient $feeCurrencySymbol for fee' notification is displayed") {
onTokenDetailsScreen {
networkFeeNotificationTitle(feeCurrencyName).assertIsDisplayed()
networkFeeNotificationMessage(
currencyName = token,
networkName = feeCurrencyName,
feeCurrencyName = feeCurrencyName,
feeCurrencySymbol = feeCurrencySymbol,
).assertIsDisplayed()
}
}
step("Click on 'Go to $feeCurrencySymbol' button") {
onTokenDetailsScreen { goToBuyCurrencyButton(feeCurrencySymbol).clickWithAssertion() }
}
step("Assert $feeCurrencyName token screen is opened") {
onTokenDetailsScreen { tokenTitle(feeCurrencyName).assertIsDisplayed() }
}
}
}
}

View file

@ -313,6 +313,16 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="onboard-virtual-account"
android:scheme="tangem" />
</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="news"
android:scheme="tangem" />

View file

@ -34,18 +34,11 @@ class CustomerIoAnalyticsHandler(
class Builder : AnalyticsHandlerBuilder {
override fun build(data: AnalyticsHandlerBuilder.Data): AnalyticsHandler? {
val cdpApiKey = data.config.customerIoCdpApiKey
return if (data.logConfig.isCustomerIoLogEnabled) {
CustomerIoAnalyticsHandler(client = CustomerIoLogClient())
} else if (!cdpApiKey.isNullOrBlank()) {
CustomerIoAnalyticsHandler(
client = CustomerIoClient(
application = data.application,
cdpApiKey = cdpApiKey,
),
)
} else {
null
}
if (cdpApiKey.isNullOrBlank()) return null
return CustomerIoAnalyticsHandler(
client = CustomerIoClient(application = data.application, cdpApiKey = cdpApiKey),
)
}
}
}

View file

@ -1,23 +0,0 @@
package com.tangem.tap.common.analytics.handlers.customerio
import com.tangem.utils.logging.TangemLogger
/**
* Log client for Customer.io (used in debug mode).
*
* Logs all operations to Timber instead of sending them to Customer.io.
*/
internal class CustomerIoLogClient : CustomerIoAnalyticsClient {
private var userId: String? = null
override fun setUserId(userId: String) {
this.userId = userId
TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("identify: userId=$userId")
}
override fun clearUserId() {
TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("clearIdentify: previous userId=$userId")
this.userId = null
}
}

View file

@ -1,13 +1,17 @@
package com.tangem.tap.data
import android.content.Context
import android.os.Build
import com.tangem.utils.info.AppInfoProvider
import com.tangem.wallet.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import java.util.TimeZone
import javax.inject.Inject
internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider {
internal class DefaultAppInfoProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : AppInfoProvider {
override val platform: String
get() = "Android"
override val device: String
@ -18,6 +22,8 @@ internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider {
get() = Build.VERSION.SDK_INT
override val language: String
get() = Locale.getDefault().toLanguageTag()
override val deviceScale: Float
get() = context.resources.displayMetrics.density
override val timezone: String
get() = TimeZone.getDefault().id
override val appVersion: String = BuildConfig.VERSION_NAME

View file

@ -10,6 +10,7 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import dagger.Module
@ -31,6 +32,7 @@ internal class TangemSdkManagerModule {
visaCardScanHandler: VisaCardScanHandler,
visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository,
@ -44,6 +46,7 @@ internal class TangemSdkManagerModule {
visaCardScanHandler = visaCardScanHandler,
visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
tangemPayVirtualAccountTaskFactory = tangemPayVirtualAccountTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository,

View file

@ -7,6 +7,8 @@ import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.AppStoreOpener
import com.tangem.core.navigation.url.DefaultAppStoreOpener
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.tap.common.finisher.AndroidAppFinisher
@ -35,6 +37,10 @@ internal interface UtilsModule {
@Singleton
fun bindAppInfoProvider(impl: DefaultAppInfoProvider): AppInfoProvider
@Binds
@Singleton
fun bindAppStoreOpener(impl: DefaultAppStoreOpener): AppStoreOpener
companion object {
@Provides

View file

@ -1,11 +1,21 @@
package com.tangem.tap.di.domain
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
import dagger.Module
@ -32,10 +42,68 @@ object AddressBookDomainModule {
@Provides
@Singleton
fun provideVerifyAddressEntriesUseCase(
fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase {
return ValidateContactNameUseCase(repository = repository)
}
@Provides
@Singleton
fun provideGetContactsUseCase(repository: AddressBookRepository): GetContactsUseCase {
return GetContactsUseCase(repository = repository)
}
@Provides
@Singleton
fun provideGetVerifiedContactsInteractor(
getContactsUseCase: GetContactsUseCase,
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
): VerifyAddressEntriesUseCase {
return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase)
userWalletsListRepository: UserWalletsListRepository,
): GetVerifiedContactsInteractor {
return GetVerifiedContactsInteractor(
getContacts = getContactsUseCase,
verifyMessages = verifyMessagesUseCase,
userWalletsListRepository = userWalletsListRepository,
)
}
@Provides
@Singleton
fun provideSaveContactInteractor(
repository: AddressBookRepository,
validateContactNameUseCase: ValidateContactNameUseCase,
signUseCase: SignUseCase,
timestampProvider: IsoTimestampProvider,
): SaveContactInteractor {
return SaveContactInteractor(
repository = repository,
validateContactName = validateContactNameUseCase,
signUseCase = signUseCase,
timestampProvider = timestampProvider,
)
}
@Provides
@Singleton
fun provideDeleteContactUseCase(repository: AddressBookRepository): DeleteContactUseCase {
return DeleteContactUseCase(repository = repository)
}
@Provides
@Singleton
fun provideGetContactByIdUseCase(repository: AddressBookRepository): GetContactByIdUseCase {
return GetContactByIdUseCase(repository = repository)
}
@Provides
@Singleton
fun provideCheckAddressDuplicateUseCase(repository: AddressBookRepository): CheckAddressDuplicateUseCase {
return CheckAddressDuplicateUseCase(repository = repository)
}
@Provides
@Singleton
fun provideSyncAddressBooksUseCase(repository: AddressBookRepository): SyncAddressBooksUseCase {
return SyncAddressBooksUseCase(repository = repository)
}
@Provides

View file

@ -56,6 +56,14 @@ internal object ManageTokensDomainModule {
return ValidateDerivationPathUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckDerivationPathSupportedUseCase(
customTokensRepository: CustomTokensRepository,
): CheckDerivationPathSupportedUseCase {
return CheckDerivationPathSupportedUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckCurrencyUnsupportedUseCase(repository: ManageTokensRepository): CheckCurrencyUnsupportedUseCase {

View file

@ -4,13 +4,11 @@ import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.domain.swap.usecase.*
import com.tangem.feature.swap.domain.GetAvailablePairsUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import com.tangem.feature.swap.domain.api.SwapRepository as OldSwapRepository
/**
[REDACTED_AUTHOR]
@ -19,12 +17,6 @@ import com.tangem.feature.swap.domain.api.SwapRepository as OldSwapRepository
@InstallIn(SingletonComponent::class)
internal object SwapDomainModule {
@Provides
@Singleton
fun provideGetAvailablePairsUseCase(swapRepository: OldSwapRepository): GetAvailablePairsUseCase {
return GetAvailablePairsUseCase(swapRepository = swapRepository)
}
@Provides
@Singleton
fun provideGetSwapSupportedPairsUseCase(

View file

@ -6,9 +6,13 @@ import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.notifications.repository.PushNotificationsRepository
@ -319,30 +323,50 @@ internal object TransactionDomainModule {
gaslessTransactionRepository: GaslessTransactionRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
featureTogglesManager: FeatureTogglesManager,
): GetAvailableFeeTokensUseCase {
return GetAvailableFeeTokensUseCase(
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
gaslessTransactionRepository = gaslessTransactionRepository,
currencyChecksRepository = currencyChecksRepository,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@Provides
@Singleton
fun provideResolveGaslessFeePlanUseCase(
gaslessYieldRepository: GaslessYieldRepository,
): ResolveGaslessFeePlanUseCase {
return ResolveGaslessFeePlanUseCase(gaslessYieldRepository = gaslessYieldRepository)
}
@Provides
@Singleton
fun provideGetFeeForGaslessUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
getFeeUseCase: GetFeeUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
featureTogglesManager: FeatureTogglesManager,
): GetFeeForGaslessUseCase {
return GetFeeForGaslessUseCase(
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
getFeeUseCase = getFeeUseCase,
currencyChecksRepository = currencyChecksRepository,
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@ -351,15 +375,23 @@ internal object TransactionDomainModule {
fun provideGetFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
featureTogglesManager: FeatureTogglesManager,
): GetFeeForTokenUseCase {
return GetFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository,
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@ -379,6 +411,7 @@ internal object TransactionDomainModule {
singleAccountListSupplier: SingleAccountListSupplier,
cardSdkConfigRepository: CardSdkConfigRepository,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
featureTogglesManager: FeatureTogglesManager,
): CreateAndSendGaslessTransactionUseCase {
return CreateAndSendGaslessTransactionUseCase(
walletManagersFacade = walletManagersFacade,
@ -386,6 +419,9 @@ internal object TransactionDomainModule {
gaslessTransactionRepository = gaslessTransactionRepository,
cardSdkConfigRepository = cardSdkConfigRepository,
getHotWalletSigner = tangemHotWalletSignerFactory::create,
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@ -394,15 +430,21 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
featureTogglesManager: FeatureTogglesManager,
): EstimateFeeForTokenUseCase {
return EstimateFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@ -411,12 +453,14 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForGaslessTxUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
estimateFeeUseCase: EstimateFeeUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForGaslessTxUseCase {
return EstimateFeeForGaslessTxUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.transaction.WalletAddressServiceRepository
@ -25,6 +26,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSy
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.tap.domain.DefaultUserWalletSelectedHandler
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -188,8 +190,16 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun providesDeleteWalletUseCase(userWalletsListRepository: UserWalletsListRepository): DeleteWalletUseCase {
return DeleteWalletUseCase(userWalletsListRepository = userWalletsListRepository)
fun providesDeleteWalletUseCase(
userWalletsListRepository: UserWalletsListRepository,
userWalletDataCleaners: Set<@JvmSuppressWildcards UserWalletDataCleaner>,
appCoroutineScope: AppCoroutineScope,
): DeleteWalletUseCase {
return DeleteWalletUseCase(
userWalletsListRepository = userWalletsListRepository,
userWalletDataCleaners = userWalletDataCleaners,
appCoroutineScope = appCoroutineScope,
)
}
@Provides

View file

@ -50,6 +50,7 @@ import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor
import com.tangem.tap.domain.tasks.product.*
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask
@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager(
private val visaCardScanHandler: VisaCardScanHandler,
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val analyticsErrorHandler: AnalyticsErrorHandler,
private val cardRepository: CardRepository,
@ -86,7 +88,7 @@ internal class DefaultTangemSdkManager(
secureStorage = tangemSdk.secureStorage,
)
}
override val needEnrollBiometrics: Boolean
override val isEnrollBiometricsNeeded: Boolean
get() {
val isNeedEnrollBiometrics = tangemSdk.authenticationManager.needEnrollBiometrics
if (isNeedEnrollBiometrics) {
@ -102,7 +104,7 @@ internal class DefaultTangemSdkManager(
override val canUseBiometry: Boolean
get() {
val isCanUseBiometry = tangemSdk.authenticationManager.canAuthenticate || needEnrollBiometrics
val isCanUseBiometry = tangemSdk.authenticationManager.canAuthenticate || isEnrollBiometricsNeeded
if (!isCanUseBiometry) {
analyticsErrorHandler.sendErrorEvent(
AnalyticsEvent(
@ -124,7 +126,7 @@ internal class DefaultTangemSdkManager(
get() = tangemSdk.config.userCodeRequestPolicy
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean {
return needEnrollBiometrics
return isEnrollBiometricsNeeded
}
override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean {
@ -531,6 +533,24 @@ internal class DefaultTangemSdkManager(
}
}
override suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData> {
return coroutineScope {
val result = runTaskAsyncReturnOnMain(
runnable = tangemPayVirtualAccountTaskFactory.create(coroutineScope = this),
cardId = null,
initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)),
preflightReadFilter = preflightReadFilter,
)
return@coroutineScope when (result) {
is CompletionResult.Failure<*> -> result.error.left()
is CompletionResult.Success<VirtualAccountActivationData> -> result.data.right()
}
}
}
override suspend fun getWithdrawalSignature(
hash: String,
preflightReadFilter: PreflightReadFilter,

View file

@ -23,6 +23,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
@ -46,7 +47,7 @@ class MockTangemSdkManager(
override val canUseBiometry: Boolean = false
override val needEnrollBiometrics: Boolean = false
override val isEnrollBiometricsNeeded: Boolean = false
override val keystoreManager = DummyKeystoreManager()
@ -57,7 +58,7 @@ class MockTangemSdkManager(
override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean = canUseBiometry
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean = needEnrollBiometrics
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean = isEnrollBiometricsNeeded
override suspend fun scanProduct(
cardId: String?,
@ -241,6 +242,12 @@ class MockTangemSdkManager(
error("Not implemented")
}
override suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData> {
error("Not implemented")
}
override suspend fun getWithdrawalSignature(
hash: String,
preflightReadFilter: PreflightReadFilter,

View file

@ -33,6 +33,9 @@ object MockProvider {
MockOption("Shiba (No Backup, No Wallets)") { ShibaNoBackupNoWalletsMockContent },
MockOption("Ed25519 Curve") { EdCurveMockContent },
MockOption("Secp256k1 Curve") { Secpk1CurveMockContent },
MockOption("Wallet 2 (No ed25519_slip0010)") { Wallet2NoEd25519Slip0010MockContent },
MockOption("Wallet 1 (Legacy derivation)") { Wallet1LegacyDerivationMockContent },
MockOption("Firmware 4.51") { Firmware451MockContent },
MockOption("Backup Wallet") { BackupWalletMockContent },
MockOption("Dev Wallet") { DevWalletMockContent },
MockOption("Firmware 4.12") { Firmware412MockContent },

View file

@ -0,0 +1,15 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
// Firmware 4.51: HD-capable (>= 4.39) but below SolanaTokensAvailable (4.52), so Solana tokens are firmware-limited.
object Firmware451MockContent : MockContent by WalletMockContent {
override val cardDto: CardDTO = WalletMockContent.cardDto.copy(
firmwareVersion = WalletMockContent.cardDto.firmwareVersion.copy(minor = 51),
)
override val scanResponse: ScanResponse = WalletMockContent.scanResponse.copy(card = cardDto)
}

View file

@ -0,0 +1,13 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
// Wallet1 on a first-batch id (AC01) — resolves to the V1 (legacy) derivation style.
object Wallet1LegacyDerivationMockContent : MockContent by WalletMockContent {
override val cardDto: CardDTO = WalletMockContent.cardDto.copy(batchId = "AC01")
override val scanResponse: ScanResponse = WalletMockContent.scanResponse.copy(card = cardDto)
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
// Wallet2 without its ed25519_slip0010 wallet, so adding Solana warns UnsupportedCurve (no wallet for its curve).
object Wallet2NoEd25519Slip0010MockContent : MockContent by Wallet2WithSeedPhraseMockContent {
override val cardDto: CardDTO = Wallet2WithSeedPhraseMockContent.cardDto.copy(
wallets = Wallet2WithSeedPhraseMockContent.cardDto.wallets.filterNot {
it.curve == EllipticCurve.Ed25519Slip0010
},
)
override val scanResponse: ScanResponse = Wallet2WithSeedPhraseMockContent.scanResponse.copy(card = cardDto)
}

View file

@ -0,0 +1,80 @@
package com.tangem.tap.domain.tasks.visa
import com.tangem.common.CompletionResult
import com.tangem.common.card.CardWallet
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.toMapKey
import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.operations.derivation.DeriveWalletPublicKeyTask
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Derives the Virtual Account key ([VisaUtilities.virtualAccountDerivationPath]) on the card and
* generates its deposit address. The derived key is returned (keyed by the seed wallet public key)
* so the caller can persist it via `DerivationsRepository.storeDerivedKeys` no second tap needed.
*/
class TangemPayGenerateVirtualAccountAddressTask @AssistedInject constructor(
@Assisted private val coroutineScope: CoroutineScope,
) : CardSessionRunnable<VirtualAccountActivationData> {
override fun run(session: CardSession, callback: CompletionCallback<VirtualAccountActivationData>) {
coroutineScope.launch {
callback(runSuspend(session = session))
}
}
private suspend fun runSuspend(session: CardSession): CompletionResult<VirtualAccountActivationData> {
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet = card.wallets.firstOrNull { it.curve == VisaUtilities.curve }
?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError)
val extendedPublicKey = when (val derivationResult = runDerivationTask(session, wallet)) {
is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error)
is CompletionResult.Success<ExtendedPublicKey> -> derivationResult.data
}
val address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey = extendedPublicKey)
val derivedKeys = mapOf(
wallet.publicKey.toMapKey() to ExtendedPublicKeysMap(
mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey),
),
)
return CompletionResult.Success(
data = VirtualAccountActivationData(address = address, derivedKeys = derivedKeys),
)
}
private suspend fun runDerivationTask(
session: CardSession,
wallet: CardWallet,
): CompletionResult<ExtendedPublicKey> {
val deferred = CompletableDeferred<CompletionResult<ExtendedPublicKey>>()
val derivationTask = DeriveWalletPublicKeyTask(
walletPublicKey = wallet.publicKey,
derivationPath = VisaUtilities.virtualAccountDerivationPath,
)
derivationTask.run(session = session, callback = deferred::complete)
return deferred.await()
}
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): TangemPayGenerateVirtualAccountAddressTask
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.tap.domain.userWalletList.di
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
@Module
@InstallIn(SingletonComponent::class)
internal interface UserWalletDataCleanerModule {
@Multibinds
fun userWalletDataCleaners(): Set<UserWalletDataCleaner>
}

View file

@ -628,5 +628,6 @@ internal class DefaultUserWalletsListRepository(
private suspend fun onAllWalletsDeleted() {
// reset the referral attribution (set from AF deeplink) after removing the last wallet
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) }
}
}

View file

@ -31,6 +31,7 @@ internal fun AppSettingsScreen(state: AppSettingsScreenState, onBackClick: () ->
modifier = modifier,
titleRes = R.string.app_settings_title,
addBottomInsets = false,
backButtonTestTag = AppSettingsScreenTestTags.BACK_BUTTON,
content = {
when (state) {
is AppSettingsScreenState.Content -> AppSettings(state = state)

View file

@ -115,7 +115,7 @@ internal class AppSettingsModel @Inject constructor(
private fun observeBiometricsStatusChanges() {
flow {
do {
val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull()
val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::isEnrollBiometricsNeeded).getOrNull()
if (isEnrollBiometricsNeeded != null) {
emit(isEnrollBiometricsNeeded)
}
@ -366,7 +366,7 @@ internal class AppSettingsModel @Inject constructor(
localState.update { state ->
state.copy(
hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(),
isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true,
isEnrollBiometricsNeeded = runCatching(tangemSdkManager::isEnrollBiometricsNeeded).getOrNull() == true,
isBiometricAuthenticationUsed = walletsRepository.useBiometricAuthentication(),
isAccessCodeRequired = walletsRepository.requireAccessCode(),
)

View file

@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButtonIconEnd
@ -23,6 +24,7 @@ internal fun SettingsScreensScaffold(
@StringRes titleRes: Int? = null,
addBottomInsets: Boolean = true,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
backButtonTestTag: String? = null,
content: @Composable () -> Unit,
fab: @Composable () -> Unit = {},
) {
@ -35,6 +37,7 @@ internal fun SettingsScreensScaffold(
modifier = Modifier.statusBarsPadding(),
onBackClick = onBackClick,
backgroundColor = backgroundColor,
backButtonTestTag = backButtonTestTag,
)
},
modifier = modifier,
@ -91,13 +94,17 @@ internal fun EmptyTopBarWithNavigation(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color = TangemTheme.colors.background.primary,
backButtonTestTag: String? = null,
) {
TopAppBar(
modifier = modifier,
title = { },
navigationIcon =
{
IconButton(onClick = onBackClick) {
IconButton(
onClick = onBackClick,
modifier = if (backButtonTestTag != null) Modifier.testTag(backButtonTestTag) else Modifier,
) {
Icon(
painter = painterResource(id = R.drawable.ic_back_24),
contentDescription = null,

View file

@ -0,0 +1,19 @@
package com.tangem.tap.features.root
import kotlinx.coroutines.channels.Channel
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class DefaultRootWarningContinuation @Inject constructor() : RootWarningContinuation {
private val dismissals = Channel<Unit>(capacity = Channel.CONFLATED)
override suspend fun awaitDismiss() {
dismissals.receive()
}
override fun dismiss() {
dismissals.trySend(Unit)
}
}

View file

@ -1,68 +1,40 @@
package com.tangem.tap.features.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.essenty.instancekeeper.getOrCreateSimple
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.DialogFullScreen
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Full-screen root-detected security warning. Presentational only its visibility is controlled by the
* startup gate (via a childSlot); "Continue" resolves [RootWarningContinuation]. Whether it should be shown
* at all (and marking it as shown) is decided by the gate.
*/
@Suppress("UnusedPrivateProperty")
class RootDetectedWarningComponent @AssistedInject constructor(
internal class RootDetectedWarningComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
private val securityInfoProvider: DeviceSecurityInfoProvider,
private val settingsRepository: SettingsRepository,
private val rootWarningContinuation: RootWarningContinuation,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val isShown = instanceKeeper.getOrCreateSimple { MutableStateFlow(false) }
suspend fun shouldShowWarning(): Boolean {
return settingsRepository.isRootDetectedWarningShown().not() && securityInfoProvider.isSecurityExposed()
}
suspend fun tryToShowWarningAndWaitContinuation() {
if (isShown.value) return
if (shouldShowWarning()) {
isShown.value = true
}
isShown.first { it == false } // Wait until the warning is dismissed
}
@Composable
override fun Content(modifier: Modifier) {
val isShownState by isShown.collectAsStateWithLifecycle()
if (isShownState) {
DialogFullScreen(onDismissRequest = {}) {
RootDetectedWarningContent(
modifier = modifier,
onContinueClick = remember(this) { ::onContinueClick },
)
}
DialogFullScreen(onDismissRequest = {}) {
RootDetectedWarningContent(
modifier = modifier,
onContinueClick = remember(this) { ::onContinueClick },
)
}
}
private fun onContinueClick() {
componentScope.launch {
settingsRepository.setRootDetectedWarningShown(true)
isShown.value = false
}
rootWarningContinuation.dismiss()
}
@AssistedFactory

View file

@ -0,0 +1,13 @@
package com.tangem.tap.features.root
/**
* Resumes the startup gate after the root-detected security warning is dismissed.
*
* The gate awaits [awaitDismiss] while the warning is shown, and the screen calls [dismiss] on "Continue".
*/
interface RootWarningContinuation {
suspend fun awaitDismiss()
fun dismiss()
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.features.root.di
import com.tangem.tap.features.root.DefaultRootWarningContinuation
import com.tangem.tap.features.root.RootWarningContinuation
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface RootModule {
@Binds
@Singleton
fun bindRootWarningContinuation(impl: DefaultRootWarningContinuation): RootWarningContinuation
}

View file

@ -43,7 +43,7 @@ internal fun RootContent(
modifier: Modifier = Modifier,
wcContent: @Composable (modifier: Modifier) -> Unit,
hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit,
rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit,
startupGateContent: @Composable (modifier: Modifier) -> Unit,
scanFailsContent: @Composable (modifier: Modifier) -> Unit,
) {
val context = LocalContext.current
@ -82,7 +82,7 @@ internal fun RootContent(
hotAccessCodeContent(Modifier.fillMaxSize())
rootDetectedWarningContent(Modifier.fillMaxSize())
startupGateContent(Modifier.fillMaxSize())
scanFailsContent(Modifier.fillMaxSize())

View file

@ -54,13 +54,13 @@ import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.tap.common.SnackbarHandler
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerReferralParamsHandler
import com.tangem.tap.features.hot.TangemHotSDKProxy
import com.tangem.tap.features.root.RootDetectedWarningComponent
import com.tangem.tap.features.scanfails.ScanFailsComponent
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
import com.tangem.tap.routing.RootContent
import com.tangem.tap.routing.component.RoutingComponent
import com.tangem.tap.routing.component.RoutingComponent.Child
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.tap.routing.startup.AppStartupGateComponent
import com.tangem.tap.routing.utils.ChildFactory
import com.tangem.tap.routing.utils.DeepLinkFactory
import com.tangem.utils.logging.TangemLogger
@ -87,7 +87,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val tangemHotSDKProxy: TangemHotSDKProxy,
private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory,
private val hotAccessCodeRequesterProxy: HotWalletPasswordRequesterProxy,
private val rootDetectedWarningComponentFactory: RootDetectedWarningComponent.Factory,
private val appStartupGateComponentFactory: AppStartupGateComponent.Factory,
private val userWalletsListRepository: UserWalletsListRepository,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
@ -117,9 +117,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
.create(child("hotAccessCodeRequestComponent"), Unit)
}
private val rootDetectedWarningComponent: RootDetectedWarningComponent by lazy {
rootDetectedWarningComponentFactory
.create(child("rootDetectedWarningComponent"), Unit)
private val appStartupGateComponent: AppStartupGateComponent by lazy {
appStartupGateComponentFactory.create(child("appStartupGate"))
}
private val scanFailsComponent: ScanFailsComponent by lazy {
@ -175,40 +174,37 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private fun initializeInitialNavigation() {
if (initialStack.isNullOrEmpty()) {
componentScope.launch {
val initialRoute = resolveInitialRoute()
if (rootDetectedWarningComponent.shouldShowWarning()) {
launch(dispatchers.main) {
rootDetectedWarningComponent.tryToShowWarningAndWaitContinuation()
router.replaceAll(initialRoute)
}
} else {
router.replaceAll(initialRoute)
}
}
componentScope.launch { resolveAndNavigate() }
}
}
private suspend fun resolveInitialRoute(): AppRoute {
private suspend fun resolveAndNavigate() {
appStartupGateComponent.await()
navigateToStartRoute()
}
private suspend fun navigateToStartRoute() {
val initialRoute = resolveStartRoute()
onInitialRouteResolved(initialRoute)
router.replaceAll(initialRoute)
}
private suspend fun resolveStartRoute(): AppRoute {
val userWallets = userWalletsListRepository.userWalletsSync()
return when {
userWallets.isEmpty() -> navigateForEmptyWallets()
userWallets.any { it.isLocked } -> {
AppRoute.Welcome(
launchMode = launchMode,
)
}
else -> {
trackSignInEvent()
AppRoute.Wallet
}
}.also {
appRouterConfig.initializedState.value = true
checkForUnfinishedBackup()
userWallets.any { it.isLocked } -> AppRoute.Welcome(launchMode = launchMode)
else -> AppRoute.Wallet
}
}
private suspend fun onInitialRouteResolved(route: AppRoute) {
appRouterConfig.initializedState.value = true
if (route is AppRoute.Wallet) trackSignInEvent()
checkForUnfinishedBackup()
}
private suspend fun navigateForEmptyWallets(): AppRoute {
val afterEmptyRoute = resolveAppsFlyerOnboardingRoute()
?: AppRoute.Home(launchMode = launchMode)
@ -279,7 +275,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
modifier = modifier,
wcContent = { wcRoutingComponent.Content(it) },
hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) },
rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) },
startupGateContent = { appStartupGateComponent.Content(it) },
scanFailsContent = { scanFailsComponent.Content(it) },
)
}

View file

@ -0,0 +1,141 @@
package com.tangem.tap.routing.startup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.ChildSlot
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.decompose.value.Value
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.features.forceupdate.ForceUpdateComponent
import com.tangem.features.forceupdate.ForceUpdateContinuation
import com.tangem.features.forceupdate.ForceUpdateFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.tap.features.root.RootDetectedWarningComponent
import com.tangem.tap.features.root.RootWarningContinuation
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
/**
* Owns the pre-start gates shown before the regular startup navigation the force-update screen and the
* root-detected security warning as interchangeable full-screen overlays in a single [childSlot].
* [await] runs them in order and returns when the app may proceed, so the routing component stays agnostic.
*/
@Suppress("LongParameterList")
internal class AppStartupGateComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
private val getAppUpdateStateUseCase: GetAppUpdateStateUseCase,
private val forceUpdateFeatureToggles: ForceUpdateFeatureToggles,
private val forceUpdateContinuation: ForceUpdateContinuation,
private val forceUpdateComponentFactory: ForceUpdateComponent.Factory,
private val rootDetectedWarningComponentFactory: RootDetectedWarningComponent.Factory,
private val rootWarningContinuation: RootWarningContinuation,
private val settingsRepository: SettingsRepository,
private val securityInfoProvider: DeviceSecurityInfoProvider,
private val appRouterConfig: AppRouterConfig,
) : AppComponentContext by context, ComposableContentComponent {
private val slotNavigation = SlotNavigation<GateConfig>()
private val slot: Value<ChildSlot<GateConfig, ComposableContentComponent>> = childSlot(
source = slotNavigation,
serializer = null,
handleBackButton = false,
childFactory = { config, childContext ->
when (config) {
is GateConfig.ForceUpdate -> forceUpdateComponentFactory.create(
context = childByContext(childContext),
params = ForceUpdateComponent.Params(mode = config.mode),
)
GateConfig.RootWarning -> rootDetectedWarningComponentFactory.create(
context = childByContext(childContext),
params = Unit,
)
}
},
)
/** Runs the pre-start gates in order; returns when the app may proceed to normal startup. */
suspend fun await() {
awaitForceUpdate()
awaitRootWarning()
}
private suspend fun awaitForceUpdate() {
val mode = runSuspendCatching { resolveForceUpdateMode() }
.onFailure { error -> TangemLogger.e("App update check failed, proceeding with normal startup", error) }
.getOrNull()
?: return
showGate(GateConfig.ForceUpdate(mode))
forceUpdateContinuation.awaitDismiss()
slotNavigation.dismiss()
}
private suspend fun awaitRootWarning() {
if (settingsRepository.isRootDetectedWarningShown() || !securityInfoProvider.isSecurityExposed()) return
showGate(GateConfig.RootWarning)
rootWarningContinuation.awaitDismiss()
settingsRepository.setRootDetectedWarningShown(true)
slotNavigation.dismiss()
}
private fun showGate(config: GateConfig) {
// The gate overlay is drawn on top of the splash, so mark navigation initialized to dismiss the splash.
appRouterConfig.initializedState.value = true
slotNavigation.activate(config)
}
private suspend fun resolveForceUpdateMode(): ForceUpdateComponent.Mode? {
if (!forceUpdateFeatureToggles.isForceUpdateEnabled) return null
val mode = getAppUpdateStateUseCase.getCached().toForceUpdateModeOrNull()
// The force-update screen re-checks on open, so a one-shot refresh is only needed when no screen is shown.
if (mode == null) {
componentScope.launch { getAppUpdateStateUseCase.refresh() }
}
return mode
}
@Composable
override fun Content(modifier: Modifier) {
val child by slot.subscribeAsState()
child.child?.instance?.Content(modifier)
}
private fun AppUpdateState.toForceUpdateModeOrNull(): ForceUpdateComponent.Mode? = when (this) {
AppUpdateState.ForceUpdate -> ForceUpdateComponent.Mode.Force
AppUpdateState.Brick -> ForceUpdateComponent.Mode.Brick
AppUpdateState.OsTooOld -> ForceUpdateComponent.Mode.OsTooOld
AppUpdateState.OptionalUpdate -> ForceUpdateComponent.Mode.Optional
AppUpdateState.NoUpdate -> null
}
private sealed interface GateConfig {
data class ForceUpdate(val mode: ForceUpdateComponent.Mode) : GateConfig
data object RootWarning : GateConfig
}
@AssistedFactory
interface Factory {
fun create(context: AppComponentContext): AppStartupGateComponent
}
}

View file

@ -21,7 +21,6 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.*
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.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
@ -37,12 +36,14 @@ import com.tangem.features.send.api.NFTSendComponent
import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.SendEntryPointComponent
import com.tangem.features.staking.api.StakingComponent
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.swap.SwapComponent
import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent
import com.tangem.features.wallet.WalletEntryComponent
import com.tangem.features.walletconnect.components.WalletConnectEntryComponent
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
@ -71,7 +72,6 @@ internal class ChildFactory @Inject constructor(
private val onrampSuccessComponentFactory: OnrampSuccessComponent.Factory,
private val buyCryptoComponentFactory: BuyCryptoComponent.Factory,
private val sellCryptoComponentFactory: SellCryptoComponent.Factory,
private val swapSelectTokensComponentFactory: SwapSelectTokensComponent.Factory,
private val onboardingEntryComponentFactory: OnboardingEntryComponent.Factory,
private val newWelcomeComponentFactory: NewWelcomeComponent.Factory,
private val storiesComponentFactory: StoriesComponent.Factory,
@ -114,6 +114,7 @@ internal class ChildFactory @Inject constructor(
private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory,
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory,
private val virtualAccountOnboardingComponentFactory: VirtualAccountOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory,
private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
@ -253,13 +254,6 @@ internal class ChildFactory @Inject constructor(
componentFactory = sellCryptoComponentFactory,
)
}
is AppRoute.SwapCrypto -> {
createComponentChild(
context = context,
params = SwapSelectTokensComponent.Params(userWalletId = route.userWalletId),
componentFactory = swapSelectTokensComponentFactory,
)
}
is AppRoute.Onboarding -> {
createComponentChild(
context = context,
@ -380,6 +374,7 @@ internal class ChildFactory @Inject constructor(
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN
is AppRoute.QrScanning.Source.AddressBook -> SourceType.ADDRESS_BOOK
}
createComponentChild(
context = context,
@ -495,10 +490,14 @@ internal class ChildFactory @Inject constructor(
componentFactory = feedEntryComponentFactory,
)
}
is AppRoute.Usedesk -> { // TODO [REDACTED_TASK_KEY] pass params
is AppRoute.Usedesk -> {
createComponentChild(
context = context,
params = UsedeskComponent.Params(),
params = UsedeskComponent.Params(
userWalletId = route.walletMetaInfo.userWalletId?.stringValue,
source = route.source,
prefilledMessage = route.prefilledMessage,
),
componentFactory = usedeskComponentFactory,
)
}
@ -702,6 +701,23 @@ internal class ChildFactory @Inject constructor(
componentFactory = tangemPayWalletOnboardingComponentFactory,
)
}
is AppRoute.VirtualAccountOnboarding -> {
createComponentChild(
context = context,
params = when (val mode = route.mode) {
is AppRoute.VirtualAccountOnboarding.Mode.Deeplink ->
VirtualAccountOnboardingComponent.Params.Deeplink(
userWalletId = mode.userWalletId,
deeplink = mode.deeplink,
)
is AppRoute.VirtualAccountOnboarding.Mode.FromMain ->
VirtualAccountOnboardingComponent.Params.FromMain(userWalletId = mode.userWalletId)
is AppRoute.VirtualAccountOnboarding.Mode.FromDetailsScreen ->
VirtualAccountOnboardingComponent.Params.FromDetailsScreen(userWalletId = mode.userWalletId)
},
componentFactory = virtualAccountOnboardingComponentFactory,
)
}
is AppRoute.Kyc -> {
createComponentChild(
context = context,
@ -759,7 +775,7 @@ internal class ChildFactory @Inject constructor(
is AppRoute.AddressBook -> {
createComponentChild(
context = context,
params = AddressBookComponent.Params(route.predefinedAddress),
params = AddressBookComponent.Params(addressBookOpenMode = route.addressBookOpenMode),
componentFactory = addressBookComponentFactory,
)
}

View file

@ -21,6 +21,7 @@ import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler
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.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler
import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
@ -57,6 +58,7 @@ internal class DeepLinkFactory @Inject constructor(
private val swapDeepLink: SwapDeepLinkHandler.Factory,
private val promoDeepLink: PromoDeeplinkHandler.Factory,
private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory,
private val onboardVirtualAccountsDeepLink: OnboardVirtualAccountsDeepLinkHandler.Factory,
private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory,
private val tangemPayMainDeepLink: TangemPayMainDeepLinkHandler.Factory,
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
@ -173,6 +175,7 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri)
DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri)
DeepLinkRoute.OnboardVirtualAccounts.host -> onboardVirtualAccountsDeepLink.create(deeplinkUri)
DeepLinkRoute.News.host -> newsDeepLink.create(queryParams)
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)

View file

@ -1,5 +1,5 @@
<resources>
<bool name="largeHeap">true</bool>
</resources>
<resources>
<bool name="largeHeap">true</bool>
</resources>

View file

@ -1,12 +1,12 @@
<resources>
<!-- Declare custom theme attributes that allow changing which styles are
used for button bars depending on the API level.
?android:attr/buttonBarStyle is new as of API 11 so this is
necessary to support previous API levels. -->
<declare-styleable name="ButtonBarContainerTheme">
<attr name="metaButtonBarStyle" format="reference" />
<attr name="metaButtonBarButtonStyle" format="reference" />
</declare-styleable>
</resources>
<resources>
<!-- Declare custom theme attributes that allow changing which styles are
used for button bars depending on the API level.
?android:attr/buttonBarStyle is new as of API 11 so this is
necessary to support previous API levels. -->
<declare-styleable name="ButtonBarContainerTheme">
<attr name="metaButtonBarStyle" format="reference" />
<attr name="metaButtonBarButtonStyle" format="reference" />
</declare-styleable>
</resources>

View file

@ -1,5 +1,5 @@
<resources>
<bool name="largeHeap">true</bool>
</resources>
<resources>
<bool name="largeHeap">true</bool>
</resources>

View file

@ -14,7 +14,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.models.errors.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.utils.coroutines.AppCoroutineScope

View file

@ -19,6 +19,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler
import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler
import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
@ -84,6 +85,10 @@ class DeepLinkFactoryTest {
every { create(any()) } returns mockk()
}
private val onboardVirtualAccountsDeepLink = mockk<OnboardVirtualAccountsDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val tangemPayMainDeepLink = mockk<TangemPayMainDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
}
@ -140,6 +145,7 @@ class DeepLinkFactoryTest {
swapDeepLink = swapDeepLinkFactory,
promoDeepLink = promoDeepLinkFactory,
onboardVisaDeepLink = onboardVisaDeepLink,
onboardVirtualAccountsDeepLink = onboardVirtualAccountsDeepLink,
tangemPayMainDeepLink = tangemPayMainDeepLink,
newsDetailsDeepLink = newsDeeplink,
newsDeepLink = newsDeepLinkFactory,