From 7b182e97b3920f60a023317d5281bc06e462bc2d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 2 Sep 2026 19:53:24 +0800 Subject: [PATCH 1/6] fix(market): merge local and online search results --- .../android/extension/IterableExtension.kt | 23 +++ .../repository/PerpsMarketRepository.kt | 35 +++- .../home/web3/market/MarketSearchViewModel.kt | 54 ++++-- .../android/db/perps/PerpsMarketDaoTest.kt | 33 ++++ .../repository/PerpsMarketRepositoryTest.kt | 144 ++++++++++++++++ .../market/MarketSearchViewModelSearchTest.kt | 162 ++++++++++++++++++ 6 files changed, 439 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt diff --git a/app/src/main/java/one/mixin/android/extension/IterableExtension.kt b/app/src/main/java/one/mixin/android/extension/IterableExtension.kt index c46c8cc533..0659c4f0ee 100644 --- a/app/src/main/java/one/mixin/android/extension/IterableExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/IterableExtension.kt @@ -8,3 +8,26 @@ suspend fun Iterable.pmap(f: suspend (A) -> B): List = coroutineScope { map { async { f(it) } }.awaitAll() } + +fun mergeLocalAndRefreshed( + localMatches: List, + refreshedMatches: List, + keySelector: (T) -> K, +): List { + val refreshedByKey = refreshedMatches.associateBy(keySelector) + val localKeys = LinkedHashSet() + val merged = ArrayList(localMatches.size + refreshedMatches.size) + + localMatches.forEach { local -> + val key = keySelector(local) + localKeys += key + merged += refreshedByKey[key] ?: local + } + refreshedMatches.forEach { refreshed -> + if (keySelector(refreshed) !in localKeys) { + merged += refreshed + } + } + + return merged +} diff --git a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt index 217aea60ec..ad30490a59 100644 --- a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt @@ -1,6 +1,7 @@ package one.mixin.android.repository import androidx.room.withTransaction +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import one.mixin.android.Constants import one.mixin.android.api.response.perps.PerpsFavorite @@ -12,6 +13,7 @@ import one.mixin.android.db.PerpsDatabase import one.mixin.android.db.perps.PerpsFavoriteDao import one.mixin.android.db.perps.PerpsMarketCategoryDao import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.extension.mergeLocalAndRefreshed import one.mixin.android.extension.nowInUtc import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.vo.market.MarketCategory @@ -40,6 +42,13 @@ class PerpsMarketRepository suspend fun searchMarkets(query: String): List = marketDao.searchMarkets(query.trim()) + suspend fun searchMarketsOnlineFirst(query: String): List = + searchPerpsMarketsOnlineFirst( + query = query, + searchLocalMarkets = marketDao::searchMarkets, + syncOnlineMarkets = { syncAllMarkets() }, + ) + suspend fun getMarket(marketId: String): PerpsMarket? = marketDao.getMarket(marketId) suspend fun syncAllMarkets(): List? { @@ -167,7 +176,10 @@ class PerpsMarketRepository response.data.orEmpty().map(PerpsMarket::withDefaults) }, failureBlock = { true }, - exceptionBlock = { true }, + exceptionBlock = { throwable -> + if (throwable is CancellationException) throw throwable + true + }, defaultErrorHandle = {}, defaultExceptionHandle = {}, requestSession = { @@ -179,3 +191,24 @@ class PerpsMarketRepository const val CATEGORY_FAVORITE = "favorite" } } + +internal suspend fun searchPerpsMarketsOnlineFirst( + query: String, + searchLocalMarkets: suspend (query: String) -> List, + syncOnlineMarkets: suspend () -> Unit, +): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) return emptyList() + + val localMatches = searchLocalMarkets(normalizedQuery) + + return try { + syncOnlineMarkets() + val refreshedMatches = searchLocalMarkets(normalizedQuery) + mergeLocalAndRefreshed(localMatches, refreshedMatches, PerpsMarket::marketId) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + localMatches + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index fa7490de13..cadf36653a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import one.mixin.android.Constants.Account.PREF_MARKET_RECENT_SEARCH import one.mixin.android.Constants.Account.PREF_RECENT_SEARCH import one.mixin.android.extension.escapeSql +import one.mixin.android.extension.mergeLocalAndRefreshed import one.mixin.android.extension.putString import one.mixin.android.extension.remove import one.mixin.android.repository.PerpsMarketRepository @@ -27,6 +28,7 @@ import one.mixin.android.repository.TokenRepository import one.mixin.android.util.GsonHelper import one.mixin.android.vo.RecentSearch import one.mixin.android.vo.RecentSearchType +import one.mixin.android.vo.market.Market import one.mixin.android.vo.market.MarketCategory import one.mixin.android.vo.market.MarketItem import javax.inject.Inject @@ -205,15 +207,18 @@ internal class MarketSearchViewModel private suspend fun searchSpotMarkets(query: String): List = try { - val escapedQuery = query.escapeSql() - var markets = tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal()) - if (markets.isEmpty()) { - tokenRepository.searchMarket(query) - markets = tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal()) - } - markets.map { market -> - tokenRepository.findMarketItemByCoinId(market.coinId) ?: MarketItem.fromMarket(market) - } + searchSpotMarketsOnlineFirst( + query = query, + searchLocalMarkets = { escapedQuery -> + tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal()) + }, + refreshOnlineMarkets = { normalizedQuery -> + tokenRepository.searchMarket(normalizedQuery) + }, + resolveMarketItem = { market -> + tokenRepository.findMarketItemByCoinId(market.coinId) ?: MarketItem.fromMarket(market) + }, + ) } catch (e: CancellationException) { throw e } catch (_: Exception) { @@ -222,11 +227,38 @@ internal class MarketSearchViewModel private suspend fun searchPerpetualMarkets(query: String) = try { - initialPerpetualSyncJob.join() - perpsMarketRepository.searchMarkets(query) + perpsMarketRepository.searchMarketsOnlineFirst(query) } catch (e: CancellationException) { throw e } catch (_: Exception) { emptyList() } +} + +internal suspend fun searchSpotMarketsOnlineFirst( + query: String, + searchLocalMarkets: suspend (escapedQuery: String) -> List, + refreshOnlineMarkets: suspend (query: String) -> Unit, + resolveMarketItem: suspend (Market) -> MarketItem, +): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) return emptyList() + + val escapedQuery = normalizedQuery.escapeSql() + val localMatches = searchLocalMarkets(escapedQuery) + + val markets = + try { + refreshOnlineMarkets(normalizedQuery) + val refreshedMatches = searchLocalMarkets(escapedQuery) + mergeLocalAndRefreshed(localMatches, refreshedMatches, Market::coinId) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + localMatches + } + + return markets.map { market -> + resolveMarketItem(market) } +} diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt index 5447e026f4..3428d34a71 100644 --- a/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt @@ -98,6 +98,39 @@ class PerpsMarketDaoTest { assertEquals(listOf("btc-usdt", "eth-btc"), result.map(PerpsMarket::marketId)) } + @Test + fun upsertedRefreshedMarketsBecomeSearchableWithoutClearingDatabase() = + runBlocking { + database.perpsMarketDao().upsertList( + listOf( + market("eth-usdt", volume = "50", score = 0).copy( + displaySymbol = "ETHUSDT", + tokenSymbol = "ETH", + quoteSymbol = "USDT", + ), + ), + ) + + database.perpsMarketDao().upsertList( + listOf( + market("btc-usdt", volume = "100", score = 0).copy( + displaySymbol = "BTCUSDT", + tokenSymbol = "BTC", + quoteSymbol = "USDT", + ), + market("eth-usdt", volume = "60", score = 0).copy( + displaySymbol = "ETHUSDT", + tokenSymbol = "ETH", + quoteSymbol = "USDT", + ), + ), + ) + + val result = database.perpsMarketDao().searchMarkets("btc") + + assertEquals(listOf("btc-usdt"), result.map(PerpsMarket::marketId)) + } + @Test fun favoriteMarketsOrderByNewestAddition() = runBlocking { diff --git a/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt b/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt new file mode 100644 index 0000000000..8965dd49f5 --- /dev/null +++ b/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt @@ -0,0 +1,144 @@ +package one.mixin.android.repository + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import one.mixin.android.api.response.perps.PerpsMarket +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PerpsMarketRepositoryTest { + @Test + fun perpsSearch_callsSyncEvenWhenLocalMatchesAreNonEmpty() = + runBlocking { + val local = listOf(market("btc-usdt")) + var syncCalls = 0 + + searchPerpsMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { local }, + syncOnlineMarkets = { syncCalls += 1 }, + ) + + assertEquals(1, syncCalls) + } + + @Test + fun perpsSearch_appendsRemoteOnlyMatchesAfterSync() = + runBlocking { + val initialLocal = listOf(market("btc-usdt")) + val refreshedLocal = listOf(market("btc-usdt"), market("btc-usdc")) + var searchCalls = 0 + + val result = + searchPerpsMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) initialLocal else refreshedLocal + }, + syncOnlineMarkets = {}, + ) + + assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(PerpsMarket::marketId)) + } + + @Test + fun perpsSearch_preservesLocalOrderButUsesRefreshedOverlappingMatchesByMarketId() = + runBlocking { + val staleLocal = listOf(market("eth-usdt").copy(last = "1"), market("btc-usdt").copy(last = "1")) + val refreshedLocal = + listOf( + market("eth-usdt").copy(last = "2"), + market("btc-usdt").copy(last = "3"), + market("sol-usdt"), + ) + var searchCalls = 0 + + val result = + searchPerpsMarketsOnlineFirst( + query = "usdt", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) staleLocal else refreshedLocal + }, + syncOnlineMarkets = {}, + ) + + assertEquals(listOf("eth-usdt", "btc-usdt", "sol-usdt"), result.map(PerpsMarket::marketId)) + assertEquals("2", result[0].last) + assertEquals("3", result[1].last) + } + + @Test + fun perpsSearch_returnsInitialLocalMatchesWhenSyncFails() = + runBlocking { + val local = listOf(market("btc-usdt")) + + val result = + searchPerpsMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { local }, + syncOnlineMarkets = { error("offline") }, + ) + + assertEquals(listOf("btc-usdt"), result.map(PerpsMarket::marketId)) + } + + @Test(expected = CancellationException::class) + fun perpsSearch_propagatesCancellation() { + runBlocking { + searchPerpsMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { listOf(market("btc-usdt")) }, + syncOnlineMarkets = { throw CancellationException("cancelled") }, + ) + } + } + + @Test + fun perpsSearch_blankQuerySkipsLocalAndSyncCalls() = + runBlocking { + var localCalls = 0 + var syncCalls = 0 + + val result = + searchPerpsMarketsOnlineFirst( + query = " ", + searchLocalMarkets = { + localCalls += 1 + emptyList() + }, + syncOnlineMarkets = { syncCalls += 1 }, + ) + + assertTrue(result.isEmpty()) + assertEquals(0, localCalls) + assertEquals(0, syncCalls) + } + + private fun market(marketId: String) = + PerpsMarket( + marketId = marketId, + displaySymbol = marketId, + tokenSymbol = marketId, + quoteSymbol = "USD", + markPrice = "1", + leverage = 10, + iconUrl = "", + fundingRate = "0", + minAmount = "0", + maxAmount = "0", + last = "1", + volume = "1", + tradeVolumeScore1D = 0, + high = "1", + low = "1", + open = "1", + change = "0", + bidPrice = "1", + askPrice = "1", + createdAt = "", + updatedAt = "", + ) +} diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt new file mode 100644 index 0000000000..4ec36ce6c8 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt @@ -0,0 +1,162 @@ +package one.mixin.android.ui.home.web3.market + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import one.mixin.android.vo.market.Market +import one.mixin.android.vo.market.MarketItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarketSearchViewModelSearchTest { + @Test + fun spotSearch_callsRemoteEvenWhenLocalMatchesAreNonEmpty() = + runBlocking { + val local = listOf(market("btc-local")) + var remoteCalls = 0 + + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { local }, + refreshOnlineMarkets = { remoteCalls++ }, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals(1, remoteCalls) + } + + @Test + fun spotSearch_appendsRemoteOnlyMatchesAfterRefresh() = + runBlocking { + val initialLocal = listOf(market("btc-usdt")) + val refreshedLocal = listOf(market("btc-usdt"), market("btc-usdc")) + var searchCalls = 0 + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) initialLocal else refreshedLocal + }, + refreshOnlineMarkets = {}, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(MarketItem::coinId)) + } + + @Test + fun spotSearch_preservesLocalOrderButUsesRefreshedOverlappingMatches() = + runBlocking { + val staleLocal = listOf(market("btc").copy(name = "Local BTC"), market("eth").copy(name = "Local ETH")) + val refreshedLocal = + listOf( + market("eth").copy(name = "Remote ETH"), + market("btc").copy(name = "Remote BTC"), + market("btc-cash"), + ) + var searchCalls = 0 + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) staleLocal else refreshedLocal + }, + refreshOnlineMarkets = {}, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals(listOf("btc", "eth", "btc-cash"), result.map(MarketItem::coinId)) + assertEquals("Remote BTC", result.first().name) + assertEquals("Remote ETH", result[1].name) + } + + @Test + fun spotSearch_returnsLocalMatchesWhenRemoteRefreshFails() = + runBlocking { + val local = listOf(market("btc")) + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { local }, + refreshOnlineMarkets = { error("offline") }, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals(listOf("btc"), result.map(MarketItem::coinId)) + } + + @Test(expected = CancellationException::class) + fun spotSearch_propagatesCancellation() { + runBlocking { + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { listOf(market("btc")) }, + refreshOnlineMarkets = { throw CancellationException("cancelled") }, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + } + } + + @Test + fun spotSearch_blankQuerySkipsLocalAndRemoteSearch() = + runBlocking { + var localCalls = 0 + var remoteCalls = 0 + + val result = + searchSpotMarketsOnlineFirst( + query = " ", + searchLocalMarkets = { + localCalls += 1 + emptyList() + }, + refreshOnlineMarkets = { remoteCalls += 1 }, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertTrue(result.isEmpty()) + assertEquals(0, localCalls) + assertEquals(0, remoteCalls) + } + + private fun market(coinId: String) = + Market( + coinId = coinId, + name = coinId.uppercase(), + symbol = coinId.uppercase(), + iconUrl = "", + currentPrice = "1", + marketCap = "1", + marketCapRank = "1", + totalVolume = "1", + high24h = "1", + low24h = "1", + priceChange24h = "0", + priceChangePercentage1H = "0", + priceChangePercentage24H = "0", + priceChangePercentage7D = "0", + priceChangePercentage30D = "0", + marketCapChange24h = "0", + marketCapChangePercentage24h = "0", + circulatingSupply = "1", + totalSupply = "1", + maxSupply = "1", + ath = "1", + athChangePercentage = "0", + athDate = "", + atl = "1", + atlChangePercentage = "0", + atlDate = "", + assetIds = emptyList(), + sparklineIn7d = "", + sparklineIn24h = "", + updatedAt = "", + descriptions = null, + perpsMarketId = null, + ) +} From a4958584a59f314e670c248f8013ad397aaec319 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 2 Sep 2026 20:29:23 +0800 Subject: [PATCH 2/6] fix(market): align search section spacing --- .../mixin/android/ui/home/web3/market/MarketSearchPage.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index c1e901e622..d5444662da 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -113,6 +114,7 @@ internal fun MarketSearchPage( text = stringResource(R.string.Recent), color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, + modifier = Modifier.padding(vertical = 16.dp), ) IconButton( onClick = onClearRecentSearches, @@ -130,7 +132,7 @@ internal fun MarketSearchPage( FlowRow( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), + .padding(start = 20.dp, end = 20.dp, bottom = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { @@ -157,7 +159,7 @@ internal fun MarketSearchPage( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), + .padding(start = 20.dp, top = 20.dp, end = 20.dp, bottom = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { tabs.forEach { tab -> @@ -429,6 +431,7 @@ private fun MarketRecentSearchChip( fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(min = 34.dp), ) if (recentSearch.type == RecentSearchType.PERPETUAL) { Spacer(modifier = Modifier.width(3.dp)) @@ -454,6 +457,7 @@ private fun MarketRecentSearchChip( maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), + modifier = Modifier.widthIn(min = 34.dp), ) } } From 847a23bdc5b7937ade26492ea2295758b93f3de1 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 3 Sep 2026 19:38:50 +0800 Subject: [PATCH 3/6] fix(market): sort search results by market cap Keep refreshed spot search results ordered by market cap and format perpetual prices without fiat conversion. --- .../ui/home/web3/market/MarketSearchPage.kt | 2 +- .../home/web3/market/MarketSearchViewModel.kt | 9 +++-- .../market/MarketSearchViewModelSearchTest.kt | 37 ++++++++++++++++++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index d5444662da..0a107d9759 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -527,7 +527,7 @@ private fun PerpetualMarketSearchRow( title = market.displaySymbol.ifBlank { market.tokenSymbol }, badge = stringResource(R.string.Perp), volume = formatSearchPerpetualVolume(market.volume), - price = "$${market.last}", + price = formatPerpsMarketListPrice(market.last), change = market.changePercentValue(), quoteColorReversed = quoteColorReversed, onClick = onClick, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index cadf36653a..84385b8633 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -31,6 +31,7 @@ import one.mixin.android.vo.RecentSearchType import one.mixin.android.vo.market.Market import one.mixin.android.vo.market.MarketCategory import one.mixin.android.vo.market.MarketItem +import java.math.BigDecimal import javax.inject.Inject @HiltViewModel @@ -258,7 +259,9 @@ internal suspend fun searchSpotMarketsOnlineFirst( localMatches } - return markets.map { market -> - resolveMarketItem(market) - } + return markets + .sortedByDescending { it.marketCap.toBigDecimalOrNull() ?: BigDecimal.ZERO } + .map { market -> + resolveMarketItem(market) + } } diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt index 4ec36ce6c8..f695228905 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt @@ -46,6 +46,39 @@ class MarketSearchViewModelSearchTest { assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(MarketItem::coinId)) } + @Test + fun spotSearch_sortsCombinedResultsByMarketCapDescending() = + runBlocking { + val initialLocal = + listOf( + market("btc-low", marketCap = "10"), + market("btc-high", marketCap = "100"), + ) + val refreshedLocal = + listOf( + market("btc-high", marketCap = "100"), + market("btc-new", marketCap = "50"), + market("btc-low", marketCap = "10"), + ) + var searchCalls = 0 + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) initialLocal else refreshedLocal + }, + refreshOnlineMarkets = {}, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals( + listOf("btc-high", "btc-new", "btc-low"), + result.map(MarketItem::coinId), + ) + } + @Test fun spotSearch_preservesLocalOrderButUsesRefreshedOverlappingMatches() = runBlocking { @@ -124,14 +157,14 @@ class MarketSearchViewModelSearchTest { assertEquals(0, remoteCalls) } - private fun market(coinId: String) = + private fun market(coinId: String, marketCap: String = "1") = Market( coinId = coinId, name = coinId.uppercase(), symbol = coinId.uppercase(), iconUrl = "", currentPrice = "1", - marketCap = "1", + marketCap = marketCap, marketCapRank = "1", totalVolume = "1", high24h = "1", From 68cdbfe28a1e2d3aaa1852144d4bcd7ca2612758 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 4 Sep 2026 09:14:10 +0800 Subject: [PATCH 4/6] fix(market): align recent chip text --- .../android/ui/home/web3/market/MarketSearchPage.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index 0a107d9759..44e9e0fe5e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -421,8 +421,10 @@ private fun MarketRecentSearchChip( MarketIcon(url = recentSearch.iconUrl.orEmpty(), size = 32.dp) Spacer(modifier = Modifier.width(6.dp)) Column( - modifier = Modifier.wrapContentWidth(), - verticalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier + .wrapContentWidth() + .height(32.dp), + verticalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -431,6 +433,7 @@ private fun MarketRecentSearchChip( fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + lineHeight = 14.sp, modifier = Modifier.widthIn(min = 34.dp), ) if (recentSearch.type == RecentSearchType.PERPETUAL) { @@ -452,8 +455,8 @@ private fun MarketRecentSearchChip( Text( text = formatSearchPercent(search.change), color = searchChangeColor(search.change, quoteColorReversed), - fontSize = 12.sp, - lineHeight = 12.sp, + fontSize = 13.sp, + lineHeight = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), From 8de2ffb03a773338862a834b23b32ac106ee57e9 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 4 Sep 2026 14:37:36 +0800 Subject: [PATCH 5/6] fix(market): rank market search results --- .../ui/home/web3/market/MarketSearchModels.kt | 16 +++++++++ .../home/web3/market/MarketSearchViewModel.kt | 20 +++++++---- .../web3/market/MarketSearchModelsTest.kt | 32 +++++++++++++++-- .../market/MarketSearchViewModelSearchTest.kt | 35 +++++++++++-------- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt index 7882c64c0d..485be3f533 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt @@ -39,6 +39,22 @@ internal fun List.sortedForTrendingSearch(): List = .thenBy { it.marketId }, ) +internal fun List.sortedForMarketSearch( + query: String, + symbol: (T) -> String, + name: (T) -> String, + volume: (T) -> String, +): List { + val normalizedQuery = query.trim() + return sortedWith( + compareByDescending { symbol(it).equals(normalizedQuery, ignoreCase = true) } + .thenByDescending { name(it).equals(normalizedQuery, ignoreCase = true) } + .thenByDescending { volume(it).toBigDecimalOrNull() ?: BigDecimal.ZERO } + .thenBy { symbol(it).lowercase(Locale.ROOT) } + .thenBy { name(it).lowercase(Locale.ROOT) }, + ) +} + internal data class MarketSearchUiState( val query: String = "", val selectedTab: MarketSearchTab = MarketSearchTab.CRYPTO, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index 84385b8633..9ce888793a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.Constants.Account.PREF_MARKET_RECENT_SEARCH import one.mixin.android.Constants.Account.PREF_RECENT_SEARCH import one.mixin.android.extension.escapeSql @@ -31,7 +32,6 @@ import one.mixin.android.vo.RecentSearchType import one.mixin.android.vo.market.Market import one.mixin.android.vo.market.MarketCategory import one.mixin.android.vo.market.MarketItem -import java.math.BigDecimal import javax.inject.Inject @HiltViewModel @@ -228,7 +228,12 @@ internal class MarketSearchViewModel private suspend fun searchPerpetualMarkets(query: String) = try { - perpsMarketRepository.searchMarketsOnlineFirst(query) + perpsMarketRepository.searchMarketsOnlineFirst(query).sortedForMarketSearch( + query = query, + symbol = PerpsMarket::tokenSymbol, + name = PerpsMarket::displaySymbol, + volume = PerpsMarket::volume, + ) } catch (e: CancellationException) { throw e } catch (_: Exception) { @@ -260,8 +265,11 @@ internal suspend fun searchSpotMarketsOnlineFirst( } return markets - .sortedByDescending { it.marketCap.toBigDecimalOrNull() ?: BigDecimal.ZERO } - .map { market -> - resolveMarketItem(market) - } + .map { market -> resolveMarketItem(market) } + .sortedForMarketSearch( + query = normalizedQuery, + symbol = MarketItem::symbol, + name = MarketItem::name, + volume = MarketItem::totalVolume, + ) } diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt index 220e4eb684..2e57e702f7 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt @@ -47,6 +47,30 @@ class MarketSearchModelsTest { ) } + @Test + fun marketSearchRanksExactFieldsThenVolumeAndAlphabetically() { + val markets = + listOf( + market("volume", volume = "100", tokenSymbol = "BTC-X"), + market("name", volume = "1", tokenSymbol = "AAA", displaySymbol = "BTC"), + market("symbol", volume = "0", tokenSymbol = "BTC"), + market("alpha-b", volume = "10", tokenSymbol = "ETH"), + market("alpha-a", volume = "10", tokenSymbol = "AAA"), + ) + + assertEquals( + listOf("symbol", "name", "volume", "alpha-a", "alpha-b"), + markets + .sortedForMarketSearch( + query = "btc", + symbol = PerpsMarket::tokenSymbol, + name = PerpsMarket::displaySymbol, + volume = PerpsMarket::volume, + ) + .map(PerpsMarket::marketId), + ) + } + @Test fun recentSearchesKeepOnlySpotAndPerpetualMarkets() { val searches = @@ -88,11 +112,13 @@ class MarketSearchModelsTest { private fun market( marketId: String, volume: String, - score: Int, + score: Int = 0, + displaySymbol: String = marketId, + tokenSymbol: String = marketId, ) = PerpsMarket( marketId = marketId, - displaySymbol = marketId, - tokenSymbol = marketId, + displaySymbol = displaySymbol, + tokenSymbol = tokenSymbol, quoteSymbol = "USD", markPrice = "1", leverage = 10, diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt index f695228905..e7dd629732 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt @@ -43,22 +43,23 @@ class MarketSearchViewModelSearchTest { resolveMarketItem = { MarketItem.fromMarket(it) }, ) - assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(MarketItem::coinId)) + assertEquals(listOf("btc-usdc", "btc-usdt"), result.map(MarketItem::coinId)) } @Test - fun spotSearch_sortsCombinedResultsByMarketCapDescending() = + fun spotSearch_sortsByExactSymbolThenNameThenVolume() = runBlocking { val initialLocal = listOf( - market("btc-low", marketCap = "10"), - market("btc-high", marketCap = "100"), + market("volume-low", symbol = "BTC-X", totalVolume = "1"), + market("name-exact", symbol = "AAA", name = "BTC", totalVolume = "1"), ) val refreshedLocal = listOf( - market("btc-high", marketCap = "100"), - market("btc-new", marketCap = "50"), - market("btc-low", marketCap = "10"), + market("volume-low", symbol = "BTC-X", totalVolume = "1"), + market("volume-high", symbol = "BTC-Y", totalVolume = "100"), + market("symbol-exact", symbol = "BTC", totalVolume = "0"), + market("name-exact", symbol = "AAA", name = "BTC", totalVolume = "1"), ) var searchCalls = 0 @@ -74,7 +75,7 @@ class MarketSearchViewModelSearchTest { ) assertEquals( - listOf("btc-high", "btc-new", "btc-low"), + listOf("symbol-exact", "name-exact", "volume-high", "volume-low"), result.map(MarketItem::coinId), ) } @@ -102,9 +103,9 @@ class MarketSearchViewModelSearchTest { resolveMarketItem = { MarketItem.fromMarket(it) }, ) - assertEquals(listOf("btc", "eth", "btc-cash"), result.map(MarketItem::coinId)) + assertEquals(listOf("btc", "btc-cash", "eth"), result.map(MarketItem::coinId)) assertEquals("Remote BTC", result.first().name) - assertEquals("Remote ETH", result[1].name) + assertEquals("Remote ETH", result.last().name) } @Test @@ -157,16 +158,22 @@ class MarketSearchViewModelSearchTest { assertEquals(0, remoteCalls) } - private fun market(coinId: String, marketCap: String = "1") = + private fun market( + coinId: String, + marketCap: String = "1", + name: String = coinId.uppercase(), + symbol: String = coinId.uppercase(), + totalVolume: String = "1", + ) = Market( coinId = coinId, - name = coinId.uppercase(), - symbol = coinId.uppercase(), + name = name, + symbol = symbol, iconUrl = "", currentPrice = "1", marketCap = marketCap, marketCapRank = "1", - totalVolume = "1", + totalVolume = totalVolume, high24h = "1", low24h = "1", priceChange24h = "0", From accdf65e2cdd3332c3ecbff48e4b8de6b4487d4b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sat, 5 Sep 2026 08:19:16 +0800 Subject: [PATCH 6/6] fix(market): restore and stabilize recent searches --- .../home/web3/market/MarketSearchFragment.kt | 7 ++---- .../ui/home/web3/market/MarketSearchPage.kt | 13 +++++------ .../home/web3/market/MarketSearchViewModel.kt | 22 +++++++++++++++---- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt index 47dc62c601..1f09eb5132 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt @@ -61,11 +61,8 @@ class MarketSearchFragment : BaseFragment() { } } - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) + override fun onStart() { + super.onStart() marketSearchViewModel.loadRecentSearches(requireContext().defaultSharedPreferences) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index 44e9e0fe5e..9a05ea608b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -419,12 +419,10 @@ private fun MarketRecentSearchChip( verticalAlignment = Alignment.CenterVertically, ) { MarketIcon(url = recentSearch.iconUrl.orEmpty(), size = 32.dp) - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(4.dp)) Column( - modifier = Modifier - .wrapContentWidth() - .height(32.dp), - verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.wrapContentWidth(), + verticalArrangement = Arrangement.spacedBy((-2).dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -433,7 +431,8 @@ private fun MarketRecentSearchChip( fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, - lineHeight = 14.sp, + lineHeight = 18.sp, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), modifier = Modifier.widthIn(min = 34.dp), ) if (recentSearch.type == RecentSearchType.PERPETUAL) { @@ -456,7 +455,7 @@ private fun MarketRecentSearchChip( text = formatSearchPercent(search.change), color = searchChangeColor(search.change, quoteColorReversed), fontSize = 13.sp, - lineHeight = 13.sp, + lineHeight = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index 9ce888793a..d48867f6c7 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.Constants.Account.PREF_MARKET_RECENT_SEARCH import one.mixin.android.Constants.Account.PREF_RECENT_SEARCH @@ -134,7 +135,6 @@ internal class MarketSearchViewModel viewModelScope.launch(Dispatchers.IO) { val searches = readRecentSearches(sp).addMarketRecentSearch(search) sp.putString(PREF_MARKET_RECENT_SEARCH, GsonHelper.customGson.toJson(searches)) - publishRecentSearches(searches) } } @@ -145,13 +145,27 @@ internal class MarketSearchViewModel } suspend fun findSpotMarket(coinId: String): MarketItem? = - tokenRepository.findMarketItemByCoinId(coinId) + withContext(Dispatchers.IO) { + try { + tokenRepository.checkMarketById(coinId) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } suspend fun findPerpetualMarket(marketId: String) = perpsMarketRepository.getOrRefreshMarket(marketId) private suspend fun publishRecentSearches(searches: List) { - _recentSearches.value = searches.map { MarketRecentSearch(it) } + val previousSearches = _recentSearches.value + _recentSearches.value = searches.map { search -> + val change = previousSearches.firstOrNull { + it.search.type == search.type && it.search.primaryKey == search.primaryKey + }?.change + MarketRecentSearch(search, change) + } val resolvedSearches = mutableListOf() for (search in searches) { resolvedSearches += resolveRecentSearch(search) @@ -165,7 +179,7 @@ internal class MarketSearchViewModel when (search.type) { RecentSearchType.MARKET -> search.primaryKey - ?.let { tokenRepository.findMarketItemByCoinId(it) } + ?.let { findSpotMarket(it) } ?.priceChangePercentage24H ?.toBigDecimalOrNull() RecentSearchType.PERPETUAL ->