Updated on 2026-08-14
This commit is contained in:
parent
f7ac40363f
commit
7832fae9f3
2 changed files with 176 additions and 23 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue