diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index bedaad69a6..cf9fb967fc 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -189,10 +189,22 @@ abstract class BaseTestCase : TestCase( "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, + // Toggles released in 5.39+ — forced on so tests run against the actual build even when the + // app version resolves to 1.0.0-SNAPSHOT on CI (then 1.0.0 < 5.39 would disable them). + "STAKING_ETH_ENABLED" to true, + "DYNAMIC_ADDRESSES_ENABLED" to true, + "SOLANA_TX_HISTORY_ENABLED" to true, + "SOLANA_SCALED_UI_AMOUNT_ENABLED" to true, + "SWAP_AB_ENABLED" to true, "AND_15310_ADD_FUNDS_STAGE1" to true, - "APP_REDESIGN_ENABLED" to true, + "AND_15009_SWAP_PROVIDER_FILTER_ENABLED" to true, + "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true, + "AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED" to true, + "AND_15103_SWAP_RATE_EXPERIENCE_ENABLED" to true, "AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true, + "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED" to true, + // Toggles released in 5.40 + "APP_REDESIGN_ENABLED" to true, ) ) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 272f804ed8..08641bc0d6 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -2,8 +2,6 @@ package com.tangem.scenarios import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.waitUntilAtLeastOneExists import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.clickWithAssertion diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 4faf866ff0..ec4df4e127 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -436,7 +436,7 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti fun assertTokensCount(expectedCount: Int) { semanticsProvider - .onAllNodes(withTestTag(TokenElementsTestTags.TOKEN_PRICE)) + .onAllNodes(withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM), useUnmergedTree = true) .assertCountEquals(expectedCount) } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index f3a2dc4b7c..149aaf1d89 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -1,5 +1,7 @@ package com.tangem.screens +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags @@ -65,7 +67,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide } val fiatBalance: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.BALANCE_FIAT) + hasAnyAncestor(withTestTag(TokenDetailsScreenTestTags.BALANCE_FIAT)) + addSemanticsMatcher(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt index 56bb30983e..a2d7a6c358 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt @@ -3,7 +3,6 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.R -import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.WarningBottomSheetTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen @@ -31,27 +30,23 @@ class WarningBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsP } val okGotItButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) + hasTestTag(WarningBottomSheetTestTags.BUTTON_SECONDARY) hasText(getResourceString(R.string.warning_button_ok)) - useUnmergedTree = true } val gotItButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) + hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY) hasText(getResourceString(R.string.common_got_it)) - useUnmergedTree = true } val cancelButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) + hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY) hasText(getResourceString(R.string.common_cancel)) - useUnmergedTree = true } val connectAnywayButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) + hasTestTag(WarningBottomSheetTestTags.BUTTON_SECONDARY) hasText(getResourceString(R.string.wc_alert_connect_anyway)) - useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 3155dbbe80..1ef7c4baf1 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -383,14 +383,14 @@ class MainScreenActionButtonsTest : BaseTestCase() { step("Open 'Main Screen' on '$cardName' card") { openMainScreen(mockContent = cardType, isTwinsCard = true) } - step("Assert 'Buy' button is displayed") { - onMainScreen { buyButton.assertIsDisplayed() } + step("Assert 'Add funds' button is displayed") { + onMainScreen { addFundsButton.assertIsDisplayed() } } - step("Click on 'Buy' button") { - onMainScreen { buyButton.performClick() } + step("Click on 'Add funds' button") { + onMainScreen { addFundsButton.performClick() } } - step("Click on token: '$tokenTitle'") { - onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).performClick() } + step("Click on 'Buy' button in bottom sheet") { + onAddFundsBottomSheet { buyButton.clickWithAssertion() } } step("Click on 'Confirm' button in 'Dialog'") { waitForIdle() diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index efdc72799c..531f8a6e71 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -88,14 +88,12 @@ class TotalBalanceUpdateTest : BaseTestCase() { } step("Click on 'Add' button in 'Add token' bottom sheet") { flakySafely(WAIT_UNTIL_TIMEOUT) { - onAddTokenBottomSheet { - addButton.performClick() - } - onAddTokenBottomSheet { laterButton.assertIsDisplayed() } + onAddTokenBottomSheet { addButton.performClick() } } } - step("Click on 'Later' button") { - onAddTokenBottomSheet { laterButton.performClick() } + step("Press 'Back' button") { + waitForIdle() + device.uiDevice.pressBack() } step("Press 'Back' button") { waitForIdle() @@ -201,8 +199,11 @@ class TotalBalanceUpdateTest : BaseTestCase() { step("Assert 'Token details screen' open") { onTokenDetailsScreen { screenContainer.assertIsDisplayed() } } - step("Click 'More' button") { - onTokenDetailsTopBar { backButton.clickWithAssertion() } + step("Click on 'Back' button") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onTokenDetailsTopBar { backButton.clickWithAssertion() } + onMainScreen { screenContainer.assertIsDisplayed() } + } } step("Assert $TOTAL_BALANCE is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt index c96032df3a..27b130e296 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt @@ -138,7 +138,7 @@ class AssetsDiscoveryTest : BaseTestCase() { val tetherTitle = "Tether" val ethereumNetworkTitle = "ETHEREUM" val accountName = getResourceString(CoreUiR.string.account_main_account_title) - val expectedTokensCount = 4 + val expectedTokensCount = 2 setupHooks( additionalBeforeAppLaunchSection = { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt index 75bd8276a3..2f69a7e418 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt @@ -3,8 +3,8 @@ package com.tangem.tests.main import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.extensions.SwipeDirection -import com.tangem.common.extensions.swipeVertical import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeVertical import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen @@ -86,10 +86,10 @@ class MainScreenTest : BaseTestCase() { openMainScreen() } step("Assert 'Add & Manage' button is displayed") { - onMainScreen { addAndManageButtonNode.assertIsDisplayed() } + onMainScreen { addAndManageButton().assertIsDisplayed() } } step("Click 'Add & Manage' button") { - onMainScreen { addAndManageButtonNode.clickWithAssertion() } + onMainScreen { addAndManageButton().clickWithAssertion() } } step("Assert 'Organize tokens' option is not displayed (nothing to organize)") { onAddAndManageBottomSheet { organizeTokensButton.assertIsNotDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt index 582555f882..c922a7915d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt @@ -231,7 +231,9 @@ class GaslessSendViaSwapTest : BaseTestCase() { onSendConfirmScreen { secondaryAmount.assertIsDisplayed() } } step("Assert the network fee is paid in '$currencySymbol'") { - onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + } } step("Sign, send and open the 'Transaction sent' screen") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt index 5156609bb7..7ab3fb9ab5 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt @@ -4,9 +4,11 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.DOGECOIN_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendConfirmScreenViaNextButton import com.tangem.scenarios.openSendScreen import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendScreen @@ -20,8 +22,8 @@ import org.junit.Test @HiltAndroidTest class DogecoinWarningsTest : BaseTestCase() { private val tokenName = "Dogecoin" - private val amountToLeaveLessThanDust = "5.78654978" - private val amountToLeaveMoreThanDust = "5.7" + private val amountToLeaveLessThanDust = "5.7045" + private val amountToLeaveMoreThanDust = "5.6" private val amountGreaterThanDust = "0.02" private val amountLessThanDust = "0.005" private val dustAmount = "DOGE 0.01" @@ -56,8 +58,10 @@ class DogecoinWarningsTest : BaseTestCase() { step("Type address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } } step("Assert 'Invalid amount warning' is displayed") { checkSendWarning( @@ -93,8 +97,10 @@ class DogecoinWarningsTest : BaseTestCase() { step("Type address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } } step("Assert 'Invalid amount warning' is not displayed") { checkSendWarning( diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index a1e0bdd716..6e37802279 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -177,12 +177,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { val splashScreen = installSplashScreen() TangemLogger.i("Splash screen installed") - enableEdgeToEdge( - navigationBarStyle = SystemBarStyle.auto( - Color.Transparent.toArgb(), - Color.Transparent.toArgb(), - ), - ) + applyEdgeToEdge() super.onCreate(savedInstanceState) @@ -200,7 +195,10 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } splashScreen.setKeepOnScreenCondition { viewModel.isSplashScreenShown } - splashScreen.setOnExitAnimationListener { provider -> provider.remove() } + splashScreen.setOnExitAnimationListener { provider -> + provider.remove() + applyEdgeToEdge() + } installActivityDependencies() observeAppThemeModeUpdates() @@ -224,6 +222,15 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } } + private fun applyEdgeToEdge() { + enableEdgeToEdge( + navigationBarStyle = SystemBarStyle.auto( + Color.Transparent.toArgb(), + Color.Transparent.toArgb(), + ), + ) + } + private fun setRootContent() { // for now activity is singleTop and after going to ChromeCustomTab it calls onCreate but onDestroy // doesn't calls. It lead to issue that decompose nav stack is not saved in bundle and to restore it diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt index 1f05ac7237..d0d3ef1ccd 100644 --- a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt @@ -78,6 +78,23 @@ internal class DefaultAssetsDiscoveryRepository( assetsDiscoveryStore.clear() } + override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return + + val appliedIds = currencies.mapTo(hashSetOf(), CryptoCurrency::id) + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId) + + assetsDiscoveryStore.removeMatching { token -> + val currency = responseCryptoCurrenciesFactory.createCurrency( + responseToken = token, + userWallet = userWallet, + accountIndex = DerivationIndex.Main, + ) + currency != null && currency.id in appliedIds + } + } + override suspend fun clearPendingFlag(userWalletId: UserWalletId) { setPendingFlag(userWalletId, value = false) } diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt index fa1b8c658e..5798e65444 100644 --- a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt @@ -8,5 +8,7 @@ interface AssetsDiscoveryStore { suspend fun append(tokens: List) + suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean) + suspend fun clear() } \ No newline at end of file diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt index 9bc4d75f7f..740df1181c 100644 --- a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt @@ -18,6 +18,12 @@ internal class DefaultAssetsDiscoveryStore( } } + override suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean) { + persistenceStore.updateData { existing -> + existing.filterNot(predicate) + } + } + override suspend fun clear() { persistenceStore.updateData { emptyList() } } diff --git a/domain/assetsdiscovery/build.gradle.kts b/domain/assetsdiscovery/build.gradle.kts index 6bea487412..9f3d15b8d5 100644 --- a/domain/assetsdiscovery/build.gradle.kts +++ b/domain/assetsdiscovery/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.domain.assetsdiscovery" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { api(projects.domain.core) implementation(projects.domain.models) @@ -19,4 +23,10 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) + + // region Tests + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.common.test) + testImplementation(projects.test.core) + // endregion } \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt index cf9020c3c1..433bef12bf 100644 --- a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt @@ -22,4 +22,6 @@ interface AssetsDiscoveryRepository { suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) + + suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List) } \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt index 5ba4a8ffe6..e889aa89a0 100644 --- a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt @@ -28,9 +28,10 @@ class StartAssetsDiscoveryUseCase( try { analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncStarted()) assetsDiscoveryRepository.runDiscovery(userWalletId) - applyDiscoveredTokens(userWalletId) - assetsDiscoveryRepository.completeDiscovery(userWalletId) - analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted()) + if (applyDiscoveredTokens(userWalletId)) { + assetsDiscoveryRepository.completeDiscovery(userWalletId) + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted()) + } } catch (e: Exception) { TangemLogger.e("Token sync failed for wallet: $userWalletId", e) } finally { @@ -51,7 +52,13 @@ class StartAssetsDiscoveryUseCase( val pendingIds = assetsDiscoveryRepository.getPendingDiscoveryWalletIds() for (walletId in pendingIds) { val isApplied = applyDiscoveredTokens(walletId) - if (isApplied) { + // Clear the pending flag only when discovery is not actively running for this wallet. + // While runDiscovery is in progress it keeps appending tokens after our snapshot, so the + // store may still hold un-applied tokens even though applyDiscoveredTokens returned true. + // Clearing the flag now would strand those tokens if the app is killed before discovery + // finishes; in that case invoke()/completeDiscovery owns clearing the flag once everything + // has been applied. + if (isApplied && !activeSyncJobs.containsKey(walletId)) { assetsDiscoveryRepository.clearPendingFlag(walletId) } } @@ -72,7 +79,7 @@ class StartAssetsDiscoveryUseCase( add = currencies, ).fold( ifRight = { - assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId) + assetsDiscoveryRepository.removeAppliedCurrencies(userWalletId, currencies) true }, ifLeft = { error -> diff --git a/domain/assetsdiscovery/src/test/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCaseTest.kt b/domain/assetsdiscovery/src/test/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCaseTest.kt new file mode 100644 index 0000000000..1a7ba13030 --- /dev/null +++ b/domain/assetsdiscovery/src/test/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCaseTest.kt @@ -0,0 +1,215 @@ +package com.tangem.domain.assetsdiscovery.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.TestAppCoroutineScope +import io.mockk.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class StartAssetsDiscoveryUseCaseTest { + + private val userWalletId = UserWalletId("011") + + private val currencyFactory = MockCryptoCurrencyFactory() + private val appliedCurrency = currencyFactory.ethereum + private val lateDiscoveredCurrency = currencyFactory.createCoin(Blockchain.Polygon) + + private val manageCryptoCurrenciesUseCase = mockk() + private val analyticsEventHandler = mockk(relaxed = true) + + @BeforeEach + fun resetMocks() { + clearMocks(manageCryptoCurrenciesUseCase, analyticsEventHandler) + } + + /** + * Reproduces AND race: `WalletModel.init` triggers [StartAssetsDiscoveryUseCase.applyPendingAssetsDiscovery] + * while [AssetsDiscoveryRepository.runDiscovery] is still appending tokens. A network discovered during the + * (slow) apply window must NOT be wiped by the apply's store cleanup. + */ + @Test + fun `apply keeps tokens discovered concurrently while applying the snapshot`() = runTest { + val repository = FakeAssetsDiscoveryRepository( + initialCurrencies = listOf(appliedCurrency), + pendingWalletIds = listOf(userWalletId), + ) + // Simulate an in-flight discovery batch appending a new network during the apply call. + coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } coAnswers { + repository.store.add(lateDiscoveredCurrency) + Unit.right() + } + + val useCase = createUseCase(repository) + useCase.applyPendingAssetsDiscovery() + advanceUntilIdle() + + assertThat(repository.store.map { it.id }).contains(lateDiscoveredCurrency.id) + assertThat(repository.store.map { it.id }).doesNotContain(appliedCurrency.id) + } + + @Test + fun `apply removes every applied currency when nothing is discovered concurrently`() = runTest { + val repository = FakeAssetsDiscoveryRepository( + initialCurrencies = listOf(appliedCurrency, lateDiscoveredCurrency), + pendingWalletIds = listOf(userWalletId), + ) + coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right() + + val useCase = createUseCase(repository) + useCase.applyPendingAssetsDiscovery() + advanceUntilIdle() + + assertThat(repository.store).isEmpty() + coVerify(exactly = 1) { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } + assertThat(repository.clearedPendingFlagFor).containsExactly(userWalletId) + } + + @Test + fun `apply retains tokens and keeps pending flag when applying fails`() = runTest { + val repository = FakeAssetsDiscoveryRepository( + initialCurrencies = listOf(appliedCurrency), + pendingWalletIds = listOf(userWalletId), + ) + coEvery { + manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) + } returns IllegalStateException("apply failed").left() + + val useCase = createUseCase(repository) + useCase.applyPendingAssetsDiscovery() + advanceUntilIdle() + + assertThat(repository.store.map { it.id }).containsExactly(appliedCurrency.id) + assertThat(repository.clearedPendingFlagFor).isEmpty() + } + + /** + * Guards against premature pending-flag clearing: while `runDiscovery` is still in flight (the wallet + * has an active sync job), `applyPendingAssetsDiscovery` must NOT clear the flag, otherwise tokens + * discovered after the applied snapshot would be stranded if the app is killed before discovery finishes. + */ + @Test + fun `pending flag is not cleared while discovery is still running`() = runTest { + val discoveryGate = CompletableDeferred() + val repository = FakeAssetsDiscoveryRepository( + initialCurrencies = listOf(appliedCurrency), + pendingWalletIds = listOf(userWalletId), + onRunDiscovery = { discoveryGate.await() }, + ) + coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right() + + val useCase = createUseCase(repository) + useCase(userWalletId) // registers an active sync job and parks inside runDiscovery + runCurrent() + useCase.applyPendingAssetsDiscovery() + advanceUntilIdle() + + assertThat(repository.clearedPendingFlagFor).isEmpty() + + discoveryGate.complete(Unit) // let the discovery job finish so the test can complete cleanly + advanceUntilIdle() + } + + /** + * invoke() must not mark the discovery complete when the apply fails: completeDiscovery clears the + * pending flag, so calling it on failure would strand the still-unapplied tokens (no retry possible). + */ + @Test + fun `discovery is not marked complete when apply fails during invoke`() = runTest { + val repository = FakeAssetsDiscoveryRepository( + initialCurrencies = listOf(appliedCurrency), + pendingWalletIds = emptyList(), + ) + coEvery { + manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) + } returns IllegalStateException("apply failed").left() + + val useCase = createUseCase(repository) + useCase(userWalletId) + advanceUntilIdle() + + assertThat(repository.completedFor).isEmpty() + assertThat(repository.store.map { it.id }).containsExactly(appliedCurrency.id) + } + + @Test + fun `discovery is marked complete when apply succeeds during invoke`() = runTest { + val repository = FakeAssetsDiscoveryRepository( + initialCurrencies = listOf(appliedCurrency), + pendingWalletIds = emptyList(), + ) + coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right() + + val useCase = createUseCase(repository) + useCase(userWalletId) + advanceUntilIdle() + + assertThat(repository.completedFor).containsExactly(userWalletId) + assertThat(repository.store).isEmpty() + } + + private fun TestScope.createUseCase( + repository: AssetsDiscoveryRepository, + ): StartAssetsDiscoveryUseCase = StartAssetsDiscoveryUseCase( + assetsDiscoveryRepository = repository, + manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, + appCoroutineScope = TestAppCoroutineScope(this), + analyticsEventHandler = analyticsEventHandler, + ) + + private class FakeAssetsDiscoveryRepository( + initialCurrencies: List, + private val pendingWalletIds: List, + private val onRunDiscovery: suspend () -> Unit = {}, + ) : AssetsDiscoveryRepository { + + val store: MutableList = initialCurrencies.toMutableList() + val clearedPendingFlagFor: MutableList = mutableListOf() + val completedFor: MutableList = mutableListOf() + + override suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List = store.toList() + + override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List) { + val appliedIds = currencies.mapTo(hashSetOf(), CryptoCurrency::id) + store.removeAll { it.id in appliedIds } + } + + override suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) { + store.clear() + } + + override suspend fun getPendingDiscoveryWalletIds(): List = pendingWalletIds + + override suspend fun clearPendingFlag(userWalletId: UserWalletId) { + clearedPendingFlagFor.add(userWalletId) + } + + override suspend fun runDiscovery(userWalletId: UserWalletId) = onRunDiscovery() + + override suspend fun completeDiscovery(userWalletId: UserWalletId) { + completedFor.add(userWalletId) + } + + override fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow = emptyFlow() + + override fun acknowledgeCompletion(userWalletId: UserWalletId) = Unit + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt index a79ceb5399..c4a6dee5bd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt @@ -68,11 +68,10 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: .hazeEffectTangem { blurRadius = ACTION_BLUR_RADIUS } TangemTopBar( modifier = modifier - .statusBarsPadding() - .testTag(TokenDetailsTopBarTestTags.BACK_BUTTON), + .statusBarsPadding(), startContent = { TangemTopBarActionContent( - modifier = actionModifier, + modifier = actionModifier.testTag(TokenDetailsTopBarTestTags.BACK_BUTTON), actionUM = TangemTopBarActionUM( iconRes = R.drawable.ic_arrow_back_28, onClick = topAppBarUM.onBackClick, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt index 8c1eaa8cdf..39c028fe87 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt @@ -144,6 +144,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor( is WalletNotificationUM.TangemPayRefreshNeeded, WalletNotificationUM.TangemPayUnreachable, is WalletNotificationUM.YieldBoostPromo, + is WalletNotificationUM.AssetsDiscoveryCompleted, -> null } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 0be111cadb..f834a58d30 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -5,6 +5,8 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.ui.ds.message.TangemMessageEffect import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase @@ -22,6 +24,7 @@ import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotificationUM +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.hot.sdk.model.HotWalletId import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.extensions.addIf @@ -36,7 +39,7 @@ import javax.inject.Inject * Factory for creating a list of notifications that can be shown on the wallet screen. * These notifications are critical and should be shown separately from each other. */ -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @ModelScoped internal class GetWalletNotificationsFactory @Inject constructor( private val isDemoCardUseCase: IsDemoCardUseCase, @@ -45,6 +48,8 @@ internal class GetWalletNotificationsFactory @Inject constructor( private val accountDependencies: AccountDependencies, private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase, private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase, + private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) { fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow> { val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver @@ -52,11 +57,19 @@ internal class GetWalletNotificationsFactory @Inject constructor( val params = SingleAccountStatusListProducer.Params(userWallet.walletId) val accountStatusListFlow = accountDependencies.singleAccountStatusListSupplier(params) + val assetsDiscoveryProgressFlow = + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled && userWallet is UserWallet.Hot) { + observeAssetsDiscoveryUseCase(userWallet.walletId).distinctUntilChanged() + } else { + flowOf(AssetsDiscoveryProgress.Idle) + } + return combine( flow = accountStatusListFlow, flow2 = isNeedToBackupUseCase(userWallet.walletId).distinctUntilChanged(), flow3 = getAccessCodeSkippedUseCase(userWallet.walletId).distinctUntilChanged(), - ) { accountList, isNeedToBackup, shouldAccessCodeSkipped -> + flow4 = assetsDiscoveryProgressFlow, + ) { accountList, isNeedToBackup, shouldAccessCodeSkipped, assetsDiscoveryProgress -> val totalFiatBalance = accountList.totalFiatBalance val flattenCurrencies = accountList.flattenCurrencies() @@ -101,6 +114,12 @@ internal class GetWalletNotificationsFactory @Inject constructor( clickIntents = clickIntents, ) + addAssetsDiscoveryCompletedNotification( + userWallet = userWallet, + assetsDiscoveryProgress = assetsDiscoveryProgress, + clickIntents = clickIntents, + ) + if (paymentAccountStatus != null) { addTangemPayWarnings( status = paymentAccountStatus, @@ -311,6 +330,20 @@ internal class GetWalletNotificationsFactory @Inject constructor( return any { it.value is CryptoCurrencyStatus.Unreachable } } + private fun MutableList.addAssetsDiscoveryCompletedNotification( + userWallet: UserWallet, + assetsDiscoveryProgress: AssetsDiscoveryProgress, + clickIntents: WalletClickIntents, + ) { + addIf( + element = WalletNotificationUM.AssetsDiscoveryCompleted( + onCloseClick = { clickIntents.onDismissAssetsDiscoveryNotification(userWallet.walletId) }, + onManageTokensClick = { clickIntents.onAssetsDiscoveryManageClick(userWallet.walletId) }, + ), + condition = assetsDiscoveryProgress is AssetsDiscoveryProgress.Completed, + ) + } + private fun MutableList.addFinishWalletActivationNotification( userWallet: UserWallet, totalFiatBalance: TotalFiatBalance, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt index 3ae343814d..e6c36a1e49 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt @@ -526,5 +526,30 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t ), type = WalletNotificationType.Informational, ) + + data class AssetsDiscoveryCompleted( + val onCloseClick: () -> Unit, + val onManageTokensClick: () -> Unit, + ) : WalletNotificationUM( + messageUM = TangemMessageUM( + id = "AssetsDiscoveryCompletedNotification", + title = resourceReference(R.string.initial_wallet_sync_banner_title), + subtitle = resourceReference(R.string.initial_wallet_sync_banner_description), + iconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_check_circle_24, + tintReference = { TangemTheme.colors2.graphic.status.accent }, + ), + messageEffect = TangemMessageEffect.None, + onCloseClick = onCloseClick, + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(R.string.main_manage_tokens), + onClick = onManageTokensClick, + type = TangemButtonType.Secondary, + ), + ), + ), + type = WalletNotificationType.Informational, + ) // endregion } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt index cdb2f79b77..013b583609 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt @@ -134,6 +134,7 @@ private fun SubtitleRow(walletBalanceUM: WalletBalanceUM, modifier: Modifier = M ).resolveReference(), style = TangemTheme.typography2.bodyRegular14, color = TangemTheme.colors2.text.neutral.tertiary, + modifier = Modifier.testTag(MainScreenTestTags.SYNC_PROGRESS_TEXT), ) CircularProgressIndicator( modifier = Modifier.size(19.dp),