Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-09 18:00:10 +05:00
parent f7ac40363f
commit 7832fae9f3
2 changed files with 176 additions and 23 deletions

View file

@ -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<CryptoCurrency>? {
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
}
}

View file

@ -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<UserWalletId>()) }
}
@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<UserWalletId>()) }
}
@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<UserWalletId>()) }
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
private fun defaultQueryParams() = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",