From 7832fae9f3cc6139186db16487bdef4c5ebede23 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 18:00:10 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../DefaultTokenDetailsDeepLinkHandler.kt | 93 +++++++++++---- .../DefaultTokenDetailsDeepLinkHandlerTest.kt | 106 ++++++++++++++++++ 2 files changed, 176 insertions(+), 23 deletions(-) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index 42605a81a8..3d6772d33a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -31,7 +31,10 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull @Suppress("LongParameterList") internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( @@ -83,10 +86,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } - // Refresh the portfolio before searching so a token just added on the backend is present locally. - refreshAccountsIfNeeded(userWallet) - - val cryptoCurrency = findCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId) + val cryptoCurrency = resolveCryptoCurrency(userWallet, networkId, tokenId) if (cryptoCurrency == null) { TangemLogger.e( @@ -130,6 +130,28 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } + /** + * Resolves the target currency for the deeplink: refreshes the portfolio when needed and searches for the token. + * + * A multi-currency link needs both [networkId] and [tokenId] to match a token; a malformed link can never match, + * so we skip the refresh/await entirely to avoid wasted backend work and return immediately for the redirect. + */ + private suspend fun resolveCryptoCurrency( + userWallet: UserWallet, + networkId: String?, + tokenId: String?, + ): CryptoCurrency? { + if (userWallet.isMultiCurrency && (networkId.isNullOrBlank() || tokenId.isNullOrBlank())) return null + + val wasRefreshed = refreshAccountsIfNeeded(userWallet) + return findCryptoCurrency( + userWallet = userWallet, + networkId = networkId, + tokenId = tokenId, + awaitOnMiss = wasRefreshed, + ) + } + /** * Refreshes wallet accounts so a token just added on the backend appears in the local portfolio. * @@ -137,12 +159,17 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( * on cold start the fresh list is already loaded by the regular auth flow, and single-currency * wallets have a fixed token. The fetch is best-effort — on failure we fall through and try the * current cache, so existing tokens (e.g. swap/onramp pushes) still open without regression. + * + * @return `true` only when a refresh was actually performed and succeeded. Waiting for the refreshed + * list (see [awaitCryptoCurrency]) makes sense only in that case; otherwise there is nothing to wait for. */ - private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet) { + private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet): Boolean { if (isFromOnNewIntent && userWallet.isMultiCurrency) { - singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId)) + return singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId)) .onLeft { TangemLogger.e("Error on refreshing wallet accounts", it) } + .isRight() } + return false } private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) { @@ -158,26 +185,42 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } - private suspend fun findCryptoCurrency(userWallet: UserWallet, networkId: String?, tokenId: String?) = - if (userWallet.isMultiCurrency) { - val derivationPath = queryParams[DERIVATION_PATH_KEY] - - getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency -> - val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true) - val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true - - val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card - val isCustomDerivation = derivationPath?.equals(currency.network.derivationPath.value) == true - val isCorrectDerivation = isDefaultDerivation || isCustomDerivation - isNetwork && isCurrency && isCorrectDerivation - } - } else { - singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) + private suspend fun findCryptoCurrency( + userWallet: UserWallet, + networkId: String?, + tokenId: String?, + awaitOnMiss: Boolean, + ): CryptoCurrency? { + if (!userWallet.isMultiCurrency) { + return singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) ?.mainAccount?.cryptoCurrencies?.first() } - private suspend fun getCryptoCurrencies(userWalletId: UserWalletId): List? { - return singleAccountListSupplier.getSyncOrNull(userWalletId)?.flattenCurrencies() + val derivationPath = queryParams[DERIVATION_PATH_KEY] + val matches = { currency: CryptoCurrency -> currency.matches(networkId, tokenId, derivationPath) } + + return singleAccountListSupplier.getSyncOrNull(userWallet.walletId)?.flattenCurrencies()?.firstOrNull(matches) + // getSyncOrNull returns the stale SharedFlow replay just after a fetch; wait for the refreshed list. + // Only when a refresh actually ran and succeeded — otherwise a missing token would block for the full + // timeout before the fall-through redirect. + ?: if (awaitOnMiss) awaitCryptoCurrency(userWallet.walletId, matches) else null + } + + private suspend fun awaitCryptoCurrency( + userWalletId: UserWalletId, + matches: (CryptoCurrency) -> Boolean, + ): CryptoCurrency? = withTimeoutOrNull(TOKEN_APPEARANCE_TIMEOUT_MILLIS) { + singleAccountListSupplier(userWalletId) + .mapNotNull { accountList -> accountList.flattenCurrencies().firstOrNull(matches) } + .firstOrNull() + } + + private fun CryptoCurrency.matches(networkId: String?, tokenId: String?, derivationPath: String?): Boolean { + val isNetwork = network.rawId.equals(networkId, ignoreCase = true) + val isCurrency = id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + val isDefaultDerivation = network.derivationPath is Network.DerivationPath.Card + val isCustomDerivation = derivationPath?.equals(network.derivationPath.value) == true + return isNetwork && isCurrency && (isDefaultDerivation || isCustomDerivation) } @AssistedFactory @@ -188,4 +231,8 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( isFromOnNewIntent: Boolean, ): DefaultTokenDetailsDeepLinkHandler } + + private companion object { + const val TOKEN_APPEARANCE_TIMEOUT_MILLIS = 3_000L + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt index deefc4ebdf..803f252096 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt @@ -31,6 +31,7 @@ import com.tangem.utils.logging.TangemLogger import io.mockk.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -532,6 +533,9 @@ class DefaultTokenDetailsDeepLinkHandlerTest { mockMultiCurrencyWallet(userWalletId) mockSelectWallet(userWalletId) coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null + every { singleAccountListSupplier.invoke(userWalletId) } returns MutableStateFlow( + AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()), + ) createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) advanceUntilIdle() @@ -565,6 +569,108 @@ class DefaultTokenDetailsDeepLinkHandlerTest { } } + @Test + fun `GIVEN token appears only after refresh WHEN handle deeplink THEN push new route`() = runTest { + val userWalletId = UserWalletId("011") + val cryptoCurrency = mockCryptoCurrency() + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + + val staleList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()) + val freshList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(cryptoCurrency)) + val accountListFlow = MutableStateFlow(staleList) + + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns staleList + every { singleAccountListSupplier.invoke(userWalletId) } returns accountListFlow + coEvery { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) } answers { + accountListFlow.value = freshList + Either.Right(Unit) + } + every { + cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = cryptoCurrency) + } just Runs + val expectedRoute = AppRoute.CurrencyDetails(userWalletId = userWalletId, currency = cryptoCurrency) + + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) + advanceUntilIdle() + + verify { appRouter.push(route = expectedRoute, onComplete = any()) } + verify(exactly = 0) { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + } + + @Test + fun `GIVEN cold start AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + + // Act + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = false) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + } + + @Test + fun `GIVEN refresh failed AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = + runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { + singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) + } returns Either.Left(IllegalStateException("service unavailable")) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + + // Act + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + } + + @Test + fun `GIVEN malformed deeplink AND refresh succeeded WHEN handle deeplink THEN redirect to main without awaiting`() = + runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + DERIVATION_PATH_KEY to "777", + // TOKEN_ID_KEY is missing + ) + + // Act + createHandler(scope = this, queryParams, isFromOnNewIntent = true) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) } + } + private fun defaultQueryParams() = mapOf( WALLET_ID_KEY to "011", NETWORK_ID_KEY to "123",