diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22f93e0530..9d754f3add 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -283,6 +283,8 @@ dependencies { implementation(projects.features.onboardingV2.impl) implementation(projects.features.stories.api) implementation(projects.features.stories.impl) + implementation(projects.features.survey.api) + implementation(projects.features.survey.impl) implementation(projects.features.txhistory.api) implementation(projects.features.txhistory.impl) implementation(projects.features.biometry.api) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 15e6bfcafc..455df5348b 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -183,6 +183,7 @@ abstract class BaseTestCase : TestCase( "GASLESS_APPROVAL_ENABLED" to true, "MAIN_SCREEN_QR_SCANNING_ENABLED" to true, "ADD_AND_MANAGE_TOKENS_ENABLED" to true, + "ASSETS_DISCOVERY_ENABLED" to true, "VISA_ONBOARDING_ENABLED" to true, "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true, "AND_15310_ADD_FUNDS_STAGE1" to true, diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index fc4d42796b..e9cf254d43 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -48,6 +48,10 @@ object TestConstants { const val USER_TOKENS_API_SCENARIO = "user_tokens_api" const val REFERRAL_API_SCENARIO = "referral_api" const val QUOTES_API_SCENARIO = "quotes_api" + const val CREATE_USER_WALLET_API_SCENARIO = "create_user_wallet_api" + const val WALLET_TOKENS_API_SCENARIO = "wallet_tokens_api" + const val MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO = "moralis_evm_token_balances_api" + const val PROVIDERS_API_SCENARIO = "networks_providers" const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk" const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " + @@ -60,6 +64,9 @@ object TestConstants { "bread much nature basic fun iron benefit egg error prosper" const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash" + const val SEED_PHRASE_HAPPY_PATH = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility" const val TANGEM_PAY_ACCESS_CODE = "517384" } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index ce7201883e..82bc1fb24d 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.swipeUp import com.tangem.common.BaseTestCase @@ -23,7 +24,7 @@ import androidx.compose.ui.test.hasText as withText import com.tangem.core.res.R as CoreResR import com.tangem.core.ui.R as CoreUiR -class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : +class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { private val lazyList = KLazyListNode( @@ -100,6 +101,22 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + val restoringProgressText: KNode = child { + hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT) + useUnmergedTree = true + } + + val walletImportedBanner: KNode = child { + hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER) + useUnmergedTree = true + } + + val walletImportedBannerCheckHereButton: KNode = child { + hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)) + hasText(getResourceString(CoreResR.string.main_manage_tokens)) + useUnmergedTree = true + } + @OptIn(ExperimentalTestApi::class) fun marketPriceBlock(): LazyListItemNode { collapseHeader() @@ -253,6 +270,15 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + @OptIn(ExperimentalTestApi::class) + fun tokenRowWithTitle(tokenTitle: String): LazyListItemNode { + return lazyList.childWith { + hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } + } + /** * Find token list item with title and address */ @@ -350,6 +376,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } } + + fun assertTokensCount(expectedCount: Int) { + semanticsProvider + .onAllNodes(withTestTag(TokenElementsTestTags.TOKEN_PRICE)) + .assertCountEquals(expectedCount) + } } internal fun BaseTestCase.onMainScreen(function: MainScreenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt new file mode 100644 index 0000000000..c96032df3a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt @@ -0,0 +1,251 @@ +package com.tangem.tests.hotWallet + +import androidx.test.InstrumentationRegistry.getTargetContext +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.CREATE_USER_WALLET_API_SCENARIO +import com.tangem.common.constants.TestConstants.MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO +import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO +import com.tangem.common.constants.TestConstants.SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_HAPPY_PATH +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WALLET_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.restartApp +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreenWithExistingHotWallet +import com.tangem.screens.* +import com.tangem.screens.accounts.onAccountDetailsScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test +import com.tangem.core.ui.R as CoreUiR + +@HiltAndroidTest +class AssetsDiscoveryTest : BaseTestCase() { + + private companion object { + const val DISCOVERY_TIMEOUT_MILLIS = 120_000L + + const val SCENARIO_STATE_STARTED = "Started" + const val SCENARIO_STATE_EMPTY = "Empty" + const val SCENARIO_STATE_ALREADY_EXISTS = "AlreadyExists" + const val SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT = "AssetsDiscoveryRedirect" + const val SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH = "AssetsDiscoveryHappyPath" + const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES = "NonZeroEvmBalances" + const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW = "NonZeroEvmBalancesSlow" + + val EXPECTED_DISCOVERED_TOKENS = listOf( + "Ethereum", + "Polygon", + "Tether", + ) + + val TOKENS_THAT_MUST_NOT_APPEAR = listOf( + "Solana", + "USDC", + ) + + val BACKEND_PRE_POPULATED_TOKENS = listOf( + "Bitcoin", + "Ethereum", + "Polygon", + ) + } + + @AllureId("9280") + @DisplayName("Hot wallet: new import — Discovery → Sync → Banner → Check here happy path") + @Test + fun newHotWalletImportHappyPathTest() { + val packageName = getTargetContext().packageName + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT) + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH) + setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES) + }, + additionalAfterSection = { + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import a new hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH) + } + step("Assert 'Restoring' progress loader is shown (discovery is in flight)") { + onMainScreen { restoringProgressText.assertIsDisplayed() } + } + step("Wait for 'Wallet successfully imported' banner (discovery completes)") { + flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) { + onMainScreen { walletImportedBanner.assertIsDisplayed() } + } + } + step("Assert expected discovered tokens are visible in the assets list") { + onMainScreen { + EXPECTED_DISCOVERED_TOKENS.forEach { token -> + tokenRowWithTitle(token).assertIsDisplayed() + } + } + } + step("Tap 'Check here' (Manage tokens) on the banner") { + onMainScreen { walletImportedBannerCheckHereButton.clickWithAssertion() } + } + step("Assert 'Manage Tokens' screen is opened") { + onManageTokensScreen { searchField.assertIsDisplayed() } + } + step("Return to main screen") { + device.uiDevice.pressBack() + waitForIdle() + } + step("Assert banner is hidden after navigating into Manage Tokens") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + step("Force-close and re-launch the app") { + restartApp(packageName) + } + step("Assert banner is NOT shown again after relaunch") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + step("Assert previously discovered tokens still appear in the assets list") { + onMainScreen { + EXPECTED_DISCOVERED_TOKENS.forEach { token -> + tokenRowWithTitle(token).assertIsDisplayed() + } + } + } + step("Assert zero-balance and spam tokens are NOT shown in the assets list") { + onMainScreen { + TOKENS_THAT_MUST_NOT_APPEAR.forEach { token -> + assertTokenDoesNotExist(token) + } + } + } + } + } + + @AllureId("9284") + @DisplayName("Hot wallet: token added manually during Discovery — no duplicate created") + @Test + fun manualTokenAddDuringDiscoveryNoDuplicateTest() { + val tetherTitle = "Tether" + val ethereumNetworkTitle = "ETHEREUM" + val accountName = getResourceString(CoreUiR.string.account_main_account_title) + val expectedTokensCount = 4 + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT) + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH) + setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState( + MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, + state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW, + ) + }, + additionalAfterSection = { + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import a new hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH) + } + step("Assert 'Restoring' progress loader is shown (discovery is in flight)") { + onMainScreen { restoringProgressText.assertIsDisplayed() } + } + step("Open wallet details from top bar") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + step("Open 'Wallet settings'") { + onDetailsScreen { walletNameButton.performClick() } + } + step("Open account: '$accountName'") { + onWalletSettingsScreen { accountItem(accountName).performClick() } + } + step("Open 'Manage Tokens' from account details") { + onAccountDetailsScreen { manageTokensButton.performClick() } + } + step("Search for '$tetherTitle' in Manage Tokens") { + onManageTokensScreen { + searchField.performClick() + searchField.performTextInput(tetherTitle) + } + device.uiDevice.pressBack() + waitForIdle() + } + step("Expand '$tetherTitle'") { + onManageTokensScreen { tokenItem(tetherTitle).clickWithAssertion() } + waitForIdle() + } + step("Enable the $ethereumNetworkTitle network") { + onManageTokensScreen { networkSwitch(ethereumNetworkTitle).clickWithAssertion() } + } + step("Save Manage Tokens changes") { + onManageTokensScreen { saveButton.clickWithAssertion() } + waitForIdle() + } + step("Navigate back to main screen") { + repeat(times = 3) { + device.uiDevice.pressBack() + waitForIdle() + } + } + step("Wait for 'Wallet successfully imported' banner (discovery completes after delay)") { + flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) { + onMainScreen { walletImportedBanner.assertIsDisplayed() } + } + } + step("Assert '$tetherTitle' is in the assets list (manual add + discovery merged)") { + onMainScreen { tokenRowWithTitle(tetherTitle).assertIsDisplayed() } + } + step("Assert assets list contains exactly $expectedTokensCount tokens (no duplicate after merge)") { + onMainScreen { assertTokensCount(expectedTokensCount) } + } + } + } + + @AllureId("9282") + @DisplayName("Hot wallet: re-import existing wallet — 200 OK, no Discovery, tokens from backend") + @Test + fun reimportExistingHotWalletTest() { + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_ALREADY_EXISTS) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_EMPTY) + }, + additionalAfterSection = { + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import an existing hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_12) + } + step("Assert tokens from backend are displayed immediately") { + BACKEND_PRE_POPULATED_TOKENS.forEach { token -> + onMainScreen { tokenRowWithTitle(token).assertIsDisplayed() } + } + } + step("Assert 'Restoring' loader is NOT displayed (discovery did not start)") { + onMainScreen { restoringProgressText.assertIsNotDisplayed() } + } + step("Assert 'Wallet successfully imported' banner is NOT displayed") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6139be58a..36cca67394 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -210,6 +210,17 @@ android:scheme="tangem" /> + + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt index e24d5594e5..0cd2024584 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.SignIn import com.tangem.domain.card.analytics.IntroductionProcess +import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent class HotWalletContextInterceptor( val parent: ParamsInterceptor? = null, @@ -18,6 +19,7 @@ class HotWalletContextInterceptor( is SignIn.ButtonAddWallet, is SignIn.ButtonUnlockAllWithBiometric, is IntroductionProcess.ButtonScanCard, + is TokenScreenAnalyticsEvent.ButtonQuickTopUp, -> false is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll else -> true diff --git a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt index 0b6f9c503e..61ac194a7f 100644 --- a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt +++ b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt @@ -6,6 +6,8 @@ import android.net.Uri import androidx.core.net.toUri import com.tangem.common.routing.DeepLinkScheme import com.tangem.common.uri.ExternalUrlValidator +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.url.UrlOpener import com.tangem.utils.logging.TangemLogger @@ -17,6 +19,7 @@ import com.tangem.utils.logging.TangemLogger internal class DefaultDeeplinkLauncher( private val context: Context, private val urlOpener: UrlOpener, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, ) : DeeplinkLauncher { override fun launch(link: String) { @@ -58,11 +61,33 @@ internal class DefaultDeeplinkLauncher( } private fun launchDeepLink(uri: Uri) { - context.startActivity(createDeepLinkIntent(uri)) + val intent = createDeepLinkIntent(uri) + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + TangemLogger.i( + """ + No match found for deep link + |- Received URI: $uri + """.trimIndent(), + ) + analyticsExceptionHandler.sendException( + ExceptionAnalyticsEvent( + exception = UnresolvedDeeplinkException(uri), + params = mapOf( + "uri_scheme" to uri.scheme.orEmpty(), + "uri_host" to uri.host.orEmpty(), + ), + ), + ) + } } private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply { setPackage(context.packageName) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } -} \ No newline at end of file +} + +internal class UnresolvedDeeplinkException(uri: Uri) : + RuntimeException("Deeplink has no matching activity: scheme=${uri.scheme}, host=${uri.host}") \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index 492904d18a..d8cb34aece 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di import android.content.Context +import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.tap.common.deeplink.DefaultDeeplinkLauncher import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.finisher.AppFinisher @@ -55,7 +56,10 @@ internal interface UtilsModule { @Provides @Singleton - fun provideDeeplinkLauncher(@ApplicationContext context: Context, urlOpener: UrlOpener): DeeplinkLauncher = - DefaultDeeplinkLauncher(context, urlOpener) + fun provideDeeplinkLauncher( + @ApplicationContext context: Context, + urlOpener: UrlOpener, + analyticsExceptionHandler: AnalyticsExceptionHandler, + ): DeeplinkLauncher = DefaultDeeplinkLauncher(context, urlOpener, analyticsExceptionHandler) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 3be7df1c1f..41205cfc6e 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -21,6 +21,7 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.home.api.HomeComponent import com.tangem.features.hotwallet.* import com.tangem.features.kyc.KycComponent +import com.tangem.features.survey.SurveyComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensMode @@ -112,6 +113,7 @@ internal class ChildFactory @Inject constructor( private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, + private val surveyComponentFactory: SurveyComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val feedEntryComponentFactory: FeedEntryComponent.Factory, private val addFundsComponentFactory: AddFundsComponent.Factory, @@ -216,6 +218,7 @@ internal class ChildFactory @Inject constructor( userWalletId = route.userWalletId, cryptoCurrency = route.currency, source = route.source, + initialFiatAmount = route.initialFiatAmount, ), componentFactory = onrampComponentFactory, ) @@ -701,6 +704,13 @@ internal class ChildFactory @Inject constructor( componentFactory = kycComponentFactory, ) } + is AppRoute.Survey -> { + createComponentChild( + context = context, + params = SurveyComponent.Params(token = route.token, displayId = route.displayId), + componentFactory = surveyComponentFactory, + ) + } is AppRoute.YieldSupplyEntry -> { createComponentChild( context = context, diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 72fdba54ab..e886e10e14 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -19,6 +19,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler @@ -62,6 +63,7 @@ internal class DeepLinkFactory @Inject constructor( private val newsDeepLink: NewsDeepLinkHandler.Factory, private val earnDeepLink: EarnDeepLinkHandler.Factory, private val yieldDeepLink: YieldDeepLinkHandler.Factory, + private val surveyDeepLink: SurveyDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -175,6 +177,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams) else -> { TangemLogger.i( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index b256302118..1650ffedea 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -11,6 +11,7 @@ import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHand import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler @@ -99,6 +100,10 @@ class DeepLinkFactoryTest { every { create(any()) } returns mockk() } + private val surveyDeepLinkFactory = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + private val earnDeepLinkFactory = mockk(relaxed = true) { every { create(any()) } returns mockk() } @@ -140,6 +145,7 @@ class DeepLinkFactoryTest { newsDeepLink = newsDeepLinkFactory, earnDeepLink = earnDeepLinkFactory, yieldDeepLink = yieldDeepLinkFactory, + surveyDeepLink = surveyDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 4854b69392..02d03d772d 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -297,6 +297,7 @@ sealed class AppRoute(val path: String) : Route { val source: OnrampSource, val userWalletId: UserWalletId, val currency: CryptoCurrency, + val initialFiatAmount: SerializedBigDecimal? = null, ) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) } @@ -497,6 +498,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") + @Serializable + data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey") + @Serializable data class YieldSupplyEntry( val userWalletId: UserWalletId, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index ee03ee94b4..e2e31a626c 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -87,6 +87,10 @@ sealed class DeepLinkRoute { data object PayAppMain : DeepLinkRoute() { override val host: String = "pay-app-main" } + + data object Survey : DeepLinkRoute() { + override val host: String = "survey" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index fbadecf45f..97d5033769 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -328,6 +328,7 @@ sealed class AnalyticsParam { const val WALLET_TYPE = "Wallet Type" const val BACKUPED = "Backuped" const val MEMO = "Memo" + const val VALUE = "Value" } } diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 8eed9a1b0a..2fe3cc2043 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -68,7 +68,7 @@ "version": "undefined" }, { - "name": "SWAP_INTEGRATED_APPROVE", + "name": "AND_15120_SWAP_INTEGRATED_APPROVE", "version": "undefined" }, { @@ -114,5 +114,13 @@ { "name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED", "version": "undefined" + }, + { + "name": "AND_15482_SURVEYSPARROW_ENABLED", + "version": "undefined" + }, + { + "name": "AND_15258_QUICK_TOP_UP_ENABLED", + "version": "undefined" } ] diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt index fa6ba51dee..b4d72d4288 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt @@ -50,7 +50,6 @@ internal class FeatureTogglesNamingConventionTest { "SOLANA_TX_HISTORY_ENABLED", "STAKING_ETH_ENABLED", "SWAP_AB_ENABLED", - "SWAP_INTEGRATED_APPROVE", "USEDESK_ENABLED", "VIRTUAL_ACCOUNTS_ENABLED", "VISA_ONBOARDING_ENABLED", diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt index 4451945383..430e41140a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt @@ -14,21 +14,39 @@ class WireMockRedirectInterceptor : Interceptor { val request = chain.request() val url = request.url.toString() + val host = request.url.host + val sanitizedOverride = override.trimEnd('/') - if (url.contains(WIREMOCK_REMOTE_URL)) { - val newUrl = url.replace(WIREMOCK_REMOTE_URL, override.trimEnd('/')) + if (host == WIREMOCK_REMOTE_HOST) { + val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride) TangemLogger.d("WireMockRedirect: $url -> $newUrl") - val newRequest = request.newBuilder() - .url(newUrl) - .build() - return chain.proceed(newRequest) + return chain.proceed(request.newBuilder().url(newUrl).build()) + } + + if (host in REDIRECTABLE_THIRD_PARTY_HOSTS) { + val newUrl = url.replace("https://$host", "$sanitizedOverride/$host") + TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl") + return chain.proceed(request.newBuilder().url(newUrl).build()) } return chain.proceed(request) } companion object { - private const val WIREMOCK_REMOTE_URL = "[REDACTED_ENV_URL]" + private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com" + private const val WIREMOCK_REMOTE_URL = "https://$WIREMOCK_REMOTE_HOST" + + /** + * Upstream hosts that have no other override knob and should be funnelled into WireMock + * when [overriddenBaseUrl] is set. Each matched URL becomes `//`, + * so mock mappings should live under that host-prefixed path in tangem-api-mocks. Matching + * is done against the request's parsed host (exact equality) — substring matching would + * incorrectly redirect look-alikes such as `deep-index.moralis.io.evil.example`. + */ + private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf( + "deep-index.moralis.io", + "solana-gateway.moralis.io", + ) /** * Override base URL for WireMock requests. diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index f7840693f7..a83c29e34b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -32,7 +32,7 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { val tangemDimens3 = remember { TangemDimens3() } val tangemTypography3 = remember { TangemTypography3(InterFamily) } val tangemTypography2 = remember { TangemTypography2(InterFamily) } - val tangemTypography = remember { TangemTypography(InterFamily) } + val tangemTypography = remember { TangemTypography(InterFamily, useMediumForRegular = true) } MaterialTheme( colorScheme = tangemColorScheme(colors = rememberedColors), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt index a86e82124c..21321045bc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt @@ -19,7 +19,10 @@ internal val RobotoFamily = FontFamily( @Immutable class TangemTypography internal constructor( fontFamily: FontFamily, + useMediumForRegular: Boolean = false, ) { + private val regularWeight: FontWeight = if (useMediumForRegular) FontWeight.Medium else FontWeight.Normal + val head: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 34.sp, @@ -34,7 +37,7 @@ class TangemTypography internal constructor( val h1: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 34.sp, - fontWeight = FontWeight.Normal, + fontWeight = regularWeight, letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp), lineHeight = TextUnit(value = 44f, type = TextUnitType.Sp), lineHeightStyle = LineHeightStyle( @@ -89,7 +92,7 @@ class TangemTypography internal constructor( val body1: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 16.sp, - fontWeight = FontWeight.Normal, + fontWeight = regularWeight, letterSpacing = TextUnit(value = 0.5f, type = TextUnitType.Sp), lineHeight = TextUnit(value = 24f, type = TextUnitType.Sp), lineHeightStyle = LineHeightStyle( @@ -100,7 +103,7 @@ class TangemTypography internal constructor( val body2: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 14.sp, - fontWeight = FontWeight.Normal, + fontWeight = regularWeight, letterSpacing = TextUnit(value = 0.25f, type = TextUnitType.Sp), lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), lineHeightStyle = LineHeightStyle( @@ -133,7 +136,7 @@ class TangemTypography internal constructor( val caption2: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 12.sp, - fontWeight = FontWeight.Normal, + fontWeight = regularWeight, letterSpacing = TextUnit(value = 0.4f, type = TextUnitType.Sp), lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), lineHeightStyle = LineHeightStyle( diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt index b8d5867f2e..c12417b238 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt @@ -11,6 +11,7 @@ object MainScreenTestTags { const val CARD_TITLE = "MAIN_SCREEN_CARD_TITLE" const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE" const val DEVICES_COUNT = "MAIN_SCREEN_DEVICES_COUNT" + const val SYNC_PROGRESS_TEXT = "MAIN_SCREEN_SYNC_PROGRESS_TEXT" const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE" const val TOTAL_BALANCE_MENU_ITEM = "MAIN_SCREEN_TOTAL_BALANCE_MENU_ITEM" diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletNotificationTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletNotificationTestTags.kt new file mode 100644 index 0000000000..a2ea41d594 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletNotificationTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object WalletNotificationTestTags { + const val ASSETS_DISCOVERY_BANNER = "WALLET_NOTIFICATION_ASSETS_DISCOVERY_BANNER" +} \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_replace_20.xml b/core/ui/src/main/res/drawable/ic_replace_20.xml new file mode 100644 index 0000000000..3fdf7b9e3d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_replace_20.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_visa_logo.xml b/core/ui/src/main/res/drawable/ic_visa_logo.xml new file mode 100644 index 0000000000..a03fb04772 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_visa_logo.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt index 4a864c1986..e9703340e3 100644 --- a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt +++ b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt @@ -18,4 +18,5 @@ object StringsSigns { const val PASSWORD_VISUAL_CHAR = '\u2022' const val APPROXIMATE = "≈" const val WHITE_SPACE = " " + const val LIGHTNING = "⚡" } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index 32709d5676..ba7056df00 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt @@ -5,8 +5,10 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FR import com.tangem.core.analytics.models.AnalyticsParam.Key.ACTION import com.tangem.core.analytics.models.AnalyticsParam.Key.BALANCE import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN +import com.tangem.core.analytics.models.AnalyticsParam.Key.CURRENCY import com.tangem.core.analytics.models.AnalyticsParam.Key.STATUS import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM +import com.tangem.core.analytics.models.AnalyticsParam.Key.VALUE import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason /** @@ -181,6 +183,21 @@ sealed class TokenScreenAnalyticsEvent( params = mapOf("Token" to token), ) + class ButtonQuickTopUp( + token: String, + blockchain: String, + currency: String, + value: String, + ) : TokenScreenAnalyticsEvent( + event = "Quick Top Up Button", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + CURRENCY to currency, + VALUE to value, + ), + ) + companion object { const val AVAILABLE = "Available" private const val UNAVAILABLE = "Unavailable" diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index fabfb0afa0..0ea374a12d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -190,11 +190,18 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( bottomSheetState = bottomSheetState, stackState = stackStack, onHeaderSizeChange = onHeaderSizeChange, - onExpandSheet = onExpandSheet, + onExpandSheet = { onCollapsedSheetClick(onExpandSheet) }, isOpenedInBottomSheet = true, ) } + private fun onCollapsedSheetClick(onExpandSheet: () -> Unit) { + if (stack.value.active.configuration is FeedEntryChildFactory.Child.Feed) { + clickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value) + } + onExpandSheet() + } + @Composable override fun Content(modifier: Modifier) { val bottomSheetState = remember { diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt index d77c070bac..090f282d60 100644 --- a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.models.wallet.UserWalletId +import java.math.BigDecimal interface OnrampComponent : ComposableContentComponent { @@ -12,6 +13,7 @@ interface OnrampComponent : ComposableContentComponent { val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val source: OnrampSource, + val initialFiatAmount: BigDecimal? = null, ) interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt index 98df5c2a8e..6b47640fa9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampSource +import java.math.BigDecimal internal interface OnrampMainComponent : ComposableContentComponent { @@ -15,6 +16,7 @@ internal interface OnrampMainComponent : ComposableContentComponent { val source: OnrampSource, val openSettings: () -> Unit, val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + val initialFiatAmount: BigDecimal? = null, ) interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt index 1d19b99737..46e69fe061 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt @@ -51,7 +51,7 @@ internal class OnrampStateFactory( ) } - fun getReadyState(currency: OnrampCurrency): OnrampMainComponentUM.Content { + fun getReadyState(currency: OnrampCurrency, initialFiatAmount: BigDecimal? = null): OnrampMainComponentUM.Content { val state = currentStateProvider() val endButton = when (val button = state.topBarConfig.endButtonUM) { @@ -59,7 +59,7 @@ internal class OnrampStateFactory( is TopAppBarButtonUM.Text -> button.copy(isEnabled = true) } - val initialAmountBlockState = getInitialAmountBlockState(currency) + val initialAmountBlockState = getInitialAmountBlockState(currency, initialFiatAmount) return OnrampMainComponentUM.Content( topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), @@ -136,7 +136,10 @@ internal class OnrampStateFactory( ) } - private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampAmountBlockUM { + private fun getInitialAmountBlockState( + currency: OnrampCurrency, + initialFiatAmount: BigDecimal? = null, + ): OnrampAmountBlockUM { return OnrampAmountBlockUM( currencyUM = OnrampCurrencyUM( code = currency.code, @@ -146,8 +149,8 @@ internal class OnrampStateFactory( unit = currency.unit, ), amountFieldModel = AmountFieldModel( - value = "", - fiatValue = "", + value = initialFiatAmount?.toPlainString().orEmpty(), + fiatValue = initialFiatAmount?.toPlainString().orEmpty(), onValueChange = onrampIntents::onAmountValueChanged, keyboardOptions = KeyboardOptions( imeAction = ImeAction.None, @@ -156,7 +159,7 @@ internal class OnrampStateFactory( keyboardActions = KeyboardActions(), isFiatValue = true, cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency), - fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency), + fiatAmount = (initialFiatAmount ?: BigDecimal.ZERO).convertToFiatAmount(currency), isError = false, isWarning = false, error = TextReference.EMPTY, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 63d3cce362..58a8f7c652 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -274,7 +274,7 @@ internal class OnrampMainComponentModel @Inject constructor( amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) } is OnrampMainComponentUM.InitialLoading -> { - stateFactory.getReadyState(country.defaultCurrency) + stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount) } } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt index daa379e850..5cce6b54bd 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt @@ -82,6 +82,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor( ), ) }, + initialFiatAmount = params.initialFiatAmount, ), ) is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create( diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt index 7abc5a48bd..108b6fe3e5 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.promobanners.impl.model +import androidx.core.net.toUri import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -118,7 +119,18 @@ internal class PromoBannersBlockModel @Inject constructor( private fun onButtonClick(displayId: Int, deeplink: String?) { analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName)) - deeplink?.let { deeplinkLauncher.launch(it) } + deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) } + } + + private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String { + val uri = deeplink.toUri() + val isSurveyDeeplink = uri.scheme == DEEPLINK_SCHEME_TANGEM && uri.host == DEEPLINK_HOST_SURVEY + if (!isSurveyDeeplink || uri.getQueryParameter(QUERY_DISPLAY_ID) != null) return deeplink + + return uri.buildUpon() + .appendQueryParameter(QUERY_DISPLAY_ID, displayId.toString()) + .build() + .toString() } private fun getInitialState() = PromoBannersBlockUM( @@ -152,4 +164,10 @@ internal class PromoBannersBlockModel @Inject constructor( } } } + + private companion object { + const val DEEPLINK_SCHEME_TANGEM = "tangem" + const val DEEPLINK_HOST_SURVEY = "survey" + const val QUERY_DISPLAY_ID = "display_id" + } } \ No newline at end of file diff --git a/features/survey/api/build.gradle.kts b/features/survey/api/build.gradle.kts new file mode 100644 index 0000000000..a0db7dc04e --- /dev/null +++ b/features/survey/api/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.survey.api" +} + +dependencies { + implementation(projects.core.decompose) + implementation(projects.core.ui) +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt new file mode 100644 index 0000000000..4fe9360d56 --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt @@ -0,0 +1,11 @@ +package com.tangem.features.survey + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface SurveyComponent : ComposableContentComponent { + + data class Params(val token: String, val displayId: String?) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt new file mode 100644 index 0000000000..c42ffbf905 --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.survey + +interface SurveyFeatureToggles { + + val areSurveysEnabled: Boolean +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt new file mode 100644 index 0000000000..84d518d39e --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt @@ -0,0 +1,14 @@ +package com.tangem.features.survey + +import android.app.Activity + +interface SurveySparrowLauncher { + + fun present(activity: Activity, data: SurveyLaunchData) +} + +data class SurveyLaunchData( + val domain: String, + val token: String, + val customParams: Map, +) \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt new file mode 100644 index 0000000000..a1d6abc36d --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.survey.deeplink + +interface SurveyDeepLinkHandler { + + interface Factory { + fun create(queryParams: Map): SurveyDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/survey/impl/build.gradle.kts b/features/survey/impl/build.gradle.kts new file mode 100644 index 0000000000..e512a05413 --- /dev/null +++ b/features/survey/impl/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.survey.impl" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + /* Project - API */ + implementation(projects.features.survey.api) + + /* Domain */ + implementation(projects.domain.common) + implementation(projects.domain.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) + + /* Core */ + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.configToggles) + implementation(projects.core.datasource) + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.core.utils) + + /* Common */ + implementation(projects.common.routing) + + /* DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /* Compose */ + implementation(deps.compose.runtime) + implementation(deps.compose.ui) + + /* Other */ + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + /** Tangem libraries */ + implementation(tangemDeps.card.core) + implementation(deps.surveysparrow) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt new file mode 100644 index 0000000000..bc06782acc --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt @@ -0,0 +1,73 @@ +package com.tangem.features.survey.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.features.survey.SurveyComponent +import com.tangem.features.survey.SurveyLaunchData +import com.tangem.features.survey.SurveySparrowLauncher +import com.tangem.features.survey.impl.service.SurveyCustomParamsBuilder +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class DefaultSurveyComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: SurveyComponent.Params, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val customParamsBuilder: SurveyCustomParamsBuilder, + private val surveySparrowLauncher: SurveySparrowLauncher, + @Suppress("UnusedPrivateProperty") // TODO([REDACTED_TASK_KEY]): emit [Survey] analytics events + private val analyticsEventHandler: AnalyticsEventHandler, +) : SurveyComponent, AppComponentContext by appComponentContext { + + init { + // componentScope runs on mainImmediate, so presenting the SDK is already on the main thread. + componentScope.launch { + val launchData = buildLaunchData() + if (launchData != null) { + surveySparrowLauncher.present(activity, launchData) + // TODO([REDACTED_TASK_KEY]): analyticsEventHandler.send(SurveyAnalyticsEvent.Shown(...)) + } + router.pop() + } + } + + private suspend fun buildLaunchData(): SurveyLaunchData? { + return getSelectedWalletSyncUseCase().fold( + ifLeft = { error -> + TangemLogger.e("$TAG: survey skipped, no available wallet ($error)") + null + }, + ifRight = { userWallet -> + SurveyLaunchData( + domain = SURVEY_DOMAIN, + token = params.token, + customParams = customParamsBuilder.build( + userWallet = userWallet, + token = params.token, + displayId = params.displayId, + ), + ) + }, + ) + } + + @Composable + override fun Content(modifier: Modifier) = Unit + + @AssistedFactory + interface Factory : SurveyComponent.Factory { + override fun create(context: AppComponentContext, params: SurveyComponent.Params): DefaultSurveyComponent + } + + private companion object { + const val TAG = "SurveyComponent" + const val SURVEY_DOMAIN = "tangem.surveysparrow.com" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt new file mode 100644 index 0000000000..6959c5bc9e --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt @@ -0,0 +1,14 @@ +package com.tangem.features.survey.impl + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.survey.SurveyFeatureToggles +import javax.inject.Inject + +internal class DefaultSurveyFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : SurveyFeatureToggles { + + override val areSurveysEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15482_SURVEYSPARROW_ENABLED) +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt new file mode 100644 index 0000000000..eb648e78b8 --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt @@ -0,0 +1,38 @@ +package com.tangem.features.survey.impl + +import android.app.Activity +import com.surveysparrow.ss_android_sdk.SsSurvey +import com.surveysparrow.ss_android_sdk.SurveySparrow +import com.tangem.features.survey.SurveyLaunchData +import com.tangem.features.survey.SurveySparrowLauncher +import com.tangem.utils.logging.TangemLogger +import javax.inject.Inject + +internal class DefaultSurveySparrowLauncher @Inject constructor() : SurveySparrowLauncher { + + override fun present(activity: Activity, data: SurveyLaunchData) { + if (activity.isFinishing || activity.isDestroyed) { + TangemLogger.e("$TAG: cannot present survey, activity is finishing/destroyed") + return + } + + val survey = try { + SsSurvey(data.domain, data.token).apply { + setSurveyType(SurveySparrow.CLASSIC) + data.customParams.forEach { (key, value) -> addCustomParam(key, value) } + } + } catch (e: Exception) { + TangemLogger.e("$TAG: failed to create SurveySparrow survey", e) + return + } + + // Result handling (onActivityResult -> [Survey] Completed/Dismissed) is planned in [REDACTED_TASK_KEY] + SurveySparrow(activity, survey).startSurveyForResult(SURVEY_REQUEST_CODE) + TangemLogger.d("$TAG: survey started (requestCode=$SURVEY_REQUEST_CODE)") + } + + private companion object { + const val TAG = "SurveySparrowPresenter" + const val SURVEY_REQUEST_CODE = 1001 + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt new file mode 100644 index 0000000000..013015eb8a --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt @@ -0,0 +1,47 @@ +package com.tangem.features.survey.impl.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.features.survey.SurveyFeatureToggles +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultSurveyDeepLinkHandler @AssistedInject constructor( + @Assisted private val queryParams: Map, + private val surveyFeatureToggles: SurveyFeatureToggles, + private val appRouter: AppRouter, +) : SurveyDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + if (!surveyFeatureToggles.areSurveysEnabled) { + TangemLogger.i("$TAG: survey deeplink ignored, feature is disabled") + return + } + + val token = queryParams[QUERY_TOKEN]?.takeIf { it.isNotBlank() } + if (token == null) { + TangemLogger.e("$TAG: survey deeplink ignored, missing 'token' query param") + return + } + + appRouter.push(AppRoute.Survey(token = token, displayId = queryParams[QUERY_DISPLAY_ID])) + } + + @AssistedFactory + interface Factory : SurveyDeepLinkHandler.Factory { + override fun create(queryParams: Map): DefaultSurveyDeepLinkHandler + } + + private companion object { + const val TAG = "SurveyDeepLink" + const val QUERY_TOKEN = "token" + const val QUERY_DISPLAY_ID = "display_id" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt new file mode 100644 index 0000000000..2af50ede8b --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt @@ -0,0 +1,36 @@ +package com.tangem.features.survey.impl.di + +import com.tangem.features.survey.SurveyComponent +import com.tangem.features.survey.SurveyFeatureToggles +import com.tangem.features.survey.SurveySparrowLauncher +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler +import com.tangem.features.survey.impl.DefaultSurveyComponent +import com.tangem.features.survey.impl.DefaultSurveyFeatureToggles +import com.tangem.features.survey.impl.DefaultSurveySparrowLauncher +import com.tangem.features.survey.impl.deeplink.DefaultSurveyDeepLinkHandler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface SurveyModule { + + @Binds + @Singleton + fun bindSurveyFeatureToggles(impl: DefaultSurveyFeatureToggles): SurveyFeatureToggles + + @Binds + @Singleton + fun bindSurveySparrowLauncher(impl: DefaultSurveySparrowLauncher): SurveySparrowLauncher + + @Binds + @Singleton + fun bindSurveyComponentFactory(impl: DefaultSurveyComponent.Factory): SurveyComponent.Factory + + @Binds + @Singleton + fun bindSurveyDeepLinkHandlerFactory(impl: DefaultSurveyDeepLinkHandler.Factory): SurveyDeepLinkHandler.Factory +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt new file mode 100644 index 0000000000..d5d905d0b4 --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt @@ -0,0 +1,48 @@ +package com.tangem.features.survey.impl.service + +import com.tangem.common.extensions.calculateSha256 +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.AppInstanceIdProvider +import com.tangem.datasource.api.tangemTech.models.WalletType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.SupportedLanguages +import com.tangem.utils.info.AppInfoProvider +import javax.inject.Inject + +internal class SurveyCustomParamsBuilder @Inject constructor( + private val appInstanceIdProvider: AppInstanceIdProvider, + private val appInfoProvider: AppInfoProvider, +) { + + suspend fun build(userWallet: UserWallet, token: String, displayId: String?): Map { + return buildMap { + put(KEY_SURVEY_KEY, token) + put(KEY_WALLET_ID, hashWalletId(userWallet)) + WalletType.from(userWallet)?.let { put(KEY_WALLET_TYPE, it.name.lowercase()) } + displayId?.takeIf { it.isNotBlank() }?.let { put(KEY_DISPLAY_ID, it) } + appInstanceIdProvider.getAppInstanceId()?.let { put(KEY_DEVICE_ID, it) } + put(KEY_PLATFORM, appInfoProvider.platform.lowercase()) + put(KEY_APP_VERSION, appInfoProvider.appVersion) + put(KEY_LANGUAGE, SupportedLanguages.getCurrentSupportedLanguageCode()) + } + } + + private fun hashWalletId(userWallet: UserWallet): String { + return userWallet.walletId.stringValue + .hexToBytes() + .calculateSha256() + .toHexString() + } + + private companion object { + const val KEY_SURVEY_KEY = "survey_key" + const val KEY_WALLET_ID = "wallet_id" + const val KEY_WALLET_TYPE = "wallet_type" + const val KEY_DISPLAY_ID = "display_id" + const val KEY_DEVICE_ID = "device_id" + const val KEY_PLATFORM = "platform" + const val KEY_APP_VERSION = "app_version" + const val KEY_LANGUAGE = "language" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..e3c1345e03 --- /dev/null +++ b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt @@ -0,0 +1,81 @@ +package com.tangem.features.survey.impl.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.features.survey.SurveyFeatureToggles +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import com.tangem.utils.logging.TangemLogger +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class DefaultSurveyDeepLinkHandlerTest { + + private val featureToggles = mockk() + private val appRouter = mockk(relaxed = true) + + @BeforeEach + fun setup() { + mockkObject(TangemLogger) + every { TangemLogger.i(any()) } just Runs + every { TangemLogger.e(any()) } just Runs + } + + @AfterEach + fun tearDown() { + unmockkObject(TangemLogger) + } + + @Test + fun `does not navigate when feature is disabled`() { + every { featureToggles.areSurveysEnabled } returns false + + createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID)) + + verify(exactly = 0) { appRouter.push(any(), any()) } + } + + @Test + fun `does not navigate when token is missing`() { + every { featureToggles.areSurveysEnabled } returns true + + createHandler(emptyMap()) + + verify(exactly = 0) { appRouter.push(any(), any()) } + } + + @Test + fun `pushes survey route with token and display id on happy path`() { + every { featureToggles.areSurveysEnabled } returns true + + createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID)) + + verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = DISPLAY_ID), onComplete = any()) } + } + + @Test + fun `pushes survey route with null display id when absent`() { + every { featureToggles.areSurveysEnabled } returns true + + createHandler(mapOf("token" to TOKEN)) + + verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = null), onComplete = any()) } + } + + private fun createHandler(queryParams: Map) = DefaultSurveyDeepLinkHandler( + queryParams = queryParams, + surveyFeatureToggles = featureToggles, + appRouter = appRouter, + ) + + private companion object { + const val TOKEN = "ntt-84iF22PDajmervYneMW4kv" + const val DISPLAY_ID = "42" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt new file mode 100644 index 0000000000..290c70adca --- /dev/null +++ b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt @@ -0,0 +1,104 @@ +package com.tangem.features.survey.impl.service + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.extensions.calculateSha256 +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.AppInstanceIdProvider +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.info.AppInfoProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.Locale + +internal class SurveyCustomParamsBuilderTest { + + private val appInstanceIdProvider = mockk() + private val appInfoProvider = mockk() + + private val builder = SurveyCustomParamsBuilder( + appInstanceIdProvider = appInstanceIdProvider, + appInfoProvider = appInfoProvider, + ) + + @BeforeEach + fun setup() { + Locale.setDefault(Locale.ENGLISH) + every { appInfoProvider.platform } returns "Android" + every { appInfoProvider.appVersion } returns "5.40" + coEvery { appInstanceIdProvider.getAppInstanceId() } returns "device-123" + } + + @Test + fun `builds all params for a cold wallet`() = runTest { + val wallet = coldWallet(WALLET_ID_HEX) + + val params = builder.build(userWallet = wallet, token = TOKEN, displayId = "42") + + assertThat(params).containsExactlyEntriesIn( + mapOf( + "survey_key" to TOKEN, + "wallet_id" to expectedWalletIdHash(WALLET_ID_HEX), + "wallet_type" to "cold", + "display_id" to "42", + "device_id" to "device-123", + "platform" to "android", + "app_version" to "5.40", + "language" to "en", + ), + ) + } + + @Test + fun `wallet_id hash is uppercase hex`() = runTest { + val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null) + + val walletId = params.getValue("wallet_id") + assertThat(walletId).isEqualTo(walletId.uppercase()) + assertThat(walletId).matches("[0-9A-F]+") + } + + @Test + fun `wallet_type is hot for a hot wallet`() = runTest { + val wallet = mockk { every { walletId } returns UserWalletId(WALLET_ID_HEX) } + + val params = builder.build(userWallet = wallet, token = TOKEN, displayId = null) + + assertThat(params["wallet_type"]).isEqualTo("hot") + } + + @Test + fun `device_id is omitted when app instance id is null`() = runTest { + coEvery { appInstanceIdProvider.getAppInstanceId() } returns null + + val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = "42") + + assertThat(params).doesNotContainKey("device_id") + } + + @Test + fun `display_id is omitted when null or blank`() = runTest { + val nullCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null) + val blankCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = " ") + + assertThat(nullCase).doesNotContainKey("display_id") + assertThat(blankCase).doesNotContainKey("display_id") + } + + private fun coldWallet(walletIdHex: String): UserWallet.Cold = mockk { + every { walletId } returns UserWalletId(walletIdHex) + } + + private fun expectedWalletIdHash(walletIdHex: String): String = + walletIdHex.hexToBytes().calculateSha256().toHexString() + + private companion object { + const val TOKEN = "ntt-84iF22PDajmervYneMW4kv" + const val WALLET_ID_HEX = "0123456789ABCDEF" + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 861066f760..8e147f1458 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -6,6 +6,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState @@ -36,6 +37,7 @@ sealed interface SwapState { val userWallet: UserWallet, val fromTokenInfo: TokenSwapInfo, val toTokenInfo: TokenSwapInfo, + val cryptoCurrencyWarning: CryptoCurrencyWarning?, val isInsufficientBalance: Boolean, val appCurrency: AppCurrency, val isBalanceHidden: Boolean, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index 6731dc4b61..fc42b91521 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -30,13 +30,14 @@ interface SwapTransferInteractor { suspend fun loadFee( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + fromTokenAmount: BigDecimal, ): Either suspend fun loadFeeExtended( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + fromTokenAmount: BigDecimal, + selectedToken: CryptoCurrencyStatus?, ): Either suspend fun sendTransfer( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 2263812fe8..d9e91edffc 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -22,9 +22,11 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -46,7 +48,7 @@ import kotlinx.coroutines.flow.first import java.math.BigDecimal import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") class SwapTransferInteractorImpl @Inject constructor( private val swapFeatureToggles: SwapFeatureToggles, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, @@ -60,6 +62,7 @@ class SwapTransferInteractorImpl @Inject constructor( private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, ) : SwapTransferInteractor { override suspend fun updateTransfer( @@ -110,10 +113,19 @@ class SwapTransferInteractorImpl @Inject constructor( fee = fee, currencyCheck = currencyCheck, ) + val cryptoCurrencyWarning = feePaidCurrencyStatus?.let { feeStatus -> + getCryptoCurrencyWarning( + feeValue = fee?.amount?.value.orZero(), + userWallet = userWallet, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + feeStatus = feeStatus, + ) + } return SwapState.Transfer( userWallet = userWallet, fromTokenInfo = fromTokenInfo, toTokenInfo = toTokenInfo, + cryptoCurrencyWarning = cryptoCurrencyWarning, isInsufficientBalance = fromTokenAmountValue > fromTokenBalance, appCurrency = appCurrency, isBalanceHidden = isBalanceHidden, @@ -124,6 +136,20 @@ class SwapTransferInteractorImpl @Inject constructor( ) } + private suspend fun getCryptoCurrencyWarning( + feeValue: BigDecimal, + userWallet: UserWallet, + fromSwapCurrencyStatus: SwapCurrencyStatus, + feeStatus: CryptoCurrencyStatus, + ): CryptoCurrencyWarning? { + return getBalanceNotEnoughForFeeWarningUseCase( + fee = feeValue, + userWalletId = userWallet.walletId, + tokenStatus = fromSwapCurrencyStatus.status, + feeStatus = feeStatus, + ).getOrNull() + } + private suspend fun getCoverageState( fromTokenInfo: TokenSwapInfo, userWallet: UserWallet, @@ -205,27 +231,36 @@ class SwapTransferInteractorImpl @Inject constructor( override suspend fun loadFee( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + fromTokenAmount: BigDecimal, ): Either { - val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError( message = "Destination address is null", ) + val userWallet = fromSwapCurrencyStatus.userWallet + val currency = fromSwapCurrencyStatus.currency + val transactionData = createTransferTransactionUseCase( + amount = fromTokenAmount.convertToSdkAmount( + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, + ), + memo = null, + destination = destination, + userWalletId = userWallet.walletId, + network = currency.network, + ).getOrNull() ?: return feeDataError("Failed to build transfer transaction") return getFeeUseCase( - amount = amount, - destination = destination, userWallet = fromSwapCurrencyStatus.userWallet, - cryptoCurrency = fromSwapCurrencyStatus.currency, + network = fromSwapCurrencyStatus.currency.network, + transactionData = transactionData, ) } override suspend fun loadFeeExtended( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + fromTokenAmount: BigDecimal, + selectedToken: CryptoCurrencyStatus?, ): Either { - val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError( message = "Destination address is null", ) @@ -233,7 +268,7 @@ class SwapTransferInteractorImpl @Inject constructor( val currency = fromSwapCurrencyStatus.currency val transactionData = createTransferTransactionUseCase( - amount = amount.convertToSdkAmount( + amount = fromTokenAmount.convertToSdkAmount( cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ), memo = null, @@ -246,7 +281,13 @@ class SwapTransferInteractorImpl @Inject constructor( userWallet = userWallet, network = currency.network, transactionData = transactionData, - ) + ).map { transactionFeeExtended -> + selectedToken ?: return@map transactionFeeExtended + val selectedTokenId = selectedToken.currency.id + transactionFeeExtended.copy( + feeTokenId = selectedTokenId, + ) + } } override suspend fun sendTransfer( @@ -312,9 +353,9 @@ class SwapTransferInteractorImpl @Inject constructor( transactionFeeResult: TransactionFeeResult, txData: TransactionData, ): Either { - val isToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token - val isGaslessToken = isToken && transactionFeeResult is TransactionFeeResult.LoadedExtended - return if (isGaslessToken) { + val isFeeInTokenCurrency = transactionFeeResult is TransactionFeeResult.LoadedExtended && + transactionFeeResult.fee.transactionFee.normal is Fee.Ethereum.TokenCurrency + return if (isFeeInTokenCurrency) { createAndSendGaslessTransactionUseCase( transactionData = txData, userWallet = userWallet, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 864296e66f..fc31a2c1ef 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -19,6 +19,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck @@ -59,6 +60,7 @@ internal class SwapTransferInteractorImplTest { private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk() + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true) private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -73,6 +75,7 @@ internal class SwapTransferInteractorImplTest { getCurrencyCheckUseCase = getCurrencyCheckUseCase, isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, tangemPayWithdrawUseCase = tangemPayWithdrawUseCase, + getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, ) @AfterEach @@ -133,7 +136,17 @@ internal class SwapTransferInteractorImplTest { every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true val currencyCheck = buildCurrencyCheck() - coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns currencyCheck coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() @@ -160,6 +173,7 @@ internal class SwapTransferInteractorImplTest { swapCurrencyStatus = toCurrencyStatus, amountFiat = expectedFiat, ), + cryptoCurrencyWarning = null, isInsufficientBalance = false, appCurrency = appCurrency, isBalanceHidden = true, @@ -193,7 +207,17 @@ internal class SwapTransferInteractorImplTest { every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true val currencyCheck = buildCurrencyCheck() - coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns currencyCheck coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() @@ -220,6 +244,7 @@ internal class SwapTransferInteractorImplTest { swapCurrencyStatus = toCurrencyStatus, amountFiat = expectedFiat, ), + cryptoCurrencyWarning = null, isInsufficientBalance = true, appCurrency = appCurrency, isBalanceHidden = true, @@ -259,7 +284,15 @@ internal class SwapTransferInteractorImplTest { every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false coEvery { - getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) } returns buildCurrencyCheck() coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) @@ -285,40 +318,51 @@ internal class SwapTransferInteractorImplTest { @Test fun `GIVEN valid amount and destination WHEN loadFee THEN return TransactionFee from use case`() = runTest { - val userWallet: UserWallet = mockk() + val userWalletId: UserWalletId = mockk() + val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + val network: Network = mockk() val fromCurrencyStatus = buildCurrencyStatus( rawCurrencyId = FROM_RAW_CURRENCY_ID, decimals = FROM_DECIMALS, userWallet = userWallet, + network = network, ) val toCurrencyStatus = buildCurrencyStatus( rawCurrencyId = TO_RAW_CURRENCY_ID, decimals = TO_DECIMALS, destinationAddress = DESTINATION_ADDRESS, ) + val transactionData: TransactionData.Uncompiled = mockk() val transactionFee: TransactionFee = mockk() coEvery { - getFeeUseCase( - amount = BigDecimal("1.5"), + createTransferTransactionUseCase( + amount = any(), + memo = null, destination = DESTINATION_ADDRESS, + userWalletId = userWalletId, + network = network, + ) + } returns transactionData.right() + coEvery { + getFeeUseCase( userWallet = userWallet, - cryptoCurrency = fromCurrencyStatus.currency, + network = network, + transactionData = transactionData, ) } returns transactionFee.right() val result = sut.loadFee( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "1.5", + fromTokenAmount = BigDecimal("1.5"), ) assertThat(result).isEqualTo(transactionFee.right()) coVerify { getFeeUseCase( - amount = BigDecimal("1.5"), - destination = DESTINATION_ADDRESS, userWallet = userWallet, - cryptoCurrency = fromCurrencyStatus.currency, + network = network, + transactionData = transactionData, ) } } @@ -365,7 +409,8 @@ internal class SwapTransferInteractorImplTest { val result = sut.loadFeeExtended( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "2.0", + fromTokenAmount = BigDecimal("2.0"), + selectedToken = null, ) assertThat(result).isEqualTo(feeExtended.right()) @@ -465,7 +510,7 @@ internal class SwapTransferInteractorImplTest { } @Test - fun `GIVEN token and LoadedExtended fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() = + fun `GIVEN LoadedExtended fee with TokenCurrency normal fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() = runTest { val userWalletId: UserWalletId = mockk() val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } @@ -483,7 +528,11 @@ internal class SwapTransferInteractorImplTest { ) val fee: Fee = mockk() val txData: TransactionData.Uncompiled = mockk() - val transactionFeeExtended: TransactionFeeExtended = mockk() + val transactionFeeExtended: TransactionFeeExtended = mockk { + every { transactionFee } returns mockk { + every { normal } returns mockk() + } + } val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended) coEvery { createTransferTransactionUseCase( @@ -574,6 +623,62 @@ internal class SwapTransferInteractorImplTest { } } + @Test + fun `GIVEN LoadedExtended fee with non-TokenCurrency normal fee WHEN sendTransfer THEN fall back to sendTransactionUseCase`() = + runTest { + val userWalletId: UserWalletId = mockk() + val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + val network: Network = mockk() + val fromCurrencyStatus = buildTokenCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + userWallet = userWallet, + network = network, + ) + val toCurrencyStatus = buildTokenCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + destinationAddress = DESTINATION_ADDRESS, + ) + val fee: Fee = mockk() + val txData: TransactionData.Uncompiled = mockk() + val transactionFeeExtended: TransactionFeeExtended = mockk { + every { transactionFee } returns mockk { + every { normal } returns mockk() + } + } + val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended) + coEvery { + createTransferTransactionUseCase( + amount = any(), + fee = fee, + memo = null, + destination = DESTINATION_ADDRESS, + userWalletId = userWalletId, + network = network, + ) + } returns txData.right() + coEvery { + sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network) + } returns TX_HASH.right() + + val result = sut.sendTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + sendingAmount = BigDecimal("1.0"), + fee = fee, + transactionFeeResult = transactionFeeResult, + ) + + assertThat(result).isEqualTo(TX_HASH.right()) + coVerify { + sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase(any(), any(), any()) + } + } + @Test fun `GIVEN createTransferTransactionUseCase fails WHEN sendTransfer THEN return DataError`() = runTest { val userWalletId: UserWalletId = mockk() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 16cb6b2bcb..cb68e69748 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -123,6 +123,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY, analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap, ), + isTransferMode = config.isTransferMode, ), ) } @@ -143,6 +144,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( data class FeeSelectorConfig( val sendingCurrencyStatus: CryptoCurrencyStatus, val feeCurrencyStatus: CryptoCurrencyStatus, + val isTransferMode: Boolean, ) @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -151,6 +153,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle() val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } } val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } } + val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } } val shouldHideBlock by remember { derivedStateOf { val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero() @@ -158,7 +161,6 @@ internal class DefaultSwapComponent @AssistedInject constructor( val isProviderMissing = dataState.selectedProvider == null val loadedState = dataState.getCurrentLoadedSwapState() val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty - val isInTransferMode = dataState.currentTransferState != null val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady) val isTangemPayWithdrawal = model.isTangemPayWithdrawal() @@ -166,7 +168,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( } } - LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) { + LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock, isInTransferMode) { if (shouldHideBlock) { TangemLogger.e( messageString = "Dismissing fee selector: " + @@ -194,6 +196,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( FeeSelectorConfig( sendingCurrencyStatus = sendingCryptoCurrencyStatus, feeCurrencyStatus = feeCurrencyStatus, + isTransferMode = isInTransferMode, ), ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index a98c00d50b..fc49fe82dc 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -18,7 +18,7 @@ internal class DefaultSwapFeatureToggles @Inject constructor( ) override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled( - toggle = FeatureToggles.SWAP_INTEGRATED_APPROVE, + toggle = FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE, ) override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 9a5ad696ea..0af80a1a91 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -1,11 +1,11 @@ package com.tangem.feature.swap.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE -import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN @@ -13,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN import com.tangem.core.analytics.models.AppsFlyerIncludedEvent import com.tangem.core.analytics.models.getReferralParams import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.FeeBucket @@ -247,4 +248,46 @@ sealed class SwapEvents( "Provider" to provider.name, ), ) + + class TransferModeSwitched( + fromCurrency: CryptoCurrency?, + toCurrency: CryptoCurrency?, + ) : SwapEvents( + event = "Transfer Mode Switched", + params = mapOf( + SEND_TOKEN to fromCurrency?.symbol.orEmpty(), + "Send Blockchain" to fromCurrency?.network?.name.orEmpty(), + RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(), + "Receive Blockchain" to toCurrency?.network?.name.orEmpty(), + ), + ) + + class ButtonTransferClicked( + fromCurrency: CryptoCurrency?, + toCurrency: CryptoCurrency?, + ) : SwapEvents( + event = "Button - Transfer", + params = mapOf( + SEND_TOKEN to fromCurrency?.symbol.orEmpty(), + "Send Blockchain" to fromCurrency?.network?.name.orEmpty(), + RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(), + "Receive Blockchain" to toCurrency?.network?.name.orEmpty(), + ), + ) + + @Suppress("NullableToStringCall", "LongParameterList") + class TransferInProgressScreen( + fromCurrency: CryptoCurrency?, + toCurrency: CryptoCurrency?, + feeNetwork: Network, + ) : SwapEvents( + event = "Transfer in Progress Screen Opened", + params = mapOf( + SEND_TOKEN to fromCurrency?.symbol.orEmpty(), + "Send Blockchain" to fromCurrency?.network?.name.orEmpty(), + RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(), + "Receive Blockchain" to toCurrency?.network?.name.orEmpty(), + "Network fee" to feeNetwork.name, + ), + ), AppsFlyerIncludedEvent } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt index 96a5d81ee3..7607eafad4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt @@ -19,11 +19,7 @@ import com.tangem.features.send.v2.api.params.FeeSelectorParams import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* class SwapFeeSelectorBlockComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @@ -44,7 +40,11 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor( null }, feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, - feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow, + feeStateConfiguration = if (params.isTransferMode) { + FeeSelectorParams.FeeStateConfiguration.None + } else { + FeeSelectorParams.FeeStateConfiguration.ExcludeLow + }, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus, analyticsCategoryName = params.analyticsParams.analyticsCategoryName, @@ -100,6 +100,7 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor( val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val analyticsParams: AnalyticsParams, val repository: ModelRepository, + val isTransferMode: Boolean, ) @AssistedFactory diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 7ca2eef0a1..3e0eb1ed9f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -601,7 +601,15 @@ internal class SwapModel @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = lastAmount.value, ) - if (isUpdatedToTransferMode) return + if (isUpdatedToTransferMode) { + analyticsEventHandler.send( + event = SwapEvents.TransferModeSwitched( + fromCurrency = fromSwapCurrencyStatus.currency, + toCurrency = toSwapCurrencyStatus.currency, + ), + ) + return + } dataState = dataState.copy(currentTransferState = null) modelScope.launch { uiState = stateBuilder.createInitialLoadingState( @@ -770,7 +778,10 @@ internal class SwapModel @Inject constructor( feePaidCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, ) as? SwapState.Transfer ?: currentTransferState - dataState = dataState.copy(currentTransferState = refreshed) + dataState = dataState.copy( + currentTransferState = refreshed, + feePaidCryptoCurrency = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency, + ) uiState = swapTransferStateBuilder.updateTransferButtonEnableState( dataState = dataState, transferState = refreshed, @@ -1291,6 +1302,12 @@ internal class SwapModel @Inject constructor( private fun onTransferClick() { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + analyticsEventHandler.send( + event = SwapEvents.ButtonTransferClicked( + fromCurrency = fromSwapCurrencyStatus?.currency, + toCurrency = toSwapCurrencyStatus?.currency, + ), + ) val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null) { TangemLogger.e("onTransferClick: missing currency status, aborting") @@ -1332,6 +1349,7 @@ internal class SwapModel @Inject constructor( TangemLogger.e( messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}", ) + startLoadingQuotesFromLastState() showAlert() } .onRight { result -> @@ -1343,9 +1361,11 @@ internal class SwapModel @Inject constructor( } private fun updateTransferModeTangemPayState() { + sendTransferInProgressEvent() uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState( uiState = uiState, dataState = dataState, + fee = getSelectedSwapFee()?.fee, onExploreClick = { val txUrl = uiState.successState?.txUrl.orEmpty() if (txUrl.isNotEmpty()) { @@ -1373,6 +1393,7 @@ internal class SwapModel @Inject constructor( ).fold( ifLeft = { error -> TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") + startLoadingQuotesFromLastState() showAlert() }, ifRight = { txHash -> @@ -1384,14 +1405,13 @@ internal class SwapModel @Inject constructor( "" } updateWalletBalance() + sendTransferInProgressEvent() uiState = swapTransferStateBuilder.createSuccessState( uiState = uiState, dataState = dataState, - appCurrency = selectedAppCurrencyFlow.value, - isAccountsMode = isAccountsMode, txUrl = txUrl, timestamp = System.currentTimeMillis(), - fee = null, + fee = getSelectedSwapFee()?.fee, onExplorerClick = { if (txUrl.isNotEmpty()) { urlOpener.openUrl(txUrl) @@ -1403,6 +1423,18 @@ internal class SwapModel @Inject constructor( ) } + private fun sendTransferInProgressEvent() { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + analyticsEventHandler.send( + event = SwapEvents.TransferInProgressScreen( + fromCurrency = fromSwapCurrencyStatus?.currency, + toCurrency = toSwapCurrencyStatus?.currency, + feeNetwork = getFeeToken().network, + ), + ) + } + private suspend fun processTangemPayWithdrawal( fromSwapCurrencyStatus: SwapCurrencyStatus, swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, @@ -2180,6 +2212,7 @@ internal class SwapModel @Inject constructor( dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency, @@ -2188,7 +2221,7 @@ internal class SwapModel @Inject constructor( return swapTransferInteractor.loadFee( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, - fromTokenAmount = lastAmount.value, + fromTokenAmount = amount, ).onLeft { TangemLogger.e("loadFee[transfer]: Failed to load fee with error $it") }.onRight { @@ -2202,9 +2235,7 @@ internal class SwapModel @Inject constructor( return Either.Left(GetFeeError.UnknownError) } - val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull() - ?: return Either.Left(GetFeeError.UnknownError) - val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals) val swapDataForCall = when (quoteState.swapProvider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError) @@ -2235,6 +2266,8 @@ internal class SwapModel @Inject constructor( dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) + val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency, @@ -2243,7 +2276,8 @@ internal class SwapModel @Inject constructor( return swapTransferInteractor.loadFeeExtended( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, - fromTokenAmount = lastAmount.value, + fromTokenAmount = amount, + selectedToken = selectedToken, ) } val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError) @@ -2252,8 +2286,7 @@ internal class SwapModel @Inject constructor( return Either.Left(GetFeeError.UnknownError) } - val amountDecimal = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) - val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals) // DEX path requires a SwapDataModel. val swapDataForCall = when (quoteState.swapProvider.type) { @@ -2300,10 +2333,6 @@ internal class SwapModel @Inject constructor( modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) } return } - refreshTransferUIStateIfNeeded( - feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, - fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee, - ) val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus @@ -2312,7 +2341,13 @@ internal class SwapModel @Inject constructor( fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency, ) - if (shouldTransferInsteadOfSwap) return + if (shouldTransferInsteadOfSwap) { + refreshTransferUIStateIfNeeded( + feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken, + fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee, + ) + return + } val quoteState = dataState.getCurrentLoadedSwapState() ?: return val swapFee = getSelectedSwapFee() ?: return diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt index 7468e1af03..5ae7859709 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -3,12 +3,14 @@ package com.tangem.feature.swap.ui.transfer import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.SwapState @@ -24,12 +26,14 @@ import javax.inject.Inject internal class SwapTransferNotificationsFactory @Inject constructor() { + @Suppress("LongParameterList") fun getNotifications( transferState: SwapState.Transfer, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, fee: Fee?, onReduceByAmount: (SwapAmount, BigDecimal) -> Unit, onReduceToAmount: (SwapAmount) -> Unit, + onBuyClick: (CryptoCurrency) -> Unit, ): ImmutableList { return buildList { maybeAddRentExemptionError(transferState) @@ -41,6 +45,7 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { onReduceToAmount = onReduceToAmount, ) maybeAddNeedReserveToCreateAccountWarning(transferState) + maybeAddExceedsBalanceNotification(transferState, onBuyClick) }.toPersistentList() } @@ -172,4 +177,21 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { ) } } + + private fun MutableList.maybeAddExceedsBalanceNotification( + transferState: SwapState.Transfer, + onBuyClick: (CryptoCurrency) -> Unit, + ) { + val cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status + addExceedsBalanceNotification( + cryptoCurrencyWarning = transferState.cryptoCurrencyWarning, + cryptoCurrencyStatus = cryptoCurrencyStatus, + shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum( + networkId = cryptoCurrencyStatus.currency.network.rawId, + ), + onClick = onBuyClick, + onAnalyticsEvent = {}, + onResetAnalyticsEvent = {}, + ) + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 97e06f6f2b..9f2b1065b0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -59,6 +59,7 @@ internal class SwapTransferStateBuilder @Inject constructor( transferState = transferState, feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, + onBuyClick = actions.openTokenDetailsScreen, onReduceByAmount = actions.onReduceByAmount, onReduceToAmount = actions.onReduceToAmount, ) @@ -227,6 +228,7 @@ internal class SwapTransferStateBuilder @Inject constructor( transferState = transferState, feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, + onBuyClick = actions.openTokenDetailsScreen, onReduceByAmount = actions.onReduceByAmount, onReduceToAmount = actions.onReduceToAmount, ) @@ -325,13 +327,12 @@ internal class SwapTransferStateBuilder @Inject constructor( fun createSuccessState( uiState: SwapStateHolder, dataState: SwapProcessDataState, - appCurrency: AppCurrency, - isAccountsMode: Boolean, + fee: Fee?, txUrl: String, timestamp: Long, - fee: TextReference?, onExplorerClick: () -> Unit, ): SwapStateHolder { + val transferState = requireNotNull(dataState.currentTransferState) val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val amount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO @@ -341,11 +342,11 @@ internal class SwapTransferStateBuilder @Inject constructor( val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) } val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) } val fromFiatAmount = getFormattedFiatAmount( - appCurrency = appCurrency, + appCurrency = transferState.appCurrency, amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount), ) val toFiatAmount = getFormattedFiatAmount( - appCurrency = appCurrency, + appCurrency = transferState.appCurrency, amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount), ) @@ -359,15 +360,15 @@ internal class SwapTransferStateBuilder @Inject constructor( isTransferMode = true, providerIcon = "", rate = TextReference.EMPTY, - fee = fee, + fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) }, fromTitle = getCardAccountTitle( account = fromSwapCurrencyStatus.account, - isAccountsMode = isAccountsMode, + isAccountsMode = transferState.isAccountsMode, isFromCard = true, ), toTitle = getCardAccountTitle( account = toSwapCurrencyStatus.account, - isAccountsMode = isAccountsMode, + isAccountsMode = transferState.isAccountsMode, isFromCard = false, ), fromTokenAmount = stringReference(fromAmountText), @@ -385,6 +386,7 @@ internal class SwapTransferStateBuilder @Inject constructor( fun createTangemPayWithdrawalSuccessState( uiState: SwapStateHolder, dataState: SwapProcessDataState, + fee: Fee?, onExploreClick: () -> Unit, ): SwapStateHolder { val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) @@ -407,7 +409,7 @@ internal class SwapTransferStateBuilder @Inject constructor( isTransferMode = true, providerIcon = "", rate = TextReference.EMPTY, - fee = null, + fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) }, fromTitle = getCardAccountTitle( account = fromSwapCurrencyStatus.account, isAccountsMode = transferState.isAccountsMode, @@ -429,4 +431,19 @@ internal class SwapTransferStateBuilder @Inject constructor( ), ) } + + private fun formatFeeForSuccess(transferState: SwapState.Transfer, fee: Fee): TextReference { + val feeAmount = fee.amount + val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO + val cryptoFormatted = totalFeeValue.format { + crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals) + } + val appCurrency = transferState.appCurrency + val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus + val fiatRate = swapCurrencyStatus.status.value.fiatRate + val fiatFormatted = fiatRate?.multiply(totalFeeValue).format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + return stringReference("$cryptoFormatted ($fiatFormatted)") + } } \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt index ab218123aa..f541df7896 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt @@ -43,6 +43,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result).isEmpty() @@ -65,6 +66,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -91,6 +93,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = fee, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -113,6 +116,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -131,6 +135,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -154,6 +159,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -176,6 +182,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) val reserve = result.filterIsInstance() @@ -199,15 +206,39 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) } + @Test + fun `GIVEN BalanceNotEnoughForFee warning WHEN getNotifications THEN TokenExceedsBalance is added`() = runTest { + val warning = CryptoCurrencyWarning.BalanceNotEnoughForFee( + tokenCurrency = buildCoin(), + coinCurrency = buildCoin(), + ) + val transferState = buildTransferState( + cryptoCurrencyWarning = warning, + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + onBuyClick = {}, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + @Suppress("LongParameterList") private fun buildTransferState( fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()), toTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()), + cryptoCurrencyWarning: CryptoCurrencyWarning? = null, currencyCheck: CryptoCurrencyCheck? = null, validationResult: Throwable? = null, minAdaValue: BigDecimal? = null, @@ -217,6 +248,7 @@ internal class SwapTransferNotificationsFactoryTest { userWallet = coldWallet, fromTokenInfo = fromTokenInfo, toTokenInfo = toTokenInfo, + cryptoCurrencyWarning = cryptoCurrencyWarning, isInsufficientBalance = false, appCurrency = AppCurrency.Default, isBalanceHidden = false, diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index b33d90d5e3..16bddb47cc 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -56,6 +56,7 @@ internal class SwapTransferStateBuilderTest { fee = any(), onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } returns persistentListOf() } @@ -129,6 +130,7 @@ internal class SwapTransferStateBuilderTest { fee = null, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -170,6 +172,7 @@ internal class SwapTransferStateBuilderTest { fee = null, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -212,6 +215,7 @@ internal class SwapTransferStateBuilderTest { fee = null, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -260,6 +264,7 @@ internal class SwapTransferStateBuilderTest { fee = null, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -307,6 +312,7 @@ internal class SwapTransferStateBuilderTest { fee = fee, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } returns persistentListOf() @@ -330,6 +336,7 @@ internal class SwapTransferStateBuilderTest { fee = fee, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -468,25 +475,40 @@ internal class SwapTransferStateBuilderTest { @Test fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() { - val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") val amount = BigDecimal("1.5") + val transferState = buildTransferState( + fromAmount = amount, + toAmount = amount, + isAccountsMode = true, + ) val dataState = SwapProcessDataState( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, amount = amount.toPlainString(), + currentTransferState = transferState, + ) + val feeValue = BigDecimal("0.001") + val fee = Fee.Common( + amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18), + ) + val appCurrency = transferState.appCurrency + val expectedFee = stringReference( + "${feeValue.format { crypto(symbol = "ETH", decimals = 18) }} " + + "(${ + fromCurrencyStatus.status.value.fiatRate!!.multiply(feeValue).format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + })", ) - val fee: TextReference = stringReference("0.001 ETH") val txUrl = "https://explorer.example/tx/0xabc" val timestamp = 1_700_000_000_000L val result = sut.createSuccessState( uiState = baseStateHolder(), dataState = dataState, - appCurrency = appCurrency, - isAccountsMode = true, + fee = fee, txUrl = txUrl, timestamp = timestamp, - fee = fee, onExplorerClick = {}, ) @@ -495,7 +517,7 @@ internal class SwapTransferStateBuilderTest { assertThat(success.shouldShowStatusButton).isFalse() assertThat(success.timestamp).isEqualTo(timestamp) assertThat(success.txUrl).isEqualTo(txUrl) - assertThat(success.fee).isEqualTo(fee) + assertThat(success.fee).isEqualTo(expectedFee) assertThat(success.providerName).isEqualTo(TextReference.EMPTY) assertThat(success.providerType).isEqualTo(TextReference.EMPTY) assertThat(success.providerIcon).isEmpty() @@ -613,6 +635,7 @@ internal class SwapTransferStateBuilderTest { val result = sut.createTangemPayWithdrawalSuccessState( uiState = baseStateHolder(), dataState = dataState, + fee = null, onExploreClick = onExploreClick, ) val after = System.currentTimeMillis() @@ -707,6 +730,7 @@ internal class SwapTransferStateBuilderTest { userWallet = coldWallet, fromTokenInfo = fromInfo, toTokenInfo = toInfo, + cryptoCurrencyWarning = null, isInsufficientBalance = isInsufficientBalance, appCurrency = AppCurrency.Default, isBalanceHidden = false, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index a669eab012..abff694265 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -16,11 +16,13 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.NavigationBar3ButtonsScrim import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.model.TangemPayDetailsModel import com.tangem.features.tangempay.ui.TangemPayDetailsScreen +import com.tangem.features.tangempay.ui.TangemPayDetailsScreenV2 import com.tangem.features.tangempay.utils.requireLoaded import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokendetails.ExpressTransactionsComponent @@ -72,12 +74,21 @@ internal class TangemPayDetailsComponent( val bottomSheet by bottomSheetSlot.subscribeAsState() NavigationBar3ButtonsScrim() - TangemPayDetailsScreen( - state = state, - txHistoryComponent = txHistoryComponent, - expressTransactionsComponent = expressTransactionsComponent, - modifier = modifier, - ) + if (LocalRedesignEnabled.current) { + TangemPayDetailsScreenV2( + state = state, + txHistoryComponent = txHistoryComponent, + expressTransactionsComponent = expressTransactionsComponent, + modifier = modifier, + ) + } else { + TangemPayDetailsScreen( + state = state, + txHistoryComponent = txHistoryComponent, + expressTransactionsComponent = expressTransactionsComponent, + modifier = modifier, + ) + } bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt index b4d70fd998..cb24f4b42d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -17,6 +17,7 @@ internal data class TangemPayCardPageUM( val dailyLimitState: TangemPayDailyLimitBlockState, val addToWalletBlockState: AddToWalletBlockState? = null, val isReissueInProgress: Boolean = false, + val menuItems: ImmutableList, ) { companion object { fun stub( @@ -40,6 +41,7 @@ internal data class TangemPayCardPageUM( onBackClick = {}, isReissueInProgress = isReissueInProgress, dailyLimitState = dailyLimitState, + menuItems = persistentListOf(), ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index efabb31b08..8de4b0c86d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -4,9 +4,12 @@ import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_document_20 import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.utils.TangemPayDetailIntents @@ -31,6 +34,7 @@ internal class TangemPayDetailsStateFactory( onBackClick = onBack, onOpenMenu = onOpenMenu, items = getTopBarMenuItems(isTangemPayDeactivated), + itemsV2 = getTopBarMenuItemsV2(isTangemPayDeactivated), ), pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, @@ -89,4 +93,31 @@ internal class TangemPayDetailsStateFactory( ), ) } + + private fun getTopBarMenuItemsV2(isTangemPayDeactivated: Boolean): ImmutableList { + if (isTangemPayDeactivated) return persistentListOf() + + return persistentListOf( + TangemPayDropDownItemUM( + title = resourceReference(R.string.tangem_pay_terms_limits), + onClick = intents::onClickTermsAndLimits, + icon = TangemIconUM.Icon( + imageVector = Icons.ic_document_20, + tintReference = { + TangemTheme.colors3.icon.primary + }, + ), + ), + TangemPayDropDownItemUM( + title = resourceReference(R.string.tangempay_pay_support), + onClick = intents::onContactSupportClicked, + icon = TangemIconUM.Icon( + imageVector = Icons.ic_document_20, + tintReference = { + TangemTheme.colors3.icon.primary + }, + ), // TODO change when the icon will be ready in design + ), + ) + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt index 9675f1b31d..f25f792ca5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -7,4 +7,5 @@ internal data class TangemPayDetailsTopBarConfig( val onBackClick: () -> Unit, val onOpenMenu: () -> Unit, val items: ImmutableList, + val itemsV2: ImmutableList, ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index e51533434b..706d875d26 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -77,7 +77,7 @@ internal sealed class TangemPayDetailsBalanceBlockState { data class Content( override val actionButtons: ImmutableList, override val cardsBlockState: CardsBlockState?, - val fiatBalance: String, + val fiatBalance: TextReference, val isBalanceFlickering: Boolean, ) : TangemPayDetailsBalanceBlockState() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt new file mode 100644 index 0000000000..71d30e3f2e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference + +internal data class TangemPayDropDownItemUM( + val onClick: () -> Unit, + val title: TextReference, + val icon: TangemIconUM, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 0694b3694b..9db19e4817 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.format.bigdecimal.fiat @@ -20,6 +21,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig @@ -53,7 +55,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject import com.tangem.core.ui.R as CoreUiR -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class TangemPayCardPageModel @Inject constructor( @@ -89,6 +91,7 @@ internal class TangemPayCardPageModel @Inject constructor( dailyLimitState = TangemPayDailyLimitBlockState.Loading, settings = persistentListOf(), settingsV2 = persistentListOf(), + menuItems = buildMenuItems(), ), ) @@ -210,6 +213,21 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + private fun buildMenuItems(): ImmutableList { + return persistentListOf( + TangemPayDropDownItemUM( + title = TextReference.Res(R.string.tangempay_card_details_reissue_card), + onClick = ::onClickReissueCard, + icon = TangemIconUM.Icon( + iconRes = CoreUiR.drawable.ic_replace_20, + tintReference = { + TangemTheme.colors3.icon.primary + }, + ), + ), + ) + } + private fun onClickViewDetails() { modelScope.launch(dispatchers.default) { cardDetailsEventListener.send(CardDetailsEvent.Show) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index d2a9482577..eca90fdbb4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -2,8 +2,10 @@ package com.tangem.features.tangempay.model.transformers import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState import com.tangem.features.tangempay.entity.TangemPayDetailsUM @@ -35,10 +37,14 @@ internal class DetailsBalanceTransformer( return prevState.copy(balanceBlockState = balance) } - private fun getFiatBalanceText(balance: TangemPayCardBalance): String { + private fun getFiatBalanceText(balance: TangemPayCardBalance): TextReference { val currency = Currency.getInstance(balance.currencyCode) - return balance.fiatBalance.format { - fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol) + return balance.fiatBalance.formatStyled { + fiat( + fiatCurrencyCode = currency.currencyCode, + fiatCurrencySymbol = currency.symbol, + spanStyleReference = { TangemTheme.typography3.heading.medium.toSpanStyle() }, + ) } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 6b2374624b..6793c51dd5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -15,9 +15,8 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -25,6 +24,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalRedesignEnabled @@ -36,7 +38,9 @@ import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCard import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.* +import com.tangem.features.tangempay.ui.components.PayContextMenuBlock import kotlinx.collections.immutable.ImmutableList +import com.tangem.core.ui.R as CoreUiR private const val CONTENT_FADE_DURATION_MS = 300 @@ -51,8 +55,8 @@ internal fun TangemPayCardPageScreen( Scaffold( modifier = modifier, topBar = { - AppBarWithBackButton( - modifier = Modifier.statusBarsPadding(), + CardPageTopBar( + items = state.menuItems, onBackClick = state.onBackClick, ) }, @@ -170,6 +174,48 @@ private fun TangemPayCardPageSettingRow( } } +@Composable +private fun CardPageTopBar( + onBackClick: () -> Unit, + items: ImmutableList, + modifier: Modifier = Modifier, +) { + if (LocalRedesignEnabled.current) { + var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) } + TangemTopBar( + modifier = modifier.statusBarsPadding(), + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_back_28), + onClick = onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + endContent = { + Box { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_more_default_24), + onClick = { isDropdownMenuShown = true }, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + PayContextMenuBlock( + items = items, + onMenuDismiss = { isDropdownMenuShown = false }, + isDropdownMenuShown = isDropdownMenuShown, + ) + } + }, + ) + } else { + AppBarWithBackButton( + modifier = modifier, + onBackClick = onBackClick, + ) + } +} + private fun LazyListScope.cardPageItem( key: Any? = null, contentType: Any? = null, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageSettingsButtonsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageSettingsButtonsBlock.kt index ce36ac6ad5..ca0e75ed12 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageSettingsButtonsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageSettingsButtonsBlock.kt @@ -2,20 +2,20 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach -import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.tangempay.entity.TangemPayCardPageSettingV2 +import com.tangem.features.tangempay.ui.components.TangemPayActionButton import kotlinx.collections.immutable.ImmutableList @Composable @@ -30,41 +30,18 @@ internal fun TangemPayCardPageSettingsButtonsBlock( horizontalArrangement = Arrangement.Center, ) { settings.fastForEach { setting -> - TangemPaySettingButton( - setting = setting, + TangemPayActionButton( modifier = Modifier.then(if (setting.testTag != null) Modifier.testTag(setting.testTag) else Modifier), + title = setting.title, + iconRes = setting.iconRes, + onClick = setting.onClick, + isEnabled = setting.isEnabled, + isLoading = setting.isLoading, ) } } } -@Composable -private fun TangemPaySettingButton(setting: TangemPayCardPageSettingV2, modifier: Modifier = Modifier) { - Column( - modifier = modifier.padding(horizontal = TangemTheme.dimens2.x6), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - TangemButton( - variant = TangemButton.Variant.Material, - size = TangemButton.Size.X14, - onClick = setting.onClick, - iconStart = TangemIconUM.Icon( - iconRes = setting.iconRes, - tintReference = { TangemTheme.colors3.icon.primary }, - ), - isLoading = setting.isLoading, - isEnabled = setting.isEnabled, - ) - - Text( - text = setting.title.resolveAnnotatedReference(), - style = TangemTheme.typography3.subheading.medium, - color = TangemTheme.colors3.text.primary, - ) - } -} - @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 55d6159978..3371d0dcdb 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -45,9 +46,7 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TangemPayTestTags @@ -343,7 +342,7 @@ private fun FiatBalance( ) is TangemPayDetailsBalanceBlockState.Content -> Text( modifier = modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), - text = state.fiatBalance.orMaskWithStars(isBalanceHidden), + text = state.fiatBalance.orMaskWithStars(isBalanceHidden).resolveReference(), style = TangemTheme.typography.h2.applyBladeBrush( isEnabled = state.isBalanceFlickering, textColor = TangemTheme.colors.text.primary1, @@ -432,10 +431,15 @@ private fun TangemPayDetailsScreenPreview( } } -private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( +internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( collection = listOf( TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = persistentListOf()), + topBarConfig = TangemPayDetailsTopBarConfig( + onBackClick = {}, + onOpenMenu = {}, + items = persistentListOf(), + itemsV2 = persistentListOf(), + ), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), balanceBlockState = TangemPayDetailsBalanceBlockState.Content( actionButtons = persistentListOf( @@ -445,7 +449,14 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( +internal class TangemPayDetailsTxHistoryProvider : CollectionPreviewParameterProvider( collection = listOf( PreviewTangemPayTxHistoryComponent.loadingUM, PreviewTangemPayTxHistoryComponent.contentUM, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt new file mode 100644 index 0000000000..8be30b01d3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt @@ -0,0 +1,404 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.components.topFade +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessage +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.shimmers.TextShimmer +import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.TangemPayTestTags +import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent +import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM +import com.tangem.features.tangempay.ui.components.PayContextMenuBlock +import com.tangem.features.tangempay.ui.components.TangemPayActionButton +import com.tangem.features.tangempay.ui.components.TangemPayAddCardView +import com.tangem.features.tangempay.ui.components.TangemPayCardView +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import com.tangem.utils.StringsSigns.DASH_SIGN +import kotlinx.collections.immutable.ImmutableList +import com.tangem.core.ui.R as CoreUiR + +private val InitialTopBarHeight: Dp = 64.dp +private val BgImageFadeDistance: Dp = 96.dp +private const val TOP_FADE_MID_STOP = 0.8f +private const val TOP_FADE_MID_ALPHA = 0.8f + +@Suppress("LongMethod") +@Composable +internal fun TangemPayDetailsScreenV2( + state: TangemPayDetailsUM, + txHistoryComponent: TangemPayTxHistoryComponent, + expressTransactionsComponent: ExpressTransactionsComponent, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val density = LocalDensity.current + val statusBarHeight = with(density) { WindowInsets.systemBars.getTop(this).toDp() } + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + var topBarTotalHeight by remember { mutableStateOf(InitialTopBarHeight + statusBarHeight) } + val rootBackground = TangemTheme.colors3.bg.primary + val fadeDistancePx = with(LocalDensity.current) { BgImageFadeDistance.toPx() } + val bgImageAlpha by remember(fadeDistancePx) { + derivedStateOf { + val scrollOffsetPx = when (listState.firstVisibleItemIndex) { + 0 -> listState.firstVisibleItemScrollOffset.toFloat() + else -> fadeDistancePx + }.coerceAtLeast(0f) + (1f - scrollOffsetPx / fadeDistancePx).coerceIn(0f, 1f) + } + } + + val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() + val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() + val expressTransactionsBottomSheetState = expressState.bottomSheetSlot + + Box( + modifier = modifier + .fillMaxSize() + .background(rootBackground), + ) { + Image( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.img_bg_pay_details), + contentDescription = null, + contentScale = ContentScale.FillWidth, + alpha = bgImageAlpha, + ) + + TangemPullToRefreshSlidingContainer( + config = state.pullToRefreshConfig, + indicatorOffset = topBarTotalHeight, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .topFade( + height = topBarTotalHeight, + 0f to rootBackground.copy(alpha = 1f - bgImageAlpha), + TOP_FADE_MID_STOP to rootBackground.copy(alpha = TOP_FADE_MID_ALPHA * (1f - bgImageAlpha)), + 1f to Color.Transparent, + ), + horizontalAlignment = Alignment.CenterHorizontally, + state = listState, + contentPadding = PaddingValues( + top = topBarTotalHeight, + bottom = TangemTheme.dimens2.x4 + bottomBarHeight, + ), + ) { + payDetailsBody(state) + with(expressTransactionsComponent) { + expressTransactionsContent( + state = expressState.transactionsToDisplay, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp) + .fillMaxWidth(), + ) + } + with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } + } + } + + PayDetailsTopBar( + config = state.topBarConfig, + onHeightChange = { measuredHeight -> + if (topBarTotalHeight != measuredHeight) topBarTotalHeight = measuredHeight + }, + ) + } + expressTransactionsBottomSheetState?.content(null) +} + +private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) { + item("balanceBlock") { + BalanceBlock( + state = state.balanceBlockState, + isBalanceHidden = state.isBalanceHidden, + ) + } + state.balanceBlockState.cardsBlockState?.let { cardsState -> + item("cardsBlock") { + CardsBlock(cardsBlockState = cardsState) + } + } + if (state.balanceBlockState.actionButtons.isNotEmpty()) { + item("actionButtonsBlock") { + ActionBlock(actionButtons = state.balanceBlockState.actionButtons) + } + } + when { + state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isReissuing } == true -> { + item("reissuingBannerBlock") { + TangemMessage( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + title = resourceReference(R.string.tangempay_reissue_card_in_progress), + subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description), + ) + } + } + else -> { + if (state.addToWalletBlockState != null) { + item("addToWalletBannerBlock") { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + ) + } + } + if (state.accountDeactivatedNotificationConfig != null) { + item("deactivationBannerBlock") { + TangemMessage( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens2.x4) + .clickableSingle( + onClick = { state.accountDeactivatedNotificationConfig.onClick?.invoke() }, + ), + title = state.accountDeactivatedNotificationConfig.title, + subtitle = state.accountDeactivatedNotificationConfig.subtitle, + messageEffect = TangemMessageEffect.Warning, + ) + } + } + } + } +} + +@Composable +private fun PayDetailsTopBar( + config: TangemPayDetailsTopBarConfig, + onHeightChange: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + TangemTopBar( + modifier = modifier + .onSizeChanged { size -> + onHeightChange(with(density) { size.height.toDp() }) + } + .statusBarsPadding(), + title = resourceReference(R.string.tangempay_payment_account), + subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network), + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_arrow_back_28), + onClick = config.onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + endContent = if (config.itemsV2.isNotEmpty()) { + { + var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) } + Box { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_more_default_24), + onClick = { + config.onOpenMenu() + isDropdownMenuShown = true + }, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + PayContextMenuBlock( + items = config.itemsV2, + onMenuDismiss = { isDropdownMenuShown = false }, + isDropdownMenuShown = isDropdownMenuShown, + ) + } + } + } else { + null + }, + ) +} + +@Composable +private fun BalanceBlock( + state: TangemPayDetailsBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x12), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + targetState = state, + label = "Updating the balance", + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 90)) + }, + ) { animatedState -> + when (animatedState) { + is TangemPayDetailsBalanceBlockState.Loading -> TextShimmer( + modifier = Modifier.size(width = 160.dp, height = 56.dp), + text = "1234.00", + style = TextShimmerStyle.HEADING_MEDIUM, + radius = TangemTheme.dimens2.x25, + ) + is TangemPayDetailsBalanceBlockState.Content -> Text( + modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), + text = animatedState.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + style = TangemTheme.typography3.display.medium.applyBladeBrush( + isEnabled = animatedState.isBalanceFlickering, + textColor = TangemTheme.colors3.text.primary, + ), + color = TangemTheme.colors3.text.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography3.heading.medium.fontSize, + maxFontSize = TangemTheme.typography3.display.medium.fontSize, + ), + ) + is TangemPayDetailsBalanceBlockState.Error -> Text( + modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), + text = DASH_SIGN.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography3.display.medium, + color = TangemTheme.colors3.text.primary, + ) + } + } + } +} + +@Composable +private fun CardsBlock( + cardsBlockState: TangemPayDetailsBalanceBlockState.CardsBlockState, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier = modifier.fillMaxWidth(), + state = rememberLazyListState(), + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens2.x4, + vertical = TangemTheme.dimens2.x6, + ), + horizontalArrangement = Arrangement.Center, + ) { + items(items = cardsBlockState.cards) { item -> + TangemPayCardView( + isReissuing = item.isReissuing, + lastDigits = item.lastDigits, + onClick = item.onClick, + ) + SpacerW(TangemTheme.dimens2.x2) + } + item { + TangemPayAddCardView(onClick = cardsBlockState.onAddCardClick) + } + } +} + +@Composable +private fun LazyItemScope.ActionBlock( + actionButtons: ImmutableList, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x6), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + actionButtons.fastForEach { actionConfig -> + TangemPayActionButton( + iconRes = actionConfig.iconResId, + onClick = actionConfig.onClick, + isEnabled = actionConfig.isEnabled, + isLoading = actionConfig.isInProgress, + title = actionConfig.text, + ) + } + } +} + +// region preview + +@Preview(device = Devices.PIXEL_7_PRO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) +@Composable +private fun TangemPayDetailsScreenPreview( + @PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM, +) { + TangemThemePreviewRedesign { + TangemPayDetailsScreenV2( + state = state, + txHistoryComponent = PreviewTangemPayTxHistoryComponent( + txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM, + ), + expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), + ) + } +} + +@Preview(device = Devices.PIXEL_7_PRO) +@Composable +private fun TangemPayDetailsTxHistoryScreenPreview( + @PreviewParameter(TangemPayDetailsTxHistoryProvider::class) state: TangemPayTxHistoryUM, +) { + TangemThemePreviewRedesign { + TangemPayDetailsScreenV2( + state = TangemPayDetailsUMProvider().values.first(), + txHistoryComponent = PreviewTangemPayTxHistoryComponent(txHistoryUM = state), + expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), + ) + } +} + +// end region preview \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt new file mode 100644 index 0000000000..88dd7ca342 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt @@ -0,0 +1,57 @@ +package com.tangem.features.tangempay.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.ds.contextmenu.TangemContextMenu +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.entity.TangemPayDropDownItemUM +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun PayContextMenuBlock( + items: ImmutableList, + isDropdownMenuShown: Boolean, + onMenuDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + TangemContextMenu( + expanded = isDropdownMenuShown, + onDismissRequest = onMenuDismiss, + offset = DpOffset.Zero, + modifier = modifier, + ) { + items.fastForEach { item -> + Column { + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + modifier = Modifier + .clickableSingle( + onClick = { + item.onClick() + onMenuDismiss() + }, + ) + .padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4), + ) { + TangemIcon( + modifier = Modifier.size(TangemTheme.dimens2.x5), + tangemIconUM = item.icon, + ) + Text( + text = item.title.resolveReference(), + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + maxLines = 1, + ) + } + } + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayActionButton.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayActionButton.kt new file mode 100644 index 0000000000..924d154d90 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayActionButton.kt @@ -0,0 +1,66 @@ +package com.tangem.features.tangempay.ui.components + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TangemPayActionButton( + title: TextReference, + @DrawableRes iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + isLoading: Boolean = false, +) { + Column( + modifier = modifier.padding(horizontal = TangemTheme.dimens2.x4), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TangemButton( + variant = TangemButton.Variant.Material, + size = TangemButton.Size.X14, + onClick = onClick, + iconStart = TangemIconUM.Icon( + iconRes = iconRes, + tintReference = { TangemTheme.colors3.icon.primary }, + ), + isLoading = isLoading, + isEnabled = isEnabled, + ) + + Text( + text = title.resolveAnnotatedReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayActionButtonPreview() { + TangemThemePreviewRedesign { + TangemPayActionButton( + title = stringReference("Action"), + iconRes = R.drawable.ic_arrow_down_24, + onClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayCardView.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayCardView.kt new file mode 100644 index 0000000000..0663d4bd6d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayCardView.kt @@ -0,0 +1,215 @@ +package com.tangem.features.tangempay.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_clock_12 +import com.tangem.core.ui.res.generated.icons.ic_cloud_12 +import com.tangem.core.ui.test.TangemPayTestTags + +private const val DEFAULT_CARD_BG = 0xFF1C1F29 +private const val REISSUING_CARD_BG = 0xFF1E1E1E + +@Composable +internal fun TangemPayCardView( + isReissuing: Boolean, + lastDigits: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + CardBackground( + modifier = modifier + .size( + height = TangemTheme.dimens2.x10, + width = TangemTheme.dimens2.x14, + ) + .testTag(TangemPayTestTags.PAYMENT_ACCOUNT_CARD_BUTTON), + isReissuing = isReissuing, + onClick = onClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x1) + .padding(top = TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x3), + imageVector = if (isReissuing) { + Icons.ic_clock_12 + } else { + Icons.ic_cloud_12 + }, + tint = TangemTheme.colors3.icon.staticDark, + contentDescription = null, + ) + + Icon( + modifier = Modifier.size(height = TangemTheme.dimens2.x2, width = 22.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_visa_logo), + tint = TangemTheme.colors3.icon.staticDark, + contentDescription = null, + ) + } + + Text( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = TangemTheme.dimens2.x1, bottom = TangemTheme.dimens2.x1), + text = lastDigits, + style = TangemTheme.typography3.caption.medium.copy(fontSize = 10.sp), + color = TangemTheme.colors3.text.staticDark.primary, + ) + } +} + +@Composable +internal fun TangemPayAddCardView(onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size( + height = TangemTheme.dimens2.x10, + width = TangemTheme.dimens2.x14, + ) + .clip(RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075)) + .background(TangemTheme.colors3.bg.opaque.primary) + .clickableSingle(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_plus_default_24), + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun CardBackground( + isReissuing: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val bgColor = remember(isReissuing) { + if (isReissuing) { + Color(REISSUING_CARD_BG) + } else { + Color(DEFAULT_CARD_BG) + } + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075)) + .drawBehind { + drawRect(bgColor) + + val w = size.width + val h = size.height + val radiusScaleRightCorner = h / 2f + val radiusScaleLeftCorner = h / 1.27f + + drawRect( + brush = Brush.radialGradient( + colors = listOf( + Color( + if (isReissuing) 0xFFB0B4BC else 0xFF38587F, + ).copy( + if (isReissuing) .1f else .41f, + ), + Color.Transparent, + ), + center = Offset(w - 20f, h * .05f), + radius = radiusScaleRightCorner, + tileMode = TileMode.Clamp, + ), + ) + + if (!isReissuing) { + drawRect( + brush = Brush.radialGradient( + colors = listOf( + Color(0xFF2881FF).copy(.25f), + Color.Transparent, + ), + center = Offset(0f, h + h * .1f), + radius = radiusScaleLeftCorner, + ), + ) + } + } + .border( + width = 1.dp, + color = TangemTheme.colors3.border.primary, + shape = RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075), + ) + .clickableSingle(onClick = onClick), + content = content, + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) +@Composable +private fun CardBackgroundPreview() { + TangemThemePreviewRedesign { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) { + CardBackground( + modifier = Modifier.size( + height = TangemTheme.dimens2.x10, + width = TangemTheme.dimens2.x14, + ), + isReissuing = false, + onClick = {}, + content = {}, + ) + SpacerH(TangemTheme.dimens2.x4) + CardBackground( + modifier = Modifier.size( + height = TangemTheme.dimens2.x10, + width = TangemTheme.dimens2.x14, + ), + isReissuing = true, + onClick = {}, + content = {}, + ) + SpacerH(TangemTheme.dimens2.x4) + TangemPayCardView(isReissuing = false, onClick = {}, lastDigits = "1234") + SpacerH(TangemTheme.dimens2.x4) + TangemPayCardView(isReissuing = true, onClick = {}, lastDigits = "") + SpacerH(TangemTheme.dimens2.x4) + TangemPayAddCardView(onClick = {}) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/res/drawable-night-hdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-night-hdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000..190f51ba02 Binary files /dev/null and b/features/tangempay/details/impl/src/main/res/drawable-night-hdpi/img_bg_pay_details.webp differ diff --git a/features/tangempay/details/impl/src/main/res/drawable-night-xhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-night-xhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000..c9b4084734 Binary files /dev/null and b/features/tangempay/details/impl/src/main/res/drawable-night-xhdpi/img_bg_pay_details.webp differ diff --git a/features/tangempay/details/impl/src/main/res/drawable-night-xxhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-night-xxhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000..d8aedcf855 Binary files /dev/null and b/features/tangempay/details/impl/src/main/res/drawable-night-xxhdpi/img_bg_pay_details.webp differ diff --git a/features/tangempay/details/impl/src/main/res/drawable-night-xxxhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-night-xxxhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000..7671f73787 Binary files /dev/null and b/features/tangempay/details/impl/src/main/res/drawable-night-xxxhdpi/img_bg_pay_details.webp differ diff --git a/features/tangempay/details/impl/src/main/res/drawable-notnight-hdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-notnight-hdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000..dcc819b567 Binary files /dev/null and b/features/tangempay/details/impl/src/main/res/drawable-notnight-hdpi/img_bg_pay_details.webp differ diff --git a/features/tangempay/details/impl/src/main/res/drawable-notnight-xhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-notnight-xhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000..8f47020f26 Binary files /dev/null and b/features/tangempay/details/impl/src/main/res/drawable-notnight-xhdpi/img_bg_pay_details.webp differ diff --git a/features/tangempay/details/impl/src/main/res/drawable-notnight-xxhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-notnight-xxhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000..1a91ca2907 Binary files /dev/null and b/features/tangempay/details/impl/src/main/res/drawable-notnight-xxhdpi/img_bg_pay_details.webp differ diff --git a/features/tangempay/details/impl/src/main/res/drawable-notnight-xxxhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-notnight-xxxhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000..0122654158 Binary files /dev/null and b/features/tangempay/details/impl/src/main/res/drawable-notnight-xxxhdpi/img_bg_pay_details.webp differ diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index f96c63f4b4..b3e1f1c4b4 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -47,7 +47,6 @@ dependencies { /** Other libraries */ implementation(deps.arrow.core) implementation(deps.kotlin.immutable.collections) - implementation(deps.surveysparrow) /** Core modules */ implementation(projects.core.datasource) @@ -60,6 +59,7 @@ dependencies { /** Feature Apis */ implementation(projects.features.tester.api) implementation(projects.features.pushNotifications.api) + implementation(projects.features.survey.api) /* SDK */ implementation(tangemDeps.blockchain) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 3829e306b7..a1ede089f5 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -1,6 +1,5 @@ package com.tangem.feature.tester.presentation -import android.widget.Toast import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,7 +17,6 @@ import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeActivity -import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen import com.tangem.feature.tester.presentation.accounts.viewmodel.TesterAccountsViewModel import com.tangem.feature.tester.presentation.actions.TesterActionsScreen @@ -40,9 +38,10 @@ import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersSc import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel import com.tangem.feature.tester.presentation.storybook.ui.StoryBookScreen import com.tangem.feature.tester.presentation.storybook.viewmodel.StoryBookViewModel -import com.tangem.feature.tester.presentation.surveysparrow.SurveySparrowManager import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen import com.tangem.feature.tester.presentation.testpush.viewmodel.TestPushViewModel +import com.tangem.features.survey.SurveyLaunchData +import com.tangem.features.survey.SurveySparrowLauncher import dagger.hilt.android.AndroidEntryPoint import kotlinx.collections.immutable.persistentSetOf import javax.inject.Inject @@ -64,7 +63,7 @@ internal class TesterActivity : ComposeActivity() { lateinit var appRouter: AppRouter @Inject - lateinit var environmentConfig: EnvironmentConfig + lateinit var surveySparrowLauncher: SurveySparrowLauncher @Composable override fun ScreenContent(modifier: Modifier) { @@ -217,29 +216,16 @@ internal class TesterActivity : ComposeActivity() { } private fun startSurveySparrow(): Boolean { - val token = environmentConfig.surveySparrowToken - - if (token.isNullOrEmpty()) { - val toast = Toast.makeText( - this, - "Survey Sparrow is not configured. Token is missing.", - Toast.LENGTH_LONG, - ) - - toast.show() - return false - } - - SurveySparrowManager(domain = DOMAIN, token = token).startSurveyForResult( + surveySparrowLauncher.present( activity = this, - requestCode = SURVEY_SPARROW_REQUEST_CODE, + data = SurveyLaunchData(domain = DOMAIN, token = TEST_SHARE_TOKEN, customParams = emptyMap()), ) return true } private companion object { - const val DOMAIN = "tangem.com" - const val SURVEY_SPARROW_REQUEST_CODE = 1001 + const val DOMAIN = "tangem.surveysparrow.com" + const val TEST_SHARE_TOKEN = "ntt-84iF22PDajmervYneMW4kv" } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt deleted file mode 100644 index 3b2385900c..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.tangem.feature.tester.presentation.surveysparrow - -import android.app.Activity -import com.surveysparrow.ss_android_sdk.SsSurvey -import com.surveysparrow.ss_android_sdk.SurveySparrow -import com.tangem.utils.logging.TangemLogger - -/** - * Manager for Survey Sparrow SDK. - * - * @param domain Survey Sparrow domain (e.g., "yourcompany") - * @param token Survey Sparrow SDK token - */ -class SurveySparrowManager( - private val domain: String, - private val token: String, -) { - - /** - * Create a SurveySparrow instance to start a survey. - * - * @param activity The activity context - * @param customVariables Optional custom variables to pass to the survey - * @return SurveySparrow instance ready to start - */ - fun createSurvey(activity: Activity, customVariables: Map? = null): SurveySparrow? { - return try { - val survey = SsSurvey(domain, token).apply { - customVariables?.forEach { (key, value) -> - addCustomParam(key, value) - } - } - - SurveySparrow(activity, survey) - } catch (e: Exception) { - TangemLogger.e("Failed to create SurveySparrow survey", e) - null - } - } - - /** - * Start a survey for result. - * - * @param activity The activity context - * @param requestCode The request code for onActivityResult - * @param customVariables Optional custom variables to pass to the survey - */ - fun startSurveyForResult(activity: Activity, requestCode: Int, customVariables: Map? = null) { - val surveySparrow = createSurvey(activity, customVariables) - if (surveySparrow != null) { - surveySparrow.startSurveyForResult(requestCode) - TangemLogger.d("SurveySparrow survey started with requestCode: $requestCode") - } - } -} \ No newline at end of file diff --git a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsFeatureToggles.kt b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsFeatureToggles.kt new file mode 100644 index 0000000000..71f92a9e18 --- /dev/null +++ b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.tokendetails + +interface TokenDetailsFeatureToggles { + val isQuickTopUpEnabled: Boolean +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsFeatureToggles.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsFeatureToggles.kt new file mode 100644 index 0000000000..c3d81a3a9b --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsFeatureToggles.kt @@ -0,0 +1,15 @@ +package com.tangem.feature.tokendetails + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.tokendetails.TokenDetailsFeatureToggles +import javax.inject.Inject + +internal class DefaultTokenDetailsFeatureToggles @Inject constructor( + featureTogglesManager: FeatureTogglesManager, +) : TokenDetailsFeatureToggles { + + override val isQuickTopUpEnabled: Boolean = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15258_QUICK_TOP_UP_ENABLED, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsFeatureModule.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsFeatureModule.kt new file mode 100644 index 0000000000..7797aba8a9 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsFeatureModule.kt @@ -0,0 +1,21 @@ +package com.tangem.feature.tokendetails.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.feature.tokendetails.DefaultTokenDetailsFeatureToggles +import com.tangem.features.tokendetails.TokenDetailsFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object TokenDetailsFeatureModule { + + @Provides + @Singleton + fun provideTokenDetailsFeatureToggles(featureTogglesManager: FeatureTogglesManager): TokenDetailsFeatureToggles { + return DefaultTokenDetailsFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 2500a2bdd5..d2e32e2a48 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig +import java.math.BigDecimal @Suppress("TooManyFunctions") interface TokenDetailsClickIntents { @@ -71,6 +72,8 @@ interface TokenDetailsClickIntents { fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) + fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) + fun onYieldInfoClick() // region Clore migration @@ -168,6 +171,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { override fun onYieldInfoClick() { /* no op */ } + override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { /* no op */ } + override fun onCopyAddress(): TextReference? { /* no op */ return null diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 1ee997f1dc..fb46837cc7 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -64,6 +64,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.offramp.GetOfframpUrlUseCase +import com.tangem.domain.onramp.CheckOnrampAvailabilityUseCase import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetStakingEntryInfoUseCase @@ -84,6 +85,7 @@ import com.tangem.domain.transaction.error.OpenTrustlineError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase import com.tangem.domain.wallets.usecase.GetWalletIconUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase @@ -103,6 +105,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.QuickTopUpBlockFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer @@ -132,6 +135,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import java.math.BigDecimal import javax.inject.Inject @Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration") @@ -192,6 +196,9 @@ internal class TokenDetailsModel @Inject constructor( private val redesignStateController: TokenDetailsStateController, private val swapFeedbackUseCase: SwapFeedbackUseCase, private val swapFeatureToggles: SwapFeatureToggles, + private val quickTopUpBlockFactory: QuickTopUpBlockFactory, + private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase, ) : Model(), TokenDetailsClickIntents, YieldSupplyDepositedWarningComponent.ModelCallback { @@ -304,6 +311,7 @@ internal class TokenDetailsModel @Inject constructor( handleBalanceHiding() checkForActionUpdates() handleNavigationParam() + observeQuickTopUpBlock() } private fun initButtons() { @@ -584,6 +592,43 @@ internal class TokenDetailsModel @Inject constructor( } } + override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { + analyticsEventsHandler.send( + TokenScreenAnalyticsEvent.ButtonQuickTopUp( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + currency = currencyCode, + value = amount.toInt().toString(), + ), + ) + appRouter.push( + AppRoute.Onramp( + source = OnrampSource.TOKEN_DETAILS, + userWalletId = userWalletId, + currency = cryptoCurrency, + initialFiatAmount = amount, + ), + ) + } + + private fun onQuickTopUpOtherClick() { + analyticsEventsHandler.send( + TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + status = ScenarioUnavailabilityReason.None.toReasonAnalyticsText(), + derivationIndex = getAccountIndexOrNull(), + ), + ) + appRouter.push( + AppRoute.Onramp( + source = OnrampSource.TOKEN_DETAILS, + userWalletId = userWalletId, + currency = cryptoCurrency, + ), + ) + } + override fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) { analyticsEventsHandler.send( TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy( @@ -1363,6 +1408,38 @@ internal class TokenDetailsModel @Inject constructor( observeRedesignStakingNotification() } + private fun observeQuickTopUpBlock() { + getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { it.status } + .distinctUntilChanged() + .flatMapLatest { status -> + flow { + val amount = status.value.amount + if (amount == null || !amount.isZero()) { + emit(null) + return@flow + } + val txCount = getTxHistoryItemsCountUseCase(userWalletId, cryptoCurrency) + val availability = checkOnrampAvailabilityUseCase(userWallet) + emit( + quickTopUpBlockFactory.build( + currencyStatus = status, + isTxHistoryEmpty = txCount, + onrampAvailability = availability, + onPresetClick = ::onQuickTopUpClick, + onOtherClick = ::onQuickTopUpOtherClick, + ), + ) + } + } + .onEach { block -> + uiState.value = uiState.value.copy(quickTopUpBlock = block) + redesignStateController.update { state -> state.copy(quickTopUpBlock = block) } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + private fun observeRedesignStakingNotification() { val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) .map { it.status } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/QuickTopUpBlockUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/QuickTopUpBlockUM.kt new file mode 100644 index 0000000000..b887360947 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/QuickTopUpBlockUM.kt @@ -0,0 +1,17 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class QuickTopUpBlockUM( + val amounts: ImmutableList, +) { + @Immutable + data class QuickTopUpAmountUM( + val displayValue: TextReference, + val onClick: () -> Unit, + val isOther: Boolean = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index a4b8ad05d5..72ff8dd5db 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -15,4 +15,5 @@ internal data class TokenDetailsState( val pullToRefreshConfig: PullToRefreshConfig, val isBalanceHidden: Boolean, val isMarketPriceAvailable: Boolean, + val quickTopUpBlock: QuickTopUpBlockUM? = null, ) \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt index 55220af5a7..512109a189 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt @@ -24,6 +24,7 @@ internal data class TokenDetailsUM( val addFundsUM: AddFundsUM, val transferUM: TransferUM, val zeroBalanceActionsUM: ZeroBalanceActionsUM, + val quickTopUpBlock: QuickTopUpBlockUM? = null, ) @Immutable diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactory.kt new file mode 100644 index 0000000000..c09bdcf582 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactory.kt @@ -0,0 +1,79 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import arrow.core.Either +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.onramp.model.OnrampAvailability +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM +import com.tangem.features.tokendetails.TokenDetailsFeatureToggles +import com.tangem.utils.extensions.isZero +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import javax.inject.Inject + +internal class QuickTopUpBlockFactory @Inject constructor( + private val featureToggles: TokenDetailsFeatureToggles, +) { + + fun build( + currencyStatus: CryptoCurrencyStatus, + isTxHistoryEmpty: Either, + onrampAvailability: Either, + onPresetClick: (BigDecimal, String) -> Unit, + onOtherClick: () -> Unit, + ): QuickTopUpBlockUM? { + if (!featureToggles.isQuickTopUpEnabled) return null + + val amount = currencyStatus.value.amount + if (amount == null || !amount.isZero()) return null + + val isHistoryEmpty = isTxHistoryEmpty.fold( + ifLeft = { it is TxHistoryStateError.EmptyTxHistories }, + ifRight = { it == 0 }, + ) + if (!isHistoryEmpty) return null + + val currency = when (val availability = onrampAvailability.getOrNull()) { + is OnrampAvailability.Available -> availability.currency + is OnrampAvailability.ConfirmResidency -> { + if (!availability.country.onrampAvailable) return null + availability.country.defaultCurrency + } + else -> return null + } + + val presets = when (currency.code) { + USD_CODE -> USD_PRESETS + EUR_CODE -> EUR_PRESETS + else -> return null + } + + val presetAmounts = presets.map { value -> + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = stringReference("${currency.unit}$value"), + onClick = { onPresetClick(BigDecimal(value), currency.code) }, + ) + } + val otherAmount = QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = resourceReference(R.string.quick_top_up_chip_other), + onClick = onOtherClick, + isOther = true, + ) + + return QuickTopUpBlockUM( + amounts = (presetAmounts + otherAmount).toImmutableList(), + ) + } + + private companion object { + const val USD_CODE = "USD" + const val EUR_CODE = "EUR" + + val USD_PRESETS = listOf(50, 200, 700) + val EUR_PRESETS = listOf(50, 200, 650) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt new file mode 100644 index 0000000000..cd2765fa44 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt @@ -0,0 +1,135 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastForEach +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.utils.StringsSigns +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM +import kotlinx.collections.immutable.persistentListOf + +private val quickTopUpGradientBrush = Brush.linearGradient( + colors = listOf(Color(0xFFEDE5F3), Color(0xFFD7EDD9)), + start = Offset(0f, 0f), + end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY), +) + +private val quickTopUpBorderBrush = Brush.sweepGradient( + listOf( + Color(0x0D000000), + Color(0x26000000), + Color(0x0D000000), + Color(0x26000000), + ), +) + +@Composable +internal fun QuickTopUpBlock(state: QuickTopUpBlockUM, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(TangemTheme.dimens.radius20) + + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(brush = quickTopUpGradientBrush) + .border(width = 1.dp, brush = quickTopUpBorderBrush, shape = shape), + ) { + Column( + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + val textColor = TangemTheme.colors.text.primary1 + Text( + text = combinedReference( + stringReference("${StringsSigns.LIGHTNING} "), + resourceReference(R.string.quick_top_up_title), + ).resolveReference(), + style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.graphicsLayer { + colorFilter = ColorFilter.tint(textColor, BlendMode.SrcIn) + }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6), + ) { + state.amounts.fastForEach { amountUM -> + Button( + onClick = amountUM.onClick, + shape = CircleShape, + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing0, + ), + colors = ButtonDefaults.buttonColors( + containerColor = TangemTheme.colors.background.primary, + contentColor = TangemTheme.colors.text.primary1, + ), + modifier = Modifier.heightIn(TangemTheme.dimens.size36), + elevation = null, + ) { + Text( + text = amountUM.displayValue.resolveReference(), + style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold), + ) + } + } + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Composable +private fun QuickTopUpBlock_Preview() { + TangemThemePreviewRedesign { + QuickTopUpBlock( + state = QuickTopUpBlockUM( + amounts = persistentListOf( + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = stringReference("\$50"), + onClick = {}, + ), + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = stringReference("\$200"), + onClick = {}, + ), + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = stringReference("\$700"), + onClick = {}, + ), + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = resourceReference(R.string.quick_top_up_chip_other), + onClick = {}, + isOther = true, + ), + ), + ), + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 31eaefbf5e..724f3b1e2e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -226,6 +226,14 @@ private fun TokenDetailsBody( modifier = expressTransactionModifier, ) } + tokenDetailsUM.quickTopUpBlock?.let { quickTopUpBlock -> + item(key = "quick_top_up_block") { + QuickTopUpBlock( + state = quickTopUpBlock, + modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0), + ) + } + } with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index d77790fea8..ea6976fc1b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -160,6 +160,15 @@ internal fun TokenDetailsScreenLegacy( ) } + state.quickTopUpBlock?.let { quickTopUpBlock -> + item(key = "quick_top_up_block") { + QuickTopUpBlock( + state = quickTopUpBlock, + modifier = itemModifier, + ) + } + } + with(txHistoryComponent) { txHistoryContentLegacy(listState = listState, state = txHistoryComponentState) } diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactoryTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactoryTest.kt new file mode 100644 index 0000000000..aa46e392f0 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactoryTest.kt @@ -0,0 +1,347 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.onramp.model.OnrampAvailability +import com.tangem.domain.onramp.model.OnrampCountry +import com.tangem.domain.onramp.model.OnrampCurrency +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.features.tokendetails.TokenDetailsFeatureToggles +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class QuickTopUpBlockFactoryTest { + + private val featureToggles: TokenDetailsFeatureToggles = mockk { + every { isQuickTopUpEnabled } returns true + } + private val factory = QuickTopUpBlockFactory(featureToggles) + + private val zeroBalanceStatus: CryptoCurrencyStatus = mockk { + every { value } returns mockk { + every { amount } returns BigDecimal.ZERO + } + } + + private val nonZeroBalanceStatus: CryptoCurrencyStatus = mockk { + every { value } returns mockk { + every { amount } returns BigDecimal.TEN + } + } + + private val usdCurrency = OnrampCurrency( + name = "US Dollar", + code = "USD", + image = null, + precision = 2, + unit = "$", + ) + + private val eurCurrency = OnrampCurrency( + name = "Euro", + code = "EUR", + image = null, + precision = 2, + unit = "€", + ) + + private val gbpCurrency = OnrampCurrency( + name = "British Pound", + code = "GBP", + image = null, + precision = 2, + unit = "£", + ) + + private val countryMock: OnrampCountry = mockk(relaxed = true) + + private val availableUsd: OnrampAvailability = OnrampAvailability.Available( + country = countryMock, + currency = usdCurrency, + ) + + private val notSupported: OnrampAvailability = OnrampAvailability.NotSupported(country = countryMock) + + private val emptyHistory = TxHistoryStateError.EmptyTxHistories.left() + private val histWithItems = 5.right() + private val histRightZero = 0.right() + + @Test + fun `returns null when feature toggle is disabled`() { + val disabledToggles: TokenDetailsFeatureToggles = mockk { + every { isQuickTopUpEnabled } returns false + } + val disabledFactory = QuickTopUpBlockFactory(disabledToggles) + + val result = disabledFactory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when balance is non-zero`() { + val result = factory.build( + currencyStatus = nonZeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when history has transactions`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = histWithItems, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when onramp is not available`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = notSupported.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when currency is not USD or EUR`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = OnrampAvailability.Available( + country = countryMock, + currency = gbpCurrency, + ).right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns block with USD presets when all conditions met`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNotNull() + val amounts = result!!.amounts + assertThat(amounts.map { it.displayValue }).containsExactly( + stringReference("$50"), + stringReference("$200"), + stringReference("$700"), + resourceReference(R.string.quick_top_up_chip_other), + ).inOrder() + assertThat(amounts.last().isOther).isTrue() + assertThat(amounts.take(3).all { !it.isOther }).isTrue() + } + + @Test + fun `returns block with EUR presets`() { + val availableEur = OnrampAvailability.Available( + country = countryMock, + currency = eurCurrency, + ) + + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableEur.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNotNull() + val amounts = result!!.amounts + assertThat(amounts.map { it.displayValue }).containsExactly( + stringReference("€50"), + stringReference("€200"), + stringReference("€650"), + resourceReference(R.string.quick_top_up_chip_other), + ).inOrder() + assertThat(amounts.last().isOther).isTrue() + } + + @Test + fun `returns block when history count is right zero (boundary case)`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = histRightZero, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNotNull() + } + + @Test + fun `returns block when ConfirmResidency and country supports onramp with USD`() { + val usdCountry = OnrampCountry( + id = "us", + name = "United States", + code = "US", + image = "", + alpha3 = "USA", + continent = "America", + defaultCurrency = usdCurrency, + onrampAvailable = true, + ) + val confirmResidency = OnrampAvailability.ConfirmResidency(country = usdCountry) + + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = confirmResidency.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNotNull() + val amounts = result!!.amounts + assertThat(amounts.map { it.displayValue }).containsExactly( + stringReference("$50"), + stringReference("$200"), + stringReference("$700"), + resourceReference(R.string.quick_top_up_chip_other), + ).inOrder() + } + + @Test + fun `returns null when onramp availability is error`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = OnrampError.DataError(code = "error", description = null).left(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when balance is loading (amount is null)`() { + val loadingStatus: CryptoCurrencyStatus = mockk { + every { value } returns CryptoCurrencyStatus.Loading + } + + val result = factory.build( + currencyStatus = loadingStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when tx history is not implemented`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = TxHistoryStateError.TxHistoryNotImplemented.left(), + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when tx history fetch fails with data error`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = TxHistoryStateError.DataError(RuntimeException("network error")).left(), + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when ConfirmResidency with non-USD or EUR default currency`() { + val gbpCountry = OnrampCountry( + id = "gb", + name = "United Kingdom", + code = "GB", + image = "", + alpha3 = "GBR", + continent = "Europe", + defaultCurrency = gbpCurrency, + onrampAvailable = true, + ) + + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = OnrampAvailability.ConfirmResidency(country = gbpCountry).right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when ConfirmResidency but country does not support onramp`() { + val restrictedCountry = OnrampCountry( + id = "kp", + name = "North Korea", + code = "KP", + image = "", + alpha3 = "PRK", + continent = "Asia", + defaultCurrency = usdCurrency, + onrampAvailable = false, + ) + val confirmResidency = OnrampAvailability.ConfirmResidency(country = restrictedCountry) + + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = confirmResidency.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt index 9f7899e8e9..2da96f8bd2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -337,6 +337,7 @@ private fun AdditionalInfo( id = R.string.initial_wallet_sync_restore_progress, formatArgs = wrappedList(animatedContent.progressPercent), ), + testTag = MainScreenTestTags.SYNC_PROGRESS_TEXT, ) CircularProgressIndicator( modifier = Modifier.size(TangemTheme.dimens.size16), @@ -353,14 +354,14 @@ private fun AdditionalInfo( } @Composable -private fun AdditionalInfoText(text: TextReference) { +private fun AdditionalInfoText(text: TextReference, testTag: String = MainScreenTestTags.DEVICES_COUNT) { Text( text = text.resolveReference(), color = TangemTheme.colors.text.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TangemTheme.typography.caption2, - modifier = Modifier.testTag(MainScreenTestTags.DEVICES_COUNT), + modifier = Modifier.testTag(testTag), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index 4459d2403c..765a96d132 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -1,9 +1,11 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -16,6 +18,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.ForceDarkTheme import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.WalletNotificationTestTags import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import kotlinx.collections.immutable.ImmutableList @@ -73,26 +76,35 @@ internal fun LazyListScope.notifications(configs: ImmutableList { - Notification( - config = item.config, - modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), - iconTint = when (item) { - is WalletNotification.Critical -> TangemTheme.colors.icon.warning - is WalletNotification.Informational -> TangemTheme.colors.icon.accent - is WalletNotification.RateApp -> TangemTheme.colors.icon.attention - is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 - is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention - else -> null - }, - subtitleColor = TangemTheme.colors.text.secondary, - ) - } + else -> DefaultWalletNotification(item = item, modifier = modifier) } }, ) } +@Composable +private fun LazyItemScope.DefaultWalletNotification(item: WalletNotification, modifier: Modifier = Modifier) { + val itemModifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + val taggedModifier = when (item) { + is WalletNotification.AssetsDiscoveryCompleted -> + itemModifier.testTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER) + else -> itemModifier + } + Notification( + config = item.config, + modifier = taggedModifier, + iconTint = when (item) { + is WalletNotification.Critical -> TangemTheme.colors.icon.warning + is WalletNotification.Informational -> TangemTheme.colors.icon.accent + is WalletNotification.RateApp -> TangemTheme.colors.icon.attention + is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 + is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention + else -> null + }, + subtitleColor = TangemTheme.colors.text.secondary, + ) +} + @Composable private fun yieldBoostPromoTitle(): AnnotatedString { val accent = TangemTheme.colors.text.accent diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index ad6ebb8cb7..9323268e46 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1532" +tangemBlockchainSdk = "develop-1535" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-620" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index 9cdc3747e2..7187124c7a 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) alias(deps.plugins.hilt.android) id("configuration") } @@ -17,10 +18,12 @@ tasks.withType().configureEach { dependencies { /** Core */ implementation(projects.core.configToggles) + implementation(projects.core.datasource) implementation(projects.core.utils) /** Tangem libraries */ implementation(tangemDeps.card.core) + implementation(tangemDeps.card.android) /** Firebase */ implementation(platform(deps.firebase.bom)) @@ -28,6 +31,11 @@ dependencies { /** Other */ implementation(deps.arrow.core) + implementation(deps.kotlin.datetime) + implementation(deps.kotlin.serialization) + implementation(deps.moshi) + implementation(deps.okHttp) + implementation(deps.retrofit) /** DI */ implementation(deps.hilt.android) diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt index 716b126163..ba1d0742c7 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt @@ -1,19 +1,34 @@ package com.tangem.lib.auth.di +import android.content.Context import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.squareup.moshi.Moshi +import com.tangem.common.services.secure.SecureStorage +import com.tangem.datasource.di.NetworkMoshi import com.tangem.lib.auth.AuthFeatureToggles import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager +import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.lib.auth.dpop.internal.DefaultDpopProofFactory +import com.tangem.lib.auth.dpop.internal.DisabledDpopProofFactory +import com.tangem.lib.auth.http.DpopAuthorizationInterceptor import com.tangem.lib.auth.nonce.AuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.lib.auth.session.internal.DefaultSessionTokensStore +import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore +import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json import java.security.KeyStore import javax.inject.Named import javax.inject.Singleton @@ -58,4 +73,52 @@ internal object AuthModule { DisabledAuthNonceDecryptor } } + + @Provides + @Singleton + fun provideSessionTokensStore( + authFeatureToggles: AuthFeatureToggles, + @ApplicationContext context: Context, + @NetworkMoshi moshi: Moshi, + dispatchers: CoroutineDispatcherProvider, + ): SessionTokensStore { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledSessionTokensStore + + return runCatching { + val storage: SecureStorage = AndroidSecureStorageV2( + appContext = context, + useStrongBox = false, + name = "tangem_session_tokens", + ) + DefaultSessionTokensStore(storage, moshi, dispatchers) + }.getOrElse { e -> + TangemLogger.e("Failed to init DefaultSessionTokensStore, falling back to disabled store", e) + FirebaseCrashlytics.getInstance().recordException(e) + DisabledSessionTokensStore + } + } + + @Provides + @Singleton + fun provideDpopProofFactory( + authFeatureToggles: AuthFeatureToggles, + deviceKeyManager: DeviceKeyManager, + dispatchers: CoroutineDispatcherProvider, + ): DpopProofFactory { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDpopProofFactory + + return DefaultDpopProofFactory( + deviceKeyManager = deviceKeyManager, + json = Json.Default, + clock = Clock.System, + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideDpopAuthorizationInterceptor( + store: SessionTokensStore, + proofFactory: DpopProofFactory, + ): DpopAuthorizationInterceptor = DpopAuthorizationInterceptor(store, proofFactory) } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt new file mode 100644 index 0000000000..92d9ba6154 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt @@ -0,0 +1,25 @@ +package com.tangem.lib.auth.dpop + +import arrow.core.Option + +/** + * Builds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP proofs (JWS) for outgoing + * HTTP requests. Each proof is bound to a single request — `htm` / `htu` / `ath` claims must + * not be reused, and `jti` is a fresh UUID per invocation. + */ +interface DpopProofFactory { + + /** + * Builds a DPoP-proof for the given request. + * + * @param httpMethod uppercase HTTP method (e.g. `"POST"`). + * @param httpUri target URI **without** query and fragment (RFC 9449 §4.2). + * @param accessToken access token bound to this proof; when present, the SHA-256 hash + * is included as `ath` claim (RFC 9449 §4.3). Pass `null` for unauthenticated + * requests (initial registration, `/authenticate`) or `/refresh` where the access + * token has already expired (RFC 9449 §5). + * @return compact-serialised JWS suitable for the `DPoP:` header, or [arrow.core.None] + * when the device key is unavailable or signing fails. + */ + suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt new file mode 100644 index 0000000000..d46fe19fd4 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt @@ -0,0 +1,101 @@ +package com.tangem.lib.auth.dpop.internal + +import android.util.Base64 +import arrow.core.None +import arrow.core.Option +import arrow.core.Some +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.security.MessageDigest +import java.util.UUID + +internal class DefaultDpopProofFactory( + private val deviceKeyManager: DeviceKeyManager, + private val json: Json, + private val clock: Clock, + private val dispatchers: CoroutineDispatcherProvider, +) : DpopProofFactory { + + override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option = + withContext(dispatchers.default) { + val publicKey = deviceKeyManager.getPublicKey().getOrNull() + + if (publicKey == null) { + TangemLogger.e("DPoP proof skipped: device key unavailable") + return@withContext None + } + + // DeviceKeyManager.getPublicKey() guarantees an uncompressed P-256 point + // (0x04 || X(32) || Y(32)) — see DefaultDeviceKeyManager.getPublicKeyBytes. + val x = publicKey.copyOfRange(fromIndex = 1, toIndex = 1 + COORDINATE_SIZE) + val y = publicKey.copyOfRange(fromIndex = 1 + COORDINATE_SIZE, toIndex = 1 + 2 * COORDINATE_SIZE) + + val header: JsonObject = buildJsonObject { + put("alg", ES256_ALG) + put("typ", DPOP_TYP) + putJsonObject("jwk") { + put("kty", EC_KTY) + put("crv", P256_CRV) + put("x", x.base64UrlNoPad()) + put("y", y.base64UrlNoPad()) + } + } + + val claims: JsonObject = buildJsonObject { + put("jti", UUID.randomUUID().toString()) + put("iat", clock.now().epochSeconds) + put("htm", httpMethod.uppercase()) + put("htu", stripQueryAndFragment(httpUri)) + if (accessToken != null) { + put("ath", sha256(accessToken.toByteArray(Charsets.US_ASCII)).base64UrlNoPad()) + } + } + + val signingInput = json.encodeToString(JsonObject.serializer(), header) + .toByteArray(Charsets.UTF_8).base64UrlNoPad() + + "." + + json.encodeToString(JsonObject.serializer(), claims) + .toByteArray(Charsets.UTF_8).base64UrlNoPad() + + val signature = try { + deviceKeyManager.sign(signingInput.toByteArray(Charsets.US_ASCII)) + } catch (e: Exception) { + TangemLogger.e("Failed to sign DPoP proof", e) + return@withContext None + } + + Some("$signingInput.${signature.base64UrlNoPad()}") + } + + private fun sha256(bytes: ByteArray): ByteArray { + return MessageDigest.getInstance(SHA_256).digest(bytes) + } + + private fun ByteArray.base64UrlNoPad(): String = + Base64.encodeToString(this, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + + /** + * Removes query and fragment without touching scheme/authority/path encoding. + * `java.net.URI.path` would decode percent-encoded bytes (e.g. `%2F` → `/`), which would + * make the `htu` claim diverge from the wire URI and fail DPoP verification. + */ + private fun stripQueryAndFragment(uri: String): String = uri.substringBefore('#').substringBefore('?') + + private companion object { + const val ES256_ALG = "ES256" + const val DPOP_TYP = "dpop+jwt" + const val EC_KTY = "EC" + const val P256_CRV = "P-256" + const val SHA_256 = "SHA-256" + const val COORDINATE_SIZE = 32 + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt new file mode 100644 index 0000000000..eb2242eaa5 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt @@ -0,0 +1,10 @@ +package com.tangem.lib.auth.dpop.internal + +import arrow.core.None +import arrow.core.Option +import com.tangem.lib.auth.dpop.DpopProofFactory + +internal object DisabledDpopProofFactory : DpopProofFactory { + + override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option = None +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt new file mode 100644 index 0000000000..9db1b87cd1 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt @@ -0,0 +1,70 @@ +package com.tangem.lib.auth.http + +import com.tangem.datasource.api.auth.RequiresSessionAuth +import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import retrofit2.Invocation + +/** + * Adds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP headers to requests whose + * Retrofit method is marked with [RequiresSessionAuth]: + * - `Authorization: DPoP ` — present if [SessionTokensStore] holds an access token. + * - `DPoP: ` — freshly generated for every annotated request; `ath` claim is set if + * the access token is present. + * + * Methods **without** the annotation pass through unchanged — keeps public endpoints + * (e.g. `/auth/nonce/auth`, `/auth/authenticate`) free of unnecessary proof generation. + * + * On unrecoverable proof-generation failures (e.g. device key unavailable) the request is passed + * through unmodified — the upstream HTTP layer will surface the resulting 401/403 and the + * `SessionAuthenticator` (if installed) will attempt recovery. + */ +class DpopAuthorizationInterceptor( + private val store: SessionTokensStore, + private val proofFactory: DpopProofFactory, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + + if (!original.requiresSessionAuth()) return chain.proceed(original) + + val accessToken = runBlocking { store.get().getOrNull()?.accessToken } + if (accessToken == null) { + // Annotated endpoint reached without a session — let the upstream HTTP layer surface + // the resulting 401 so `SessionAuthenticator` can drive recovery. + TangemLogger.e("Skipping DPoP headers: no access token in store") + return chain.proceed(original) + } + + val proof = runBlocking { + proofFactory.create(original.method, original.url.toString(), accessToken) + }.getOrNull() + + if (proof == null) { + TangemLogger.e("DPoP proof generation failed; sending request without DPoP headers") + return chain.proceed(original) + } + + return chain.proceed( + original.newBuilder() + .header(HEADER_AUTHORIZATION, "$DPOP_SCHEME $accessToken") + .header(HEADER_DPOP, proof) + .build(), + ) + } + + private fun Request.requiresSessionAuth(): Boolean = + tag(Invocation::class.java)?.method()?.isAnnotationPresent(RequiresSessionAuth::class.java) == true + + private companion object { + const val HEADER_AUTHORIZATION = "Authorization" + const val HEADER_DPOP = "DPoP" + const val DPOP_SCHEME = "DPoP" + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt new file mode 100644 index 0000000000..1eb627ee59 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt @@ -0,0 +1,31 @@ +package com.tangem.lib.auth.session + +import kotlinx.datetime.Instant + +/** + * JWT session tokens issued by the Tangem Auth Service — pure domain model. + * + * Persisted on the device via [SessionTokensStore]. The default implementation serialises a + * storage DTO mirroring the wire format (`TokenApiResponse`) into AES-256-GCM-encrypted local + * storage (`SecureStorage` / `AndroidSecureStorageV2`) with the master key residing in + * AndroidKeystore. Survives app process death but not user data wipe / app uninstall. + * + * The domain class itself carries no serialization annotations: it can grow with business + * helpers (`isAccessTokenExpired`, computed properties, etc.) without touching the on-disk + * format. + * + * @property accessToken short-lived signed JWT (verified via JWKS at API Gateway). Sent as + * `Authorization: DPoP ` on every authenticated request. + * @property refreshToken opaque rotation token. `null` for ORANGE-tier sessions (require full + * re-authentication for every new access token — see SR-8 / token policy by trust tier). + * @property refreshTokenExpiresAt `null` if [refreshToken] is `null`. + * @property walletIds wallet ids bound to the device by the backend, mirrored from token claims + * to avoid parsing the JWT on the client. + */ +data class SessionTokens( + val accessToken: String, + val accessTokenExpiresAt: Instant, + val refreshToken: String?, + val refreshTokenExpiresAt: Instant?, + val walletIds: List, +) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt new file mode 100644 index 0000000000..6aa8fea604 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt @@ -0,0 +1,20 @@ +package com.tangem.lib.auth.session + +import arrow.core.Option + +/** + * Persistent, hardware-backed storage of [SessionTokens]. + * + * Implementations are expected to survive app process death but not user data wipe / app uninstall. + */ +interface SessionTokensStore { + + /** Returns the currently stored tokens, or [arrow.core.None] if the device is not authenticated. */ + suspend fun get(): Option + + /** Atomically replaces the stored tokens. */ + suspend fun save(tokens: SessionTokens) + + /** Removes stored tokens. */ + suspend fun clear() +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt new file mode 100644 index 0000000000..cb2a7c8940 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt @@ -0,0 +1,62 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.None +import arrow.core.Option +import arrow.core.Some +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.tangem.common.services.secure.SecureStorage +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext + +/** + * Stores tokens in [SecureStorage] using the wire-format [TokenApiResponse] as the on-disk + * DTO — keeps the storage layout in lockstep with the Auth Service contract while + * isolating the [SessionTokens] domain model from serialization concerns. + * + * The underlying `AndroidSecureStorageV2` wraps `SharedPreferences` with an AES-256-GCM + * cipher whose key lives in AndroidKeystore, so token blobs are encrypted at rest and only + * decryptable on this device. + */ +internal class DefaultSessionTokensStore( + private val storage: SecureStorage, + private val moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, +) : SessionTokensStore { + + private val adapter: JsonAdapter by lazy { + moshi.adapter(TokenApiResponse::class.java) + } + + override suspend fun get(): Option = withContext(dispatchers.io) { + val payload = storage.getAsString(KEY) ?: return@withContext None + try { + val dto = adapter.fromJson(payload) ?: return@withContext None + Some(SessionTokensConverter.convertBack(dto)) + } catch (e: Exception) { + TangemLogger.e("Failed to decode session tokens; clearing storage", e) + storage.delete(KEY) + None + } + } + + override suspend fun save(tokens: SessionTokens) { + withContext(dispatchers.io) { + storage.store(KEY, adapter.toJson(SessionTokensConverter.convert(tokens))) + } + } + + override suspend fun clear() { + withContext(dispatchers.io) { + storage.delete(KEY) + } + } + + private companion object { + const val KEY = "session_tokens" + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt new file mode 100644 index 0000000000..f5b83d9231 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt @@ -0,0 +1,19 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.None +import arrow.core.Option +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.lib.auth.session.SessionTokensStore + +/** + * No-op fallback used when the backend-authentication feature toggle is off + * or the encrypted storage failed to initialise. + */ +internal object DisabledSessionTokensStore : SessionTokensStore { + + override suspend fun get(): Option = None + + override suspend fun save(tokens: SessionTokens) = Unit + + override suspend fun clear() = Unit +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt new file mode 100644 index 0000000000..7dc85dd12d --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt @@ -0,0 +1,30 @@ +package com.tangem.lib.auth.session.internal + +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.utils.converter.TwoWayConverter +import kotlinx.datetime.Instant + +/** + * Maps between the [SessionTokens] domain model and the [TokenApiResponse] wire/storage DTO. + * `convert` produces the on-disk / on-wire shape; `convertBack` parses ISO-8601 timestamps + * into [kotlinx.datetime.Instant]. + */ +internal object SessionTokensConverter : TwoWayConverter { + + override fun convert(value: SessionTokens): TokenApiResponse = TokenApiResponse( + accessToken = value.accessToken, + accessTokenExpiresAt = value.accessTokenExpiresAt.toString(), + refreshToken = value.refreshToken, + refreshTokenExpiresAt = value.refreshTokenExpiresAt?.toString(), + walletIds = value.walletIds, + ) + + override fun convertBack(value: TokenApiResponse): SessionTokens = SessionTokens( + accessToken = value.accessToken, + accessTokenExpiresAt = Instant.parse(value.accessTokenExpiresAt), + refreshToken = value.refreshToken, + refreshTokenExpiresAt = value.refreshTokenExpiresAt?.let(Instant::parse), + walletIds = value.walletIds, + ) +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt new file mode 100644 index 0000000000..8dee6ba5f9 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt @@ -0,0 +1,170 @@ +package com.tangem.lib.auth.dpop.internal + +import arrow.core.None +import arrow.core.Some +import com.google.common.truth.Truth.assertThat +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.security.MessageDigest +import java.util.Base64 +import java.util.UUID + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultDpopProofFactoryTest { + + private val dispatchers = TestingCoroutineDispatcherProvider() + private val deviceKeyManager: DeviceKeyManager = mockk() + private val json = Json.Default + + // Fixed P-256 public key (uncompressed): 0x04 || X(32) || Y(32). Values are arbitrary but + // span both halves so any off-by-one slice mistake is caught. + private val devicePublicKey: ByteArray = byteArrayOf(0x04) + + ByteArray(COORDINATE_SIZE) { it.toByte() } + + ByteArray(COORDINATE_SIZE) { (it + COORDINATE_SIZE).toByte() } + + private val signatureBytes: ByteArray = ByteArray(SIGNATURE_SIZE) { (it + 1).toByte() } + + private val fixedInstant = Instant.fromEpochSeconds(1_700_000_000) + private val fixedJti = UUID.fromString("11111111-2222-3333-4444-555555555555") + + private lateinit var factory: DefaultDpopProofFactory + + @BeforeEach + fun setup() { + // android.util.Base64 → java.util.Base64 + mockkStatic(android.util.Base64::class) + every { android.util.Base64.encodeToString(any(), any()) } answers { + val bytes = firstArg() + val flags = secondArg() + val padded = flags and android.util.Base64.NO_PADDING == 0 + val encoder = if (flags and android.util.Base64.URL_SAFE != 0) { + if (padded) Base64.getUrlEncoder() else Base64.getUrlEncoder().withoutPadding() + } else { + Base64.getEncoder() + } + encoder.encodeToString(bytes) + } + + mockkStatic(UUID::class) + every { UUID.randomUUID() } returns fixedJti + + coEvery { deviceKeyManager.getPublicKey() } returns Some(devicePublicKey) + coEvery { deviceKeyManager.sign(any()) } returns signatureBytes + + factory = DefaultDpopProofFactory( + deviceKeyManager = deviceKeyManager, + json = json, + clock = object : Clock { override fun now(): Instant = fixedInstant }, + dispatchers = dispatchers, + ) + } + + @AfterEach + fun teardown() = unmockkAll() + + @Test + fun `create produces JWS with ath when access token is provided`() = runTest { + val token = "header.payload.signature" + val proof = factory.create("post", "https://example.com/api/v1/auth/refresh?ignored=1#frag", token) + .getOrNull()!! + + val parts = proof.split('.') + assertThat(parts).hasSize(3) + + val header = decodeJsonObject(parts[0]) + assertThat(header["alg"]?.jsonPrimitive?.contentOrNull).isEqualTo("ES256") + assertThat(header["typ"]?.jsonPrimitive?.contentOrNull).isEqualTo("dpop+jwt") + val jwk = header["jwk"]!!.jsonObject + assertThat(jwk["kty"]?.jsonPrimitive?.contentOrNull).isEqualTo("EC") + assertThat(jwk["crv"]?.jsonPrimitive?.contentOrNull).isEqualTo("P-256") + assertThat(jwk["x"]?.jsonPrimitive?.contentOrNull) + .isEqualTo(base64UrlNoPad(devicePublicKey.sliceArray(1..COORDINATE_SIZE))) + assertThat(jwk["y"]?.jsonPrimitive?.contentOrNull) + .isEqualTo(base64UrlNoPad(devicePublicKey.sliceArray(COORDINATE_SIZE + 1..2 * COORDINATE_SIZE))) + + val claims = decodeJsonObject(parts[1]) + assertThat(claims["jti"]?.jsonPrimitive?.contentOrNull).isEqualTo(fixedJti.toString()) + assertThat(claims["iat"]?.jsonPrimitive?.longOrNull).isEqualTo(fixedInstant.epochSeconds) + assertThat(claims["htm"]?.jsonPrimitive?.contentOrNull).isEqualTo("POST") + assertThat(claims["htu"]?.jsonPrimitive?.contentOrNull).isEqualTo("https://example.com/api/v1/auth/refresh") + assertThat(claims["ath"]?.jsonPrimitive?.contentOrNull) + .isEqualTo(base64UrlNoPad(sha256(token.toByteArray(Charsets.US_ASCII)))) + + assertThat(parts[2]).isEqualTo(base64UrlNoPad(signatureBytes)) + } + + @Test + fun `create omits ath when access token is null`() = runTest { + val proof = factory.create("POST", "https://example.com/refresh", null).getOrNull()!! + + val claims = decodeJsonObject(proof.split('.')[1]) + assertThat(claims.containsKey("ath")).isFalse() + assertThat(claims["htm"]?.jsonPrimitive?.contentOrNull).isEqualTo("POST") + } + + @Test + fun `htu preserves percent-encoded characters in path`() = runTest { + // DPoP verification is byte-sensitive: %2F must NOT be decoded to / in htu. + val proof = factory.create("GET", "https://api.example.com/wallet%2F123/sub?x=1", null).getOrNull()!! + + val claims = decodeJsonObject(proof.split('.')[1]) + assertThat(claims["htu"]?.jsonPrimitive?.contentOrNull) + .isEqualTo("https://api.example.com/wallet%2F123/sub") + } + + @Test + fun `create returns None when device key unavailable`() = runTest { + coEvery { deviceKeyManager.getPublicKey() } returns None + + val result = factory.create("POST", "https://example.com", null) + + assertThat(result).isEqualTo(None) + } + + @Test + fun `create signs the b64u-encoded header dot payload`() = runTest { + factory.create("GET", "https://example.com", null) + + // The signing input is `.` — verify it has a dot separator and + // a non-empty header section. + coVerify { + deviceKeyManager.sign(match { bytes -> + val text = String(bytes, Charsets.US_ASCII) + text.contains('.') && text.substringBefore('.').isNotEmpty() + }) + } + } + + private fun decodeJsonObject(b64: String): JsonObject = + json.parseToJsonElement(String(Base64.getUrlDecoder().decode(b64), Charsets.UTF_8)).jsonObject + + private fun base64UrlNoPad(bytes: ByteArray): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + + private fun sha256(bytes: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(bytes) + + private companion object { + const val COORDINATE_SIZE = 32 + const val SIGNATURE_SIZE = 64 + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt new file mode 100644 index 0000000000..d96da75296 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt @@ -0,0 +1,142 @@ +package com.tangem.lib.auth.http + +import arrow.core.None +import arrow.core.Some +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.auth.RequiresSessionAuth +import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.lib.auth.session.SessionTokensStore +import io.mockk.CapturingSlot +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.datetime.Instant +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import retrofit2.Invocation +import java.lang.reflect.Method + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DpopAuthorizationInterceptorTest { + + private val store: SessionTokensStore = mockk() + private val proofFactory: DpopProofFactory = mockk() + + private val interceptor = DpopAuthorizationInterceptor(store, proofFactory) + + @BeforeEach + fun setup() { + clearMocks(store, proofFactory) + } + + private val storedTokens = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = Instant.fromEpochSeconds(1_700_000_000), + refreshToken = "rt", + refreshTokenExpiresAt = Instant.fromEpochSeconds(1_700_003_600), + walletIds = emptyList(), + ) + + @Test + fun `annotated request gets Authorization and DPoP headers`() { + coEvery { store.get() } returns Some(storedTokens) + coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt") + + val proceeded = slot() + val chain = chain(request(annotated = true), proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("Authorization")).isEqualTo("DPoP old-access") + assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt") + } + + @Test + fun `annotated request without access token passes through unmodified`() { + coEvery { store.get() } returns None + + val proceeded = slot() + val chain = chain(request(annotated = true), proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("Authorization")).isNull() + assertThat(proceeded.captured.header("DPoP")).isNull() + coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) } + } + + @Test + fun `unannotated request passes through unchanged — proof factory never invoked`() { + val original = request(annotated = false) + val proceeded = slot() + val chain = chain(original, proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("Authorization")).isNull() + assertThat(proceeded.captured.header("DPoP")).isNull() + coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) } + } + + @Test + fun `request without Invocation tag (not via Retrofit) is treated as unannotated`() { + val original = Request.Builder().url("https://example.com/api/v1/foo").build() + val proceeded = slot() + val chain = chain(original, proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("DPoP")).isNull() + coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) } + } + + @Test + fun `proof generation failure on annotated request passes through without headers`() { + coEvery { store.get() } returns Some(storedTokens) + coEvery { proofFactory.create(any(), any(), any()) } returns None + + val proceeded = slot() + val chain = chain(request(annotated = true), proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("Authorization")).isNull() + assertThat(proceeded.captured.header("DPoP")).isNull() + } + + private fun request(annotated: Boolean): Request { + val builder = Request.Builder().url("https://example.com/api/v1/foo") + builder.tag(Invocation::class.java, invocationWithAnnotation(annotated)) + return builder.build() + } + + private fun invocationWithAnnotation(annotated: Boolean): Invocation { + val method = mockk() + every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns annotated + val invocation = mockk() + every { invocation.method() } returns method + return invocation + } + + private fun chain(request: Request, captureSlot: CapturingSlot): Interceptor.Chain { + val response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("ok") + .build() + val chain = mockk() + every { chain.request() } returns request + every { chain.proceed(capture(captureSlot)) } returns response + return chain + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt new file mode 100644 index 0000000000..e603fa8d27 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt @@ -0,0 +1,97 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.None +import arrow.core.Some +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.common.services.secure.SecureStorage +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSessionTokensStoreTest { + + private val dispatchers = TestingCoroutineDispatcherProvider() + private val moshi = Moshi.Builder().build() + private val adapter = moshi.adapter(TokenApiResponse::class.java) + + private val sampleDto = TokenApiResponse( + accessToken = "acc", + accessTokenExpiresAt = "2023-11-14T22:13:20Z", + refreshToken = "rt", + refreshTokenExpiresAt = "2023-11-14T23:13:20Z", + walletIds = listOf("w1", "w2"), + ) + + private val sampleDomain = SessionTokens( + accessToken = "acc", + accessTokenExpiresAt = Instant.parse("2023-11-14T22:13:20Z"), + refreshToken = "rt", + refreshTokenExpiresAt = Instant.parse("2023-11-14T23:13:20Z"), + walletIds = listOf("w1", "w2"), + ) + + @Test + fun `get returns None when nothing stored`() = runTest { + val storage = mockk(relaxed = true) + every { storage.getAsString("session_tokens") } returns null + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + + assertThat(store.get()).isEqualTo(None) + } + + @Test + fun `save round-trips through TokenApiResponse adapter`() = runTest { + val storage = mockk(relaxed = true) + val captured = slot() + every { storage.store(eq("session_tokens"), capture(captured)) } returns Unit + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + store.save(sampleDomain) + + verify { storage.store("session_tokens", any()) } + val decoded = adapter.fromJson(captured.captured) + assertThat(decoded).isEqualTo(sampleDto) + } + + @Test + fun `get decodes TokenApiResponse and maps to domain`() = runTest { + val storage = mockk(relaxed = true) + every { storage.getAsString("session_tokens") } returns adapter.toJson(sampleDto) + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + + assertThat(store.get()).isEqualTo(Some(sampleDomain)) + } + + @Test + fun `get returns None and clears corrupted entry`() = runTest { + val storage = mockk(relaxed = true) + every { storage.getAsString("session_tokens") } returns "{not json" + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + + assertThat(store.get()).isEqualTo(None) + verify { storage.delete("session_tokens") } + } + + @Test + fun `clear removes the entry`() = runTest { + val storage = mockk(relaxed = true) + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + store.clear() + + verify { storage.delete("session_tokens") } + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt new file mode 100644 index 0000000000..a7adc7b1dd --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt @@ -0,0 +1,62 @@ +package com.tangem.lib.auth.session.internal + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.lib.auth.session.SessionTokens +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SessionTokensConverterTest { + + private val domain = SessionTokens( + accessToken = "acc", + accessTokenExpiresAt = Instant.parse("2023-11-14T22:13:20Z"), + refreshToken = "rt", + refreshTokenExpiresAt = Instant.parse("2023-11-14T23:13:20Z"), + walletIds = listOf("w1", "w2"), + ) + + private val dto = TokenApiResponse( + accessToken = "acc", + accessTokenExpiresAt = "2023-11-14T22:13:20Z", + refreshToken = "rt", + refreshTokenExpiresAt = "2023-11-14T23:13:20Z", + walletIds = listOf("w1", "w2"), + ) + + @Test + fun `convert maps domain to DTO with ISO-8601 timestamps`() { + assertThat(SessionTokensConverter.convert(domain)).isEqualTo(dto) + } + + @Test + fun `convertBack maps DTO to domain with parsed Instant timestamps`() { + assertThat(SessionTokensConverter.convertBack(dto)).isEqualTo(domain) + } + + @Test + fun `convert round-trip preserves domain value`() { + val roundTripped = SessionTokensConverter.convertBack(SessionTokensConverter.convert(domain)) + assertThat(roundTripped).isEqualTo(domain) + } + + @Test + fun `null refresh token survives both directions`() { + val orangeTier = domain.copy(refreshToken = null, refreshTokenExpiresAt = null) + val orangeDto = dto.copy(refreshToken = null, refreshTokenExpiresAt = null) + + assertThat(SessionTokensConverter.convert(orangeTier)).isEqualTo(orangeDto) + assertThat(SessionTokensConverter.convertBack(orangeDto)).isEqualTo(orangeTier) + } + + @Test + fun `empty walletIds list survives both directions`() { + val noWallets = domain.copy(walletIds = emptyList()) + val noWalletsDto = dto.copy(walletIds = emptyList()) + + assertThat(SessionTokensConverter.convert(noWallets)).isEqualTo(noWalletsDto) + assertThat(SessionTokensConverter.convertBack(noWalletsDto)).isEqualTo(noWallets) + } +} \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 58af77dd60..1e2e3552c5 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -20,6 +20,7 @@ import javax.inject.Inject * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") internal class WalletManagerFactoryCreator @Inject constructor( private val accountCreator: AccountCreator, private val blockchainDataStorage: BlockchainDataStorage, @@ -41,6 +42,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( isSolanaTxHistoryEnabled = featureToggleValues.isSolanaTxHistoryEnabled, isSolanaScaledUiAmountEnabled = featureToggleValues.isSolanaScaledUiAmountEnabled, isHederaErc20Enabled = featureToggleValues.isHederaErc20Enabled, + isStateOverrideGasEstimateEnabled = featureToggleValues.isStateOverrideGasEstimateEnabled, ), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), @@ -52,5 +54,6 @@ internal class WalletManagerFactoryCreator @Inject constructor( val isSolanaScaledUiAmountEnabled: Boolean, val isYieldModeSwapEnabled: Boolean, val isHederaErc20Enabled: Boolean, + val isStateOverrideGasEstimateEnabled: Boolean, ) } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt index 3ff97082fb..ef8a7025e7 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt @@ -23,8 +23,8 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.libs.blockchain_sdk.BuildConfig +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -110,6 +110,9 @@ internal object BlockchainSDKFactoryModule { isHederaErc20Enabled = featureTogglesManager.isFeatureEnabled( FeatureToggles.HEDERA_ERC20_ENABLED, ), + isStateOverrideGasEstimateEnabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE, + ), ), ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4c1a0fb251..590257dd05 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -259,6 +259,9 @@ include(":features:rating:impl") include(":features:stories:api") include(":features:stories:impl") +include(":features:survey:api") +include(":features:survey:impl") + include(":features:txhistory:api") include(":features:txhistory:impl")