Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-02 11:18:27 +02:00
commit 788d7064b5
217 changed files with 6061 additions and 1892 deletions

View file

@ -36,6 +36,9 @@ fun BaseTestCase.checkMultiCurrencyMainScreen(
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
}
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Swap' button is displayed") {
onMainScreen { swapButton.assertIsDisplayed() }
}
@ -55,8 +58,8 @@ fun BaseTestCase.checkMultiCurrencyMainScreen(
fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean = true) {
if (isEnabled) {
step("Assert 'Buy' button is enabled") {
onMainScreen { buyButton.assertIsEnabled() }
step("Assert 'Add funds' button is enabled") {
onMainScreen { addFundsButton.assertIsEnabled() }
}
step("Assert 'Swap' button is enabled") {
onMainScreen { swapButton.assertIsEnabled() }
@ -65,8 +68,8 @@ fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean =
onMainScreen { sellButton.assertIsEnabled() }
}
} else {
step("Assert 'Buy' button is not enabled") {
onMainScreen { buyButton.assertIsNotEnabled() }
step("Assert 'Add funds' button is not enabled") {
onMainScreen { addFundsButton.assertIsNotEnabled() }
}
step("Assert 'Swap' button is not enabled") {
onMainScreen { swapButton.assertIsNotEnabled() }

View file

@ -0,0 +1,39 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
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 androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
/**
* "You receive" token chooser opened from the main-screen "Add funds" button.
*/
class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ChooseTokenPageObject>(semanticsProvider = semanticsProvider) {
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
useUnmergedTree = true
}
val searchBar: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
}
fun tokenWithTitle(tokenTitle: String): KNode = child {
hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
hasAnyDescendant(withTestTag(TokenElementsTestTags.TOKEN_TITLE))
hasAnyDescendant(withText(tokenTitle))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onChooseTokenScreen(function: ChooseTokenPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,45 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
/**
* "Get token" bottom sheet shown after picking a token in the Add funds flow.
* Contains quick actions (Buy / Receive / ) and the "Go to token" button.
*/
class GetTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<GetTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
}
val closeButton: KNode = child {
hasTestTag(BaseBottomSheetTestTags.CLOSE_BUTTON)
}
// The "Get token" sheet action rows use combinedClickable; the row's testTag lands on a
// separate zero-bounds semantics node that fails assertIsDisplayed. Matching the merged node
// by its title text yields the displayed, clickable row (performClick injects a touch at its
// center, which the row's clickable handles).
val buyButton: KNode = child {
hasText(getResourceString(R.string.common_buy))
}
val receiveButton: KNode = child {
hasText(getResourceString(R.string.common_receive))
}
val goToTokenButton: KNode = child {
hasText(getResourceString(R.string.common_go_to_token))
}
}
internal fun BaseTestCase.onGetTokenBottomSheet(function: GetTokenBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -55,6 +55,11 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}
val addFundsButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_add_funds))
}
val sendButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasAnyDescendant(withText(getResourceString(R.string.common_send)))

View file

@ -12,7 +12,7 @@ class TangemPayAddFundsSheetPageObject(semanticsProvider: SemanticsNodeInteracti
ComposeScreen<TangemPayAddFundsSheetPageObject>(semanticsProvider = semanticsProvider) {
val swapOption: KNode = child {
hasText(getResourceString(CoreResR.string.common_exchange))
hasText(getResourceString(CoreResR.string.tangempay_topup_swap_title))
useUnmergedTree = true
}

View file

@ -40,15 +40,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Assert error notification title is displayed") {
onBuyTokenDetailsScreen { errorNotificationTitle.assertIsDisplayed() }
}
@ -84,15 +87,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}
@ -155,15 +161,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}
@ -238,15 +247,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}
@ -320,15 +332,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}
@ -406,15 +421,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}

View file

@ -422,17 +422,17 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.performClick() }
}
step("Assert 'Buy' screen title is displayed") {
onBuyTokenScreen { topAppBarTitle.assertIsDisplayed() }
step("Assert 'Choose token' screen title is displayed") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Assert token with title: '$tokenTitle' is displayed") {
onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).assertIsDisplayed() }
onChooseTokenScreen { tokenWithTitle(tokenTitle).assertIsDisplayed() }
}
step("Press 'Back' button") {
device.uiDevice.pressBack()
@ -481,17 +481,18 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.performClick() }
}
step("Check 'Action is unavailable' dialog") {
checkActionIsUnavailableDialog()
step("Assert 'Choose token' screen opens (Add funds is always available)") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Click on 'Ok' button") {
onDialog { okButton.performClick() }
step("Press 'Back' to return to main screen") {
device.uiDevice.pressBack()
waitForIdle()
}
step("Assert 'Swap' button is displayed") {
onMainScreen { swapButton.assertIsDisplayed() }
@ -538,17 +539,18 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.performClick() }
}
step("Check 'Action is unavailable' dialog") {
checkActionIsUnavailableDialog()
step("Assert 'Choose token' screen opens (Add funds is always available)") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Click on 'Ok' button") {
onDialog { okButton.performClick() }
step("Press 'Back' to return to main screen") {
device.uiDevice.pressBack()
waitForIdle()
}
step("Assert 'Swap' button is displayed") {
onMainScreen { swapButton.assertIsDisplayed() }

View file

@ -51,9 +51,12 @@ class HideTokenTest : BaseTestCase() {
dialogContainer.assertIsDisplayed()
okButton.clickWithAssertion()
}
waitForIdle()
}
step("Assert token: '$tokenTitle' is not displayed") {
onMainScreen { assertTokenDoesNotExist(tokenTitle) }
flakySafely {
onMainScreen { assertTokenDoesNotExist(tokenTitle) }
}
}
}
}

View file

@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddAndManageBottomSheet
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
@ -86,6 +88,15 @@ class MainScreenTest : BaseTestCase() {
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
}
step("Click 'Add & Manage' button") {
onMainScreen { addAndManageButtonNode.clickWithAssertion() }
}
step("Assert 'Organize tokens' option is not displayed (nothing to organize)") {
onAddAndManageBottomSheet { organizeTokensButton.assertIsNotDisplayed() }
}
step("Assert 'Add tokens' option is displayed") {
onAddAndManageBottomSheet { addTokensButton.assertIsDisplayed() }
}
}
}

View file

@ -170,7 +170,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
private val onActivityResultCallbacks = mutableListOf<OnActivityResultCallback>()
override fun onCreate(savedInstanceState: Bundle?) {
TangemLogger.i("onCreate")
TangemLogger.i("onCreate: data=${intent?.data}, extras=${intent?.extras?.keySet()}")
// We need to call it before onCreate to prevent unnecessary activity recreation
installAppTheme()

View file

@ -14,12 +14,14 @@ class AppsFlyerDeepLinkListener @Inject constructor(
override fun onDeepLinking(p0: DeepLinkResult) {
when (p0.status) {
DeepLinkResult.Status.FOUND -> {
referralParamsHandler.handle(deepLink = p0.deepLink)
referralParamsHandler.handleDeeplink(deepLink = p0.deepLink)
}
DeepLinkResult.Status.NOT_FOUND -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.i("No deep link found")
}
DeepLinkResult.Status.ERROR -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.e("Deep link error: ${p0.error}")
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -23,14 +24,7 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
) {
private val mutex = Mutex()
fun handle(deepLink: DeepLink) {
handle(
deepLinkValue = deepLink.deepLinkValue,
deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1),
deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2),
)
}
private val deepLinkDeferred = CompletableDeferred<String?>()
fun handle(params: Map<String?, Any?>) {
handle(
@ -40,6 +34,31 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
)
}
fun handleDeeplink(deepLink: DeepLink) {
handle(
deepLinkValue = deepLink.deepLinkValue,
deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1),
deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2),
)
deepLinkDeferred.complete(deepLink.deepLinkValue)
}
fun handleNoDeeplink() {
deepLinkDeferred.complete(null)
}
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
val deeplinkFromCache = appsFlyerStore.getDeeplink(deeplinkSource)
return if (deeplinkFromCache == null) {
val value = when (deeplinkSource) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE
}
deepLinkDeferred.await().takeIf { it == value }
} else {
deeplinkFromCache
}
}
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
TangemLogger.i("AppsFlyer deeplink received: value=$deepLinkValue")
when (deepLinkValue) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,7 +32,6 @@ import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.scan.ScanResponse
@ -54,6 +53,7 @@ import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.android.create
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
@ -70,6 +70,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
@Suppress("LongParameterList", "LargeClass")
internal class DefaultRoutingComponent @AssistedInject constructor(
@ -88,7 +90,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
private val appsFlyerStore: AppsFlyerStore,
private val appsFlyerReferralParamsHandler: AppsFlyerReferralParamsHandler,
private val trackingContextProxy: TrackingContextProxy,
private val scanFailsComponentFactory: ScanFailsComponent.Factory,
private val scanFailsRequesterProxy: ScanFailsRequesterProxy,
@ -212,11 +214,10 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
)
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled")
if (isHotWalletOnboardingEnabled) {
val tangemPayHotWalletOnboardingDeepLink = appsFlyerStore.getDeeplink(
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding,
)
val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) {
appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
}
TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}")
if (tangemPayHotWalletOnboardingDeepLink != null) {
val hotWalletRoute = AppRoute.TangemPayHotWalletOnboarding

View file

@ -9,7 +9,6 @@ import com.tangem.feature.stories.api.StoriesComponent
import com.tangem.feature.usedesk.api.UsedeskComponent
import com.tangem.feature.walletsettings.component.WalletSettingsComponent
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.createwalletselection.CreateWalletSelectionComponent
@ -116,7 +115,6 @@ internal class ChildFactory @Inject constructor(
private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val addFundsComponentFactory: AddFundsComponent.Factory,
) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -237,13 +235,6 @@ internal class ChildFactory @Inject constructor(
componentFactory = buyCryptoComponentFactory,
)
}
is AppRoute.AddFunds -> {
createComponentChild(
context = context,
params = AddFundsComponent.Params(userWalletId = route.userWalletId),
componentFactory = addFundsComponentFactory,
)
}
is AppRoute.SellCrypto -> {
createComponentChild(
context = context,

View file

@ -29,15 +29,19 @@ class AppsFlyerDeepLinkListenerTest {
@ProvideTestModels
fun onDeepLinking(model: OnDeepLinkingModel) = runTest {
if (model.shouldHandle) {
every { referralParamsHandler.handle(deepLink = model.deepLinkResult.deepLink) } just Runs
every { referralParamsHandler.handleDeeplink(deepLink = model.deepLinkResult.deepLink) } just Runs
} else {
every { referralParamsHandler.handleNoDeeplink() } just Runs
}
listener.onDeepLinking(p0 = model.deepLinkResult)
if (model.shouldHandle) {
coVerify { referralParamsHandler.handle(deepLink = model.deepLinkResult.deepLink) }
coVerify { referralParamsHandler.handleDeeplink(deepLink = model.deepLinkResult.deepLink) }
verify(inverse = true) { referralParamsHandler.handleNoDeeplink() }
} else {
coVerify(inverse = true) { referralParamsHandler.handle(deepLink = any()) }
coVerify(inverse = true) { referralParamsHandler.handleDeeplink(deepLink = any()) }
verify { referralParamsHandler.handleNoDeeplink() }
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
@ -15,6 +17,7 @@ import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@ -46,7 +49,7 @@ class AppsFlyerReferralParamsHandlerTest {
@ParameterizedTest
@ProvideTestModels
fun handle(model: HandleDeepLinkModel) = runTest {
handler.handle(deepLink = model.deepLink)
handler.handleDeeplink(deepLink = model.deepLink)
if (model.shouldStore) {
val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN)
@ -165,6 +168,78 @@ class AppsFlyerReferralParamsHandlerTest {
data class HandleParamsModel(val params: Map<String?, Any?>, val shouldStore: Boolean)
@Nested
inner class WaitForDeeplink {
private val localStore: AppsFlyerStore = mockk(relaxUnitFun = true)
private val localHandler = AppsFlyerReferralParamsHandler(
appsFlyerStore = localStore,
coroutineScope = TestAppCoroutineScope(),
setShouldShowMobileWalletPromoUseCase = mockk { coEvery { this@mockk.invoke(true) } returns Unit.right() },
)
@Test
fun `GIVEN cached deeplink WHEN waitForDeeplink THEN returns cached value`() = runTest {
// GIVEN
coEvery {
localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
} returns "tpay_mobileonboard"
// WHEN
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isEqualTo("tpay_mobileonboard")
}
@Test
fun `GIVEN no cache and matching deeplink WHEN handleDeeplink then waitForDeeplink THEN returns deeplink value`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "tpay_mobileonboard"
every { getStringValue(any()) } returns null
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isEqualTo("tpay_mobileonboard")
}
@Test
fun `GIVEN no cache and non-matching deeplink WHEN handleDeeplink then waitForDeeplink THEN returns null`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "referral"
every { getStringValue(any()) } returns null
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN no cache WHEN handleNoDeeplink then waitForDeeplink THEN returns null`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
// WHEN
localHandler.handleNoDeeplink()
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isNull()
}
}
private companion object Companion {
const val SUCCESS_REFCODE = "valid_refcode"
const val SUCCESS_CAMPAIGN = "valid_campaign"

View file

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