Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-06 11:27:45 +04:00
commit 5d853ea8b8
55 changed files with 951 additions and 199 deletions

View file

@ -27,6 +27,7 @@ import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.tap.MainActivity
import dagger.hilt.android.testing.HiltAndroidRule
import io.qameta.allure.kotlin.Allure
@ -129,6 +130,12 @@ abstract class BaseTestCase : TestCase(
value = false
)
}
appPreferencesStore.editData { mutablePreferences ->
mutablePreferences.set(
key = PreferencesKeys.getShouldShowInitialPermissionScreen(PUSH_PERMISSION),
value = false
)
}
promoRepository.setNeverToShowWalletPromo(PromoId.Sepa)
}
apiEnvironmentRule.setup(apiConfigsManager)

View file

@ -128,10 +128,6 @@ fun BaseTestCase.synchronizeAddresses(
onMainScreen { totalBalanceText.assert(!hasText(DASH_SIGN)) }
}
}
step("Expand 'Main account' to reveal tokens") {
onMainScreen { mainAccount().performClick() }
}
}
fun BaseTestCase.openDeviceSettingsScreen() {

View file

@ -11,20 +11,17 @@ import io.qameta.allure.kotlin.Allure.step
import com.tangem.common.ui.R as CommonUiR
private val firstStoryIndex = 0
private val firstStoryTitle = getResourceString(CommonUiR.string.swap_story_first_title)
private val firstStorySubtitle = getResourceString(CommonUiR.string.swap_story_first_subtitle)
private val firstStoryTitle = getResourceString(CommonUiR.string.swap_story_first_title_v2)
private val firstStorySubtitle = getResourceString(CommonUiR.string.swap_story_first_subtitle_v2)
private val secondStoryIndex = 1
private val secondStoryTitle = getResourceString(CommonUiR.string.swap_story_second_title)
private val secondStorySubtitle = getResourceString(CommonUiR.string.swap_story_second_subtitle)
private val secondStoryTitle = getResourceString(CommonUiR.string.swap_story_second_title_v2)
private val secondStorySubtitle = getResourceString(CommonUiR.string.swap_story_second_subtitle_v2)
private val thirdStoryIndex = 2
private val thirdStoryTitle = getResourceString(CommonUiR.string.swap_story_third_title)
private val thirdStorySubtitle = getResourceString(CommonUiR.string.swap_story_third_subtitle)
private val thirdStoryTitle = getResourceString(CommonUiR.string.swap_story_third_title_v2)
private val thirdStorySubtitle = getResourceString(CommonUiR.string.swap_story_third_subtitle_v2)
private val forthStoryIndex = 3
private val forthStoryTitle = getResourceString(CommonUiR.string.swap_story_forth_title)
private val forthStorySubtitle = getResourceString(CommonUiR.string.swap_story_forth_subtitle)
private val fifthStoryIndex = 4
private val fifthStoryTitle = getResourceString(CommonUiR.string.swap_story_fifth_title)
private val fifthStorySubtitle = getResourceString(CommonUiR.string.swap_story_fifth_subtitle)
private val forthStoryTitle = getResourceString(CommonUiR.string.swap_story_forth_title_v2)
private val forthStorySubtitle = getResourceString(CommonUiR.string.swap_story_forth_subtitle_v2)
fun BaseTestCase.openSwapScreen(
from: SwapEntryPoint,
@ -120,26 +117,6 @@ fun BaseTestCase.checkStoriesChanges() {
storySubtitle = forthStorySubtitle
)
}
step("Click on right side") {
onSwapStoriesScreen { container.performTouchInput { click(centerRight) } }
}
step("Check title and subtitle for story №${fifthStoryIndex + 1}") {
checkStoriesContent(
storyIndex = fifthStoryIndex,
storyTitle = fifthStoryTitle,
storySubtitle = fifthStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}
step("Check title and subtitle for story №${forthStoryIndex + 1}") {
checkStoriesContent(
storyIndex = forthStoryIndex,
storyTitle = forthStoryTitle,
storySubtitle = forthStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}

View file

@ -244,7 +244,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
fun tokenWithTitleAndAddress(tokenTitle: String): KNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenTitle))
hasText(tokenTitle)
useUnmergedTree = true
}.child<KNode> {
hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT)
@ -256,7 +256,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenTitle))
hasText(tokenTitle)
useUnmergedTree = true
}.child<KNode> {
hasTestTag(TokenElementsTestTags.TOKEN_CUSTOM_DERIVATION_ICON)
@ -297,7 +297,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenTitle))
hasText(tokenTitle)
hasLazyListItemPosition(index)
useUnmergedTree = true
}.child<KNode> {

View file

@ -143,7 +143,7 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
val youSwapBlock: KNode = child {
hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER)
hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title)))
hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2)))
hasAnyDescendant(withTestTag(SwapTokenScreenTestTags.BALANCE))
useUnmergedTree = true
}

View file

@ -30,7 +30,6 @@ class SwapChooseTokenScreenTest : BaseTestCase() {
@Test
fun checkAvailableToSwapTokensListTest() {
val tokenTitle = "Polygon"
val inputAmount = "100"
val ethereum = "Ethereum"
val polExMatic = "POL (ex-MATIC)"
val bitcoin = "Bitcoin"
@ -66,13 +65,6 @@ class SwapChooseTokenScreenTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
textInput.clickWithAssertion()
textInput.performTextReplacement(inputAmount)
}
}
step("Click on 'Select token' icon") {
onSwapTokenScreen { swapSelectTokenIcon.performClick() }
}
@ -102,7 +94,6 @@ class SwapChooseTokenScreenTest : BaseTestCase() {
@Test
fun checkSearchOnSwapChooseTokenScreenTest() {
val tokenTitle = "Polygon"
val inputAmount = "100"
val ethereum = "Ethereum"
val polExMatic = "POL (ex-MATIC)"
val polExMaticSymbol = "POL"
@ -129,13 +120,6 @@ class SwapChooseTokenScreenTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
textInput.clickWithAssertion()
textInput.performTextReplacement(inputAmount)
}
}
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}

View file

@ -117,7 +117,7 @@ class SwapStoriesTest : BaseTestCase() {
@DisplayName("Check unavailable swap stories on 'Main' screen")
@Test
fun checkUnavailableSwapStoriesOnMainScreen() {
val scenarioName = "stories_first_time_swap"
val scenarioName = "stories_first_time_swap_v2"
val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName
@ -168,7 +168,7 @@ class SwapStoriesTest : BaseTestCase() {
@DisplayName("Check unavailable swap stories on 'Token details' screen")
@Test
fun checkUnavailableSwapStoriesOnTokenDetailsScreen() {
val scenarioName = "stories_first_time_swap"
val scenarioName = "stories_first_time_swap_v2"
val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName
val tokenName = "Ethereum"
@ -224,7 +224,7 @@ class SwapStoriesTest : BaseTestCase() {
@DisplayName("Check unavailable swap stories on 'Markets' token details screen")
@Test
fun checkUnavailableSwapStoriesOnMarketsTokenDetailsScreen() {
val scenarioName = "stories_first_time_swap"
val scenarioName = "stories_first_time_swap_v2"
val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName
val tokenName = "Ethereum"

View file

@ -68,6 +68,9 @@ class SwapTokenScreenTest : BaseTestCase() {
}
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -75,9 +78,6 @@ class SwapTokenScreenTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert input amount = '$inputAmount'") {
onSwapTokenScreen { textInput.assertTextEquals(inputAmount) }
}
@ -121,6 +121,7 @@ class SwapTokenScreenTest : BaseTestCase() {
@Test
fun networkErrorSwapTest() {
val tokenTitle = "Polygon"
val receiveTokenName = "Ethereum"
setupHooks(
additionalAfterSection = {
@ -154,6 +155,9 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap' screen title is displayed") {
onSwapTokenScreen { title.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert error notification title is displayed") {
onSwapTokenScreen {
waitForIdle()
@ -212,6 +216,9 @@ class SwapTokenScreenTest : BaseTestCase() {
}
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -222,9 +229,6 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert input amount = '$inputAmount'") {
onSwapTokenScreen { textInput.assertTextEquals(inputAmount) }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert receive amount is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
@ -461,8 +465,12 @@ class SwapTokenScreenTest : BaseTestCase() {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
step("Click on 'Swap tokens on screen' button") {
onSwapTokenScreen { replaceTokensButton.performClick() }
waitForIdle()
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen {
replaceTokensButton.assertIsEnabled()
replaceTokensButton.performClick()
}
}
}
step("Assert new swap token symbol: '$receiveTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
@ -557,6 +565,9 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -564,9 +575,6 @@ class SwapTokenScreenTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Select '$market' fee type") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
selectFeeType(FeeType.Market, selectedFeeAmount = marketFeeAmount)
@ -623,6 +631,9 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -630,9 +641,6 @@ class SwapTokenScreenTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Select '$marketFeeType' fee type") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
selectFeeType(feeType = FeeType.Market, feeAmount)
@ -703,6 +711,9 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -710,9 +721,6 @@ class SwapTokenScreenTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert 'Swap' button is enabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { swapButton.assertIsEnabled() }

View file

@ -60,6 +60,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenTitle)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -67,9 +70,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenTitle)
}
step("Assert 'Insufficient funds' error is displayed") {
waitForIdle()
onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() }
@ -125,6 +125,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(networkName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -132,9 +135,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(networkName)
}
step("Check 'Unable to cover '$networkName' fee notification") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
chackUnableToCoverFeeNotification(networkName = networkName, currencySymbol = currencySymbol)
@ -204,6 +204,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -211,9 +214,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert fiat amount with warning is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen {
@ -293,6 +293,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -300,9 +303,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert fiat amount with warning is displayed") {
onSwapTokenScreen { receiveFiatAmount.assertTextContains("%", substring = true) }
}
@ -381,6 +381,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -388,9 +391,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert 'Invalid amount' warning is not displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
checkSwapWarning(
@ -450,6 +450,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -457,9 +460,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert 'Invalid amount' warning is not displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
checkSwapWarning(
@ -520,6 +520,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -527,9 +530,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert 'Invalid amount' warning is not displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
checkSwapWarning(
@ -589,6 +589,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Assert 'You swap' block is displayed") {
onSwapTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount = '$inputAmount'") {
waitForIdle()
onSwapTokenScreen {
@ -596,9 +599,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount)
}
}
step("Choose receive token") {
chooseReceiveToken(receiveTokenName)
}
step("Assert 'Invalid amount' warning is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
checkSwapWarning(

View file

@ -306,6 +306,26 @@
android:host="news"
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="earn"
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="yield"
android:scheme="tangem" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />

View file

@ -3,6 +3,7 @@ package com.tangem.tap.common.analytics.paramsInterceptor
import com.tangem.core.analytics.api.ParamsInterceptor
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.event.SignIn
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
@ -31,6 +32,7 @@ class CardContextInterceptor(
is IntroductionProcess.ButtonScanCardLegacy,
is SignIn.ScreenOpened,
is SignIn.ButtonAddWallet,
is Basic.CardWasScanned,
-> false
is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll
else -> true

View file

@ -7,11 +7,8 @@ import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.di.GlobalUiMessageSender
@ -28,7 +25,6 @@ import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase
import com.tangem.sdk.extensions.localizedDescriptionRes
import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.mainScope
import com.tangem.tap.tangemSdkManager
@ -61,6 +57,7 @@ internal class LegacyScanProcessor @Inject constructor(
cardId = cardId,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
source = analyticsSource,
)
.doOnFailure { error ->
onScanFailure(analyticsSource = analyticsSource, error = error, onFailure = {}, onCancel = {})
@ -85,10 +82,9 @@ internal class LegacyScanProcessor @Inject constructor(
val result = tangemSdkManager.scanProduct(
cardId = cardId,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
source = analyticsSource,
)
val analyticsEvent = Basic.CardWasScanned(analyticsSource)
result
.doOnFailure { error ->
scanFailsCounter.onScanFailure(
@ -111,8 +107,6 @@ internal class LegacyScanProcessor @Inject constructor(
scanFailsCounter.reset()
tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse)
sendAnalytics(analyticsEvent, scanResponse)
onScanSuccess(
scanResponse = scanResponse,
onProgressStateChange = onProgressStateChange,
@ -122,16 +116,6 @@ internal class LegacyScanProcessor @Inject constructor(
}
}
private fun sendAnalytics(analyticsEvent: AnalyticsEvent, scanResponse: ScanResponse) {
// this workaround needed to send CardWasScannedEvent without adding a context
val interceptor = CardContextInterceptor(scanResponse)
val params = analyticsEvent.params.toMutableMap()
interceptor.intercept(params)
analyticsEvent.params = params.toMap()
Analytics.send(analyticsEvent)
}
private suspend inline fun onScanFailure(
analyticsSource: AnalyticsParam.ScreensSources,
error: TangemError,

View file

@ -18,11 +18,13 @@ internal class DefaultScanCardRepository(
allowRequestAccessCodeFromStorage: Boolean,
shouldCheckIsAlreadyActivated: Boolean,
): ScanResponse {
@Suppress("UnreachableCode")
return when (
val result = tangemSdkManager.scanProduct(
cardId = cardId,
allowsRequestAccessCodeFromRepository = allowRequestAccessCodeFromStorage,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
source = TODO("Fix when enable NEW_CARD_SCANNING_ENABLED"),
)
) {
is CompletionResult.Success -> result.data

View file

@ -18,6 +18,8 @@ import com.tangem.common.usersCode.UserCodeRepository
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.res.getStringSafe
import com.tangem.crypto.bip39.DefaultMnemonic
import com.tangem.crypto.hdWallet.DerivationPath
@ -25,13 +27,13 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.*
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.operations.ScanTask
import com.tangem.operations.derivation.DerivationTaskResponse
@ -46,6 +48,7 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
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.TangemPaySignWithdrawalHashTask
@ -135,6 +138,7 @@ internal class DefaultTangemSdkManager(
messageRes: Int?,
allowsRequestAccessCodeFromRepository: Boolean,
shouldCheckIsAlreadyActivated: Boolean,
source: AnalyticsParam.ScreensSources,
): CompletionResult<ScanResponse> {
val message = Message(resources.getStringSafe(messageRes ?: R.string.initial_message_scan_header))
return coroutineScope {
@ -152,7 +156,7 @@ internal class DefaultTangemSdkManager(
),
cardId = cardId,
initialMessage = message,
).also { sendScanResultsToAnalytics(it) }
).also { sendAnalytics(result = it, source = source) }
}
}
@ -225,12 +229,24 @@ internal class DefaultTangemSdkManager(
.doOnResult { tangemSdk.config.setupForProduct(ProductType.ANY) }
}
private fun sendScanResultsToAnalytics(result: CompletionResult<ScanResponse>) {
if (result is CompletionResult.Failure) {
(result.error as? TangemSdkError)?.let { error ->
Analytics.sendErrorEvent(TangemSdkErrorEvent(error))
private fun sendAnalytics(result: CompletionResult<ScanResponse>, source: AnalyticsParam.ScreensSources) {
result
.doOnSuccess { scanResponse ->
// We don't use the standard analytics event enrichment mechanism with card context params here,
// because we've just scanned a new card that may not yet be selected as the current one
val analyticsEvent = Basic.CardWasScanned(source)
val interceptor = CardContextInterceptor(scanResponse)
val params = analyticsEvent.params.toMutableMap()
interceptor.intercept(params)
analyticsEvent.params = params.toMap()
Analytics.send(event = analyticsEvent)
}
.doOnFailure { tangemError ->
(tangemError as? TangemSdkError)?.let { error ->
Analytics.sendErrorEvent(TangemSdkErrorEvent(error))
}
}
}
}
override suspend fun derivePublicKeys(

View file

@ -14,6 +14,7 @@ import com.tangem.common.core.TangemSdkError
import com.tangem.common.core.UserCodeRequestPolicy
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.services.InMemoryStorage
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.res.getStringSafe
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
@ -63,6 +64,7 @@ class MockTangemSdkManager(
messageRes: Int?,
allowsRequestAccessCodeFromRepository: Boolean,
shouldCheckIsAlreadyActivated: Boolean,
source: AnalyticsParam.ScreensSources,
): CompletionResult<ScanResponse> {
if (!MockProvider.isPreset) {
val activity = foregroundActivityObserver.foregroundActivity

View file

@ -300,11 +300,14 @@ internal class DefaultUserWalletsListRepository(
}
val scanResponse = unlockMethod.scanResponse ?: run {
when (val res = tangemSdkManagerProvider().scanProduct(
val tangemSdkManager = tangemSdkManagerProvider()
val result = tangemSdkManager.scanProduct(
shouldCheckIsAlreadyActivated = false,
)) {
source = unlockMethod.source,
)
when (result) {
is CompletionResult.Failure -> raise(UnlockWalletError.UserCancelled)
is CompletionResult.Success -> res.data
is CompletionResult.Success -> result.data
}
}

View file

@ -709,6 +709,16 @@ internal class ChildFactory @Inject constructor(
componentFactory = feedEntryComponentFactory,
)
}
is AppRoute.Earn -> {
createComponentChild(
context = context,
params = FeedEntryRoute.Earn(
preselectedEarnType = route.preselectedEarnType,
preselectedNetworkId = route.preselectedNetworkId,
),
componentFactory = feedEntryComponentFactory,
)
}
}
}
}

View file

@ -6,11 +6,13 @@ import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
@ -56,6 +58,8 @@ internal class DeepLinkFactory @Inject constructor(
private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory,
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
private val newsDeepLink: NewsDeepLinkHandler.Factory,
private val earnDeepLink: EarnDeepLinkHandler.Factory,
private val yieldDeepLink: YieldDeepLinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -162,6 +166,8 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri)
DeepLinkRoute.News.host -> newsDeepLink.create(queryParams)
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)
else -> {
TangemLogger.i(
"""

View file

@ -84,10 +84,8 @@ internal class MockAwareTangemPayStorage @Inject constructor(
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) =
real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer)
override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? {
if (isMockMode) return true
return real.checkCustomerWalletResult(userWalletId)
}
override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? =
real.checkCustomerWalletResult(userWalletId)
override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) =
real.storeActiveWithdrawOrderId(userWalletId, orderId)

View file

@ -4,11 +4,13 @@ import android.net.Uri
import com.tangem.common.routing.AppRoute
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
@ -92,6 +94,14 @@ class DeepLinkFactoryTest {
every { create(any()) } returns mockk()
}
private val earnDeepLinkFactory = mockk<EarnDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val yieldDeepLinkFactory = mockk<YieldDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
}
private val marketsTokenExchangesDeepLinkFactory =
mockk<MarketsTokenExchangesDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
@ -122,6 +132,8 @@ class DeepLinkFactoryTest {
onboardVisaDeepLink = onboardVisaDeepLink,
newsDetailsDeepLink = newsDeeplink,
newsDeepLink = newsDeepLinkFactory,
earnDeepLink = earnDeepLinkFactory,
yieldDeepLink = yieldDeepLinkFactory,
)
@OptIn(ExperimentalCoroutinesApi::class)
@ -439,18 +451,28 @@ class DeepLinkFactoryTest {
}
@Test
fun `handleTangemDeepLinks routes news host to dedicated handler`() = runTest {
fun `handleTangemDeepLinks routes news, earn and yield hosts to dedicated handlers`() = runTest {
every { mockedUri.scheme } returns "tangem"
every { mockedUri.host } returns "news"
every { mockedUri.query } returns null
every { mockedUri.queryParameterNames } returns emptySet()
every { mockedUri.getQueryParameter(any()) } returns null
deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet)
every { mockedUri.host } returns "news"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { newsDeepLinkFactory.create(eq(emptyMap())) }
every { mockedUri.host } returns "earn"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { earnDeepLinkFactory.create(eq(emptyMap())) }
every { mockedUri.host } returns "yield"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { yieldDeepLinkFactory.create(eq(testScope), eq(emptyMap())) }
}
@Test

View file

@ -18,6 +18,7 @@ import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.wallet.UserWalletId
@ -495,4 +496,10 @@ sealed class AppRoute(val path: String) : Route {
data class News(
val categoryId: Int? = null,
) : AppRoute(path = "/news")
@Serializable
data class Earn(
val preselectedEarnType: PreselectedEarnType? = null,
val preselectedNetworkId: String? = null,
) : AppRoute(path = "/earn")
}

View file

@ -75,6 +75,14 @@ sealed class DeepLinkRoute {
data object News : DeepLinkRoute() {
override val host: String = "news"
}
data object Earn : DeepLinkRoute() {
override val host: String = "earn"
}
data object Yield : DeepLinkRoute() {
override val host: String = "yield"
}
}
enum class DeepLinkScheme(val scheme: String) {

View file

@ -20,4 +20,5 @@ object DeeplinkConst {
const val SECTION_KEY = "section"
const val CATEGORY_ID_KEY = "category_id"
const val NEWS_ID_KEY = "news_id"
const val EARN_TYPE_KEY = "earn_type"
}

View file

@ -108,6 +108,31 @@ internal class DeepLinkBuilderTest {
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.NEWS_ID_KEY}=20533")
}
@Test
fun `earn host with filters produces expected uri`() {
val result = deepLinkBuilder
.setAction("earn")
.addQueryParam(DeeplinkConst.EARN_TYPE_KEY, "yield")
.addQueryParam(DeeplinkConst.NETWORK_ID_KEY, "base")
.build()
assertThat(result).isEqualTo(
"${DeeplinkConst.TANGEM_SCHEME}://earn?${DeeplinkConst.EARN_TYPE_KEY}=yield" +
"&${DeeplinkConst.NETWORK_ID_KEY}=base",
)
}
@Test
fun `yield host with token and network produces expected uri`() {
val result = deepLinkBuilder
.setAction("yield")
.addQueryParam(DeeplinkConst.TOKEN_ID_KEY, "usd-coin")
.addQueryParam(DeeplinkConst.NETWORK_ID_KEY, "base")
.build()
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://yield?token_id=usd-coin&network_id=base")
}
@Test
fun `GIVEN complex deep link WHEN build THEN should construct correct URI`() {
// GIVEN

View file

@ -41,7 +41,7 @@
},
{
"name": "ASSETS_DISCOVERY_ENABLED",
"version": "undefined"
"version": "5.38"
},
{
"name": "SOLANA_TX_HISTORY_ENABLED",

View file

@ -96,14 +96,14 @@ fun StoriesProgressBar(
.height(2.dp)
.weight(1f)
.clip(RoundedCornerShape(2.dp))
.background(TangemColorPalette.White.copy(alpha = .2f)),
.background(TangemColorPalette.White.copy(alpha = .2f))
.testTag(SwapStoriesScreenTestTags.PROGRESS_BAR_ITEM),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(2.dp))
.background(TangemColorPalette.White)
.fillMaxHeight()
.testTag(SwapStoriesScreenTestTags.PROGRESS_BAR_ITEM)
.let { modifier ->
when (index) {
currentStep -> modifier.fillMaxWidth(progress.value)

View file

@ -71,15 +71,11 @@ internal class MockAwareOnboardingRepository @Inject constructor(
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> =
real.hasTangemPayInWallet(userWalletId)
override suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType> {
if (isMockMode) return listOf(TangemPayEligibilityType.DETAILS)
return real.checkCustomerEligibility()
}
override suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType> =
real.checkCustomerEligibility()
override suspend fun getCustomerEligibility(): List<TangemPayEligibilityType> {
if (isMockMode) return listOf(TangemPayEligibilityType.DETAILS)
return real.getCustomerEligibility()
}
override suspend fun getCustomerEligibility(): List<TangemPayEligibilityType> =
real.getCustomerEligibility()
override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? =
real.getSavedCustomerInfo(userWalletId)

View file

@ -5,13 +5,14 @@ plugins {
}
dependencies {
api(deps.kotlin.coroutines)
api(deps.arrow.core)
api(deps.arrow.fx)
api(projects.domain.models)
api(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
api(projects.core.analytics.models)
api(projects.domain.models)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)

View file

@ -1,6 +1,7 @@
package com.tangem.domain.common.wallets
import arrow.core.Either
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.common.wallets.error.*
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
@ -145,7 +146,10 @@ interface UserWalletsListRepository {
sealed class UnlockMethod {
data object Biometric : UnlockMethod()
data object AccessCode : UnlockMethod()
data class Scan(val scanResponse: ScanResponse? = null) : UnlockMethod()
data class Scan(
val scanResponse: ScanResponse? = null,
val source: AnalyticsParam.ScreensSources,
) : UnlockMethod()
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.models.earn
import kotlinx.serialization.Serializable
@Serializable
enum class PreselectedEarnType(val value: String) {
Staking("staking"),
Yield("yield"),
;
companion object {
fun parse(value: String?): PreselectedEarnType? = entries.firstOrNull { it.value == value }
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.wallets.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.UnlockWalletError
import com.tangem.domain.models.wallet.UserWallet
@ -22,7 +23,10 @@ class NonBiometricUnlockWalletUseCase(
private val walletsRepository: WalletsRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<UnlockWalletError, Unit> = either {
suspend operator fun invoke(
userWalletId: UserWalletId,
source: AnalyticsParam.ScreensSources,
): Either<UnlockWalletError, Unit> = either {
val userWallet = userWalletsListRepository.userWalletsSync()
.find { it.walletId == userWalletId }
?: raise(UnlockWalletError.UserWalletNotFound)
@ -32,7 +36,7 @@ class NonBiometricUnlockWalletUseCase(
}
val method = when (userWallet) {
is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan()
is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan(scanResponse = null, source = source)
is UserWallet.Hot -> UserWalletsListRepository.UnlockMethod.AccessCode
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.wallets.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.UnlockWalletError
import com.tangem.domain.models.wallet.UserWalletId
@ -19,12 +20,15 @@ class UnlockWalletUseCase(
private val nonBiometricUnlockWalletUseCase: NonBiometricUnlockWalletUseCase,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<UnlockWalletError, Unit> = either {
suspend operator fun invoke(
userWalletId: UserWalletId,
source: AnalyticsParam.ScreensSources,
): Either<UnlockWalletError, Unit> = either {
userWalletsListRepository.unlock(userWalletId, UserWalletsListRepository.UnlockMethod.Biometric)
.mapLeft { error ->
when (error) {
UnlockWalletError.AlreadyUnlocked -> Unit
else -> nonBiometricUnlockWalletUseCase(userWalletId).bind()
else -> nonBiometricUnlockWalletUseCase(userWalletId, source).bind()
}
}
}

View file

@ -227,7 +227,10 @@ internal class CreateWalletStartModel @Inject constructor(
is SaveWalletError.WalletAlreadySaved -> {
userWalletsListRepository.unlock(
userWalletId = userWallet.walletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse),
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(
scanResponse = scanResponse,
source = AnalyticsParam.ScreensSources.Intro,
),
).onRight {
appRouter.replaceAll(AppRoute.Wallet)
}

View file

@ -421,7 +421,10 @@ internal class CreateWalletStartModelTest {
coVerify {
userWalletsListRepository.unlock(
userWalletId = testUserWalletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(testScanResponse),
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(
scanResponse = testScanResponse,
source = AnalyticsParam.ScreensSources.Intro,
),
)
}
verify { appRouter.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) }

View file

@ -13,22 +13,22 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.usecase.ApplyUserWalletListSortingUseCase
import com.tangem.domain.wallets.usecase.UnlockWalletUseCase
import com.tangem.features.details.entity.UserWalletListUM
import com.tangem.features.details.entity.WalletReorderUM
import com.tangem.features.details.impl.R
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.features.details.utils.UserWalletSaver
import com.tangem.features.wallet.utils.UserWalletsFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@Suppress("LongParameterList")
@ -112,7 +112,7 @@ internal class UserWalletListModel @Inject constructor(
private fun onWalletClicked(userWalletId: UserWalletId) {
modelScope.launch {
unlockWalletUseCase(userWalletId)
unlockWalletUseCase(userWalletId, AnalyticsParam.ScreensSources.Settings)
.onRight { router.push(AppRoute.WalletSettings(userWalletId)) }
.onLeft { error ->
TangemLogger.e("Failed to unlock wallet $userWalletId: $error")

View file

@ -22,6 +22,7 @@ dependencies {
/* Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.markets.models)

View file

@ -5,6 +5,7 @@ import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.earn.PreselectedEarnType
import kotlinx.serialization.Serializable
@Serializable
@ -39,4 +40,10 @@ sealed interface FeedEntryRoute {
@Serializable
data class NewsList(val preselectedCategoryId: Int? = null) : FeedEntryRoute
@Serializable
data class Earn(
val preselectedEarnType: PreselectedEarnType? = null,
val preselectedNetworkId: String? = null,
) : FeedEntryRoute
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.feed.entry.deeplink
interface EarnDeepLinkHandler {
interface Factory {
fun create(queryParams: Map<String, String>): EarnDeepLinkHandler
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.feed.entry.deeplink
import kotlinx.coroutines.CoroutineScope
interface YieldDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): YieldDeepLinkHandler
}
}

View file

@ -112,4 +112,5 @@ dependencies {
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -21,10 +21,13 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.model.FeedEntryModel
@ -125,11 +128,19 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
override fun onOpenAllNews() {
innerRouter.push(FeedEntryChildFactory.Child.NewsList())
innerRouter.push(
FeedEntryChildFactory.Child.NewsList(
params = buildNewsListParams(onBack = { onChildBack() }),
),
)
}
override fun onOpenEarnPage() {
innerRouter.push(FeedEntryChildFactory.Child.Earn)
innerRouter.push(
FeedEntryChildFactory.Child.Earn(
params = buildEarnParams(onBack = { onChildBack() }),
),
)
}
override fun openSearch(source: String) {
@ -264,12 +275,49 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
),
)
is FeedEntryRoute.NewsList -> FeedEntryChildFactory.Child.NewsList(
preselectedCategoryId = entryRoute.preselectedCategoryId,
params = buildNewsListParams(
onBack = { router.pop() },
preselectedCategoryId = entryRoute.preselectedCategoryId,
),
)
is FeedEntryRoute.Earn -> FeedEntryChildFactory.Child.Earn(
params = buildEarnParams(
onBack = { router.pop() },
preselectedEarnType = entryRoute.preselectedEarnType,
preselectedNetworkId = entryRoute.preselectedNetworkId,
),
)
null -> FeedEntryChildFactory.Child.Feed
}
}
private fun buildNewsListParams(
onBack: () -> Unit,
preselectedCategoryId: Int? = null,
): DefaultNewsListComponent.Params = DefaultNewsListComponent.Params(
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
clickIntents.onArticleClick(
articleId = currentArticle,
preselectedArticlesId = prefetchedArticles,
screenSource = AnalyticsParam.ScreensSources.NewsList,
paginationConfig = paginationConfig,
)
},
onBackClick = onBack,
preselectedCategoryId = preselectedCategoryId,
)
private fun buildEarnParams(
onBack: () -> Unit,
preselectedEarnType: PreselectedEarnType? = null,
preselectedNetworkId: String? = null,
): DefaultEarnComponent.Params = DefaultEarnComponent.Params(
onBackClick = onBack,
onSearchClicked = clickIntents::openSearch,
preselectedEarnType = preselectedEarnType,
preselectedNetworkId = preselectedNetworkId,
)
@AssistedFactory
interface Factory : FeedEntryComponent.Factory {
override fun create(context: AppComponentContext, entryRoute: FeedEntryRoute?): DefaultFeedEntryComponent

View file

@ -17,7 +17,6 @@ import com.tangem.features.feed.components.market.details.portfolioblock.Portfol
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent.Params
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
@ -53,7 +52,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data class NewsList(val preselectedCategoryId: Int? = null) : Child
data class NewsList(val params: DefaultNewsListComponent.Params) : Child
@Serializable
@Immutable
@ -61,7 +60,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data object Earn : Child
data class Earn(val params: DefaultEarnComponent.Params) : Child
@Serializable
@Immutable
@ -113,18 +112,7 @@ internal class FeedEntryChildFactory @Inject constructor(
is Child.NewsList -> {
DefaultNewsListComponent(
appComponentContext = appComponentContext,
params = Params(
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
feedEntryClickIntents.onArticleClick(
articleId = currentArticle,
preselectedArticlesId = prefetchedArticles,
screenSource = AnalyticsParam.ScreensSources.NewsList,
paginationConfig = paginationConfig,
)
},
onBackClick = onBackClicked,
preselectedCategoryId = child.preselectedCategoryId,
),
params = child.params,
)
}
Child.Feed -> {
@ -139,10 +127,7 @@ internal class FeedEntryChildFactory @Inject constructor(
is Child.Earn -> {
DefaultEarnComponent(
appComponentContext = appComponentContext,
params = DefaultEarnComponent.Params(
onBackClick = onBackClicked,
onSearchClicked = feedEntryClickIntents::openSearch,
),
params = child.params,
addToPortfolioComponentFactory = addToPortfolioComponentFactory,
)
}

View file

@ -34,7 +34,9 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import kotlinx.serialization.Serializable
import com.tangem.features.feed.model.earn.EarnModel
import com.tangem.features.feed.model.earn.analytics.EarnSource
import com.tangem.features.feed.ui.components.FeedSearchBar
@ -151,8 +153,11 @@ internal class DefaultEarnComponent(
)
}
@Serializable
data class Params(
val onBackClick: () -> Unit,
val onSearchClicked: (source: String) -> Unit,
val preselectedEarnType: PreselectedEarnType? = null,
val preselectedNetworkId: String? = null,
)
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.EARN_TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultEarnDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
) : EarnDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
val earnType = PreselectedEarnType.parse(queryParams[EARN_TYPE_KEY])
val networkId = queryParams[NETWORK_ID_KEY]?.takeIf { it.isNotBlank() }
appRouter.push(
AppRoute.Earn(
preselectedEarnType = earnType,
preselectedNetworkId = networkId,
),
)
}
@AssistedFactory
interface Factory : EarnDeepLinkHandler.Factory {
override fun create(queryParams: Map<String, String>): DefaultEarnDeepLinkHandler
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal class DefaultYieldDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase,
) : YieldDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
val tokenId = queryParams[TOKEN_ID_KEY]?.takeIf { it.isNotBlank() }
val networkId = queryParams[NETWORK_ID_KEY]?.takeIf { it.isNotBlank() }
if (tokenId == null || networkId == null) {
TangemLogger.i("Yield deeplink: missing token_id or network_id; falling back to earn yield list")
pushFallback(networkId)
return
}
scope.launch {
val userWallet = getSelectedWalletSyncUseCase().getOrNull()
if (userWallet == null) {
TangemLogger.e("Yield deeplink: no selected user wallet")
pushFallback(networkId)
return@launch
}
val cryptoCurrency = multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWallet.walletId),
)
.orEmpty()
.firstOrNull { currency ->
currency.network.rawId.equals(networkId, ignoreCase = true) &&
currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
}
if (cryptoCurrency == null) {
TangemLogger.i(
"""
Yield deeplink: token not in selected wallet
|- $TOKEN_ID_KEY: $tokenId
|- $NETWORK_ID_KEY: $networkId
""".trimIndent(),
)
pushFallback(networkId)
return@launch
}
val availability = yieldSupplyGetAvailabilityUseCase(cryptoCurrency).getOrNull()
val available = availability as? YieldSupplyAvailability.Available
if (available == null) {
TangemLogger.i("Yield deeplink: yield not available for ${cryptoCurrency.name}")
pushFallback(networkId)
return@launch
}
appRouter.push(
AppRoute.YieldSupplyEntry(
userWalletId = userWallet.walletId,
cryptoCurrency = cryptoCurrency,
apy = available.apy,
),
)
}
}
private fun pushFallback(networkId: String?) {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
)
}
@AssistedFactory
interface Factory : YieldDeepLinkHandler.Factory {
override fun create(
coroutineScope: CoroutineScope,
queryParams: Map<String, String>,
): DefaultYieldDeepLinkHandler
}
}

View file

@ -1,9 +1,13 @@
package com.tangem.features.feed.deeplink.di
import com.tangem.features.feed.deeplink.DefaultEarnDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultNewsDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultYieldDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -23,4 +27,12 @@ internal interface FeedDeepLinkModule {
@Binds
@Singleton
fun bindNewsDeepLinkHandlerFactory(impl: DefaultNewsDeepLinkHandler.Factory): NewsDeepLinkHandler.Factory
@Binds
@Singleton
fun bindEarnDeepLinkHandlerFactory(impl: DefaultEarnDeepLinkHandler.Factory): EarnDeepLinkHandler.Factory
@Binds
@Singleton
fun bindYieldDeepLinkHandlerFactory(impl: DefaultYieldDeepLinkHandler.Factory): YieldDeepLinkHandler.Factory
}

View file

@ -23,6 +23,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent
import com.tangem.features.feed.components.earn.EarnTypeFilterComponent
@ -108,7 +109,8 @@ internal class EarnModel @Inject constructor(
init {
updateInitialState()
fetchEarnNetworks()
subscribeOnStoredFilters()
fetchTopEarnTokens()
subscribeOnActiveFilters()
subscribeOnNetworks()
subscribeOnBatchFlow()
subscribeToMostlyUsed()
@ -147,6 +149,45 @@ internal class EarnModel @Inject constructor(
)
}
private fun activeFilters(): Flow<EarnFilter> {
val deeplink = buildDeeplinkFilter() ?: return getEarnFilterUseCase()
return getEarnFilterUseCase().drop(1).onStart { emit(deeplink) }
}
private fun buildDeeplinkFilter(): EarnFilter? {
val type = params.preselectedEarnType?.toEarnFilterType()
val networkId = params.preselectedNetworkId?.takeIf { it.isNotBlank() }
if (type == null && networkId == null) return null
return EarnFilter(
earnFilterType = type ?: EarnFilterType.ALL,
earnFilterNetwork = networkId
?.let { EarnFilterNetwork.Specific(id = it, symbol = "", fullName = it, isSelected = true) }
?: EarnFilterNetwork.AllNetworks(isSelected = true),
)
}
private fun EarnFilter.resolveAgainst(networks: EarnNetworks): EarnFilter {
val specific = earnFilterNetwork as? EarnFilterNetwork.Specific ?: return this
if (specific.symbol.isNotEmpty()) return this
val loaded = networks.getOrNull()?.takeIf { it.isNotEmpty() } ?: return this
val match = loaded.firstOrNull { it.networkId.equals(specific.id, ignoreCase = true) }
return copy(
earnFilterNetwork = match?.let { earnNetwork ->
EarnFilterNetwork.Specific(
isSelected = true,
id = earnNetwork.networkId,
symbol = earnNetwork.symbol,
fullName = earnNetwork.fullName,
)
} ?: EarnFilterNetwork.AllNetworks(isSelected = true),
)
}
private fun PreselectedEarnType.toEarnFilterType(): EarnFilterType = when (this) {
PreselectedEarnType.Staking -> EarnFilterType.STAKING
PreselectedEarnType.Yield -> EarnFilterType.YIELD
}
private fun subscribeOnBatchFlow() {
combine(
batchFlowManager.uiItems,
@ -193,18 +234,14 @@ internal class EarnModel @Inject constructor(
}
}
private fun subscribeOnStoredFilters() {
private fun subscribeOnActiveFilters() {
modelScope.launch(dispatchers.default) {
combine(
getEarnFilterUseCase(),
earnNetworks,
) { filter, networks ->
val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType)
val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork)
combine(activeFilters(), earnNetworks) { filter, networks ->
val resolved = filter.resolveAgainst(networks)
stateController.update(
EarnFilterSelectedStateTransformer(
filterType = typeFilterUM,
filterNetwork = networkFilterUM,
filterType = EarnFilterTypeConverter().convert(resolved.earnFilterType),
filterNetwork = EarnFilterNetworkConverter().convert(resolved.earnFilterNetwork),
earnNetworks = networks,
),
)

View file

@ -0,0 +1,129 @@
package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.EARN_TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.domain.models.earn.PreselectedEarnType
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class DefaultEarnDeepLinkHandlerTest {
private val appRouter: AppRouter = mockk()
@BeforeEach
fun setUp() {
every { appRouter.push(any(), any()) } just Runs
}
@Test
fun `no params opens earn list with no filters`() {
DefaultEarnDeepLinkHandler(queryParams = emptyMap(), appRouter = appRouter)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `staking earnType is parsed`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(EARN_TYPE_KEY to "staking"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = PreselectedEarnType.Staking, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `yield earnType is parsed`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(EARN_TYPE_KEY to "yield"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = PreselectedEarnType.Yield, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `invalid earnType is silently dropped`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(EARN_TYPE_KEY to "bogus"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `networkId is passed through`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(NETWORK_ID_KEY to "base"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = "base"),
any(),
)
}
}
@Test
fun `blank networkId is dropped`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(NETWORK_ID_KEY to " "),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `earnType and networkId together`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(EARN_TYPE_KEY to "yield", NETWORK_ID_KEY to "base"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = "base",
),
any(),
)
}
}
}

View file

@ -0,0 +1,247 @@
package com.tangem.features.feed.deeplink
import arrow.core.Either
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import com.tangem.utils.logging.TangemLogger
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultYieldDeepLinkHandlerTest {
private val appRouter: AppRouter = mockk()
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase = mockk()
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk()
private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase = mockk()
private val tokenId = "usd-coin"
private val networkId = "base"
@BeforeEach
fun setUp() {
mockkObject(TangemLogger)
every { appRouter.push(any(), any()) } just Runs
}
@Test
fun `missing token_id falls back to earn yield`() = runTest {
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(NETWORK_ID_KEY to networkId),
)
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
@Test
fun `missing network_id falls back to earn yield`() = runTest {
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId),
)
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = null,
),
any(),
)
}
}
@Test
fun `no selected wallet falls back to earn yield`() = runTest {
every { getSelectedWalletSyncUseCase() } returns Either.Left(mockk())
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
@Test
fun `token not in wallet falls back to earn yield`() = runTest {
val walletId = UserWalletId("011")
val wallet = mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet)
coEvery {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
MultiWalletCryptoCurrenciesProducer.Params(walletId),
)
} returns emptySet()
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
@Test
fun `eligible token with yield available pushes YieldSupplyEntry`() = runTest {
val walletId = UserWalletId("011")
val wallet = mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
val currency = mockEligibleCurrency()
every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet)
coEvery {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
MultiWalletCryptoCurrenciesProducer.Params(walletId),
)
} returns setOf(currency)
coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns
Either.Right(YieldSupplyAvailability.Available(apy = "2.66"))
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
match<AppRoute.YieldSupplyEntry> {
it.userWalletId == walletId && it.cryptoCurrency === currency && it.apy == "2.66"
},
any(),
)
}
}
@Test
fun `eligible token with yield unavailable falls back to earn yield`() = runTest {
val walletId = UserWalletId("011")
val wallet = mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
val currency = mockEligibleCurrency()
every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet)
coEvery {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
MultiWalletCryptoCurrenciesProducer.Params(walletId),
)
} returns setOf(currency)
coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns
Either.Right(YieldSupplyAvailability.Unavailable)
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
@Test
fun `eligible token with availability lookup error falls back to earn yield`() = runTest {
val walletId = UserWalletId("011")
val wallet = mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
val currency = mockEligibleCurrency()
every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet)
coEvery {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
MultiWalletCryptoCurrenciesProducer.Params(walletId),
)
} returns setOf(currency)
coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns
Either.Left(IllegalStateException("api 500"))
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
/** Builds a [CryptoCurrency] mock whose `network.rawId` and `id.rawCurrencyId` match the test's
* [tokenId] / [networkId] i.e. the supplier-lookup predicate inside the handler returns it. */
private fun mockEligibleCurrency(): CryptoCurrency = mockk(relaxed = true) {
every { network } returns mockk<Network>(relaxed = true) { every { rawId } returns networkId }
every { id } returns mockk(relaxed = true) {
every { rawCurrencyId } returns CryptoCurrency.RawID(tokenId)
}
}
private fun handle(scope: CoroutineScope, queryParams: Map<String, String>) {
DefaultYieldDeepLinkHandler(
scope = scope,
queryParams = queryParams,
appRouter = appRouter,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
yieldSupplyGetAvailabilityUseCase = yieldSupplyGetAvailabilityUseCase,
)
}
}

View file

@ -29,12 +29,12 @@ import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.hotwallet.createhardwarewallet.entity.CreateHardwareWalletUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
private const val HIDE_PROGRESS_DELAY = 400L
@ -181,7 +181,10 @@ internal class CreateHardwareWalletModel @Inject constructor(
)
userWalletsListRepository.unlock(
userWalletId = walletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse),
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(
scanResponse = scanResponse,
source = AnalyticsParam.ScreensSources.AddNew,
),
).onRight {
router.replaceAll(AppRoute.Wallet)
}

View file

@ -115,7 +115,10 @@ internal class UpgradeWalletModel @Inject constructor(
)
tangemSdkManager
.scanProduct(shouldCheckIsAlreadyActivated = true)
.scanProduct(
shouldCheckIsAlreadyActivated = true,
source = AnalyticsParam.ScreensSources.Upgrade,
)
.doOnSuccess { scanResponse ->
checkIsWalletSuitableToBeUsedAsUpgrade(scanResponse = scanResponse) {
delay(DELAY_SDK_DIALOG_CLOSE)

View file

@ -7,7 +7,6 @@ import com.tangem.common.ui.notifications.NotificationId
import com.tangem.common.ui.userwallet.handle
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.Basic.ButtonSupport
import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
@ -174,7 +173,6 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
}
override fun onGenerateMissedAddressesClick(missedAddressCurrencies: List<CryptoCurrency>) {
analyticsEventHandler.send(Basic.CardWasScanned(AnalyticsParam.ScreensSources.Main))
analyticsEventHandler.send(MainScreen.NoticeScanYourCardTapped())
modelScope.launch {
@ -199,7 +197,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
userWalletsListRepository.unlockAllWallets()
.onLeft {
val selectedUserWalletId = stateHolder.getSelectedWalletId()
nonBiometricUnlockWalletUseCase(selectedUserWalletId)
nonBiometricUnlockWalletUseCase(selectedUserWalletId, AnalyticsParam.ScreensSources.Main)
.onLeft { error ->
error.handle(
onAlreadyUnlocked = {},

View file

@ -205,8 +205,11 @@ internal class WelcomeModel @Inject constructor(
).onLeft { error ->
if (error is SaveWalletError.WalletAlreadySaved) {
userWalletsListRepository.unlock(
userWallet.walletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse),
userWalletId = userWallet.walletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(
scanResponse = scanResponse,
source = AnalyticsParam.ScreensSources.SignIn,
),
).onRight {
userWalletsListRepository.select(userWallet.walletId)
router.replaceAll(AppRoute.Wallet)
@ -271,7 +274,7 @@ internal class WelcomeModel @Inject constructor(
}
private suspend fun nonBiometricUnlockWallet(userWalletId: UserWalletId) {
nonBiometricUnlockWalletUseCase(userWalletId)
nonBiometricUnlockWalletUseCase(userWalletId, AnalyticsParam.ScreensSources.SignIn)
.onRight {
routedOut = true
userWalletsListRepository.select(userWalletId)

View file

@ -14,6 +14,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.visa.models)
api(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.core.res)

View file

@ -12,6 +12,7 @@ import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.UserCodeRequestPolicy
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.services.secure.SecureStorage
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.models.scan.CardDTO
@ -57,6 +58,7 @@ interface TangemSdkManager {
messageRes: Int? = null,
allowsRequestAccessCodeFromRepository: Boolean = false,
shouldCheckIsAlreadyActivated: Boolean,
source: AnalyticsParam.ScreensSources,
): CompletionResult<ScanResponse>
suspend fun createProductWallet(