Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/src/main/java/one/mixin/android/extension/IterableExtension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,26 @@ suspend fun <A, B> Iterable<A>.pmap(f: suspend (A) -> B): List<B> =
coroutineScope {
map { async { f(it) } }.awaitAll()
}

fun <T, K> mergeLocalAndRefreshed(
localMatches: List<T>,
refreshedMatches: List<T>,
keySelector: (T) -> K,
): List<T> {
val refreshedByKey = refreshedMatches.associateBy(keySelector)
val localKeys = LinkedHashSet<K>()
val merged = ArrayList<T>(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
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -40,6 +42,13 @@ class PerpsMarketRepository

suspend fun searchMarkets(query: String): List<PerpsMarket> = marketDao.searchMarkets(query.trim())

suspend fun searchMarketsOnlineFirst(query: String): List<PerpsMarket> =
searchPerpsMarketsOnlineFirst(
query = query,
searchLocalMarkets = marketDao::searchMarkets,
syncOnlineMarkets = { syncAllMarkets() },
)

suspend fun getMarket(marketId: String): PerpsMarket? = marketDao.getMarket(marketId)

suspend fun syncAllMarkets(): List<PerpsMarket>? {
Expand Down Expand Up @@ -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 = {
Expand All @@ -179,3 +191,24 @@ class PerpsMarketRepository
const val CATEGORY_FAVORITE = "favorite"
}
}

internal suspend fun searchPerpsMarketsOnlineFirst(
query: String,
searchLocalMarkets: suspend (query: String) -> List<PerpsMarket>,
syncOnlineMarkets: suspend () -> Unit,
): List<PerpsMarket> {
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ internal fun List<PerpsMarket>.sortedForTrendingSearch(): List<PerpsMarket> =
.thenBy { it.marketId },
)

internal fun <T> List<T>.sortedForMarketSearch(
query: String,
symbol: (T) -> String,
name: (T) -> String,
volume: (T) -> String,
): List<T> {
val normalizedQuery = query.trim()
return sortedWith(
compareByDescending<T> { 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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),
) {
Expand All @@ -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 ->
Expand Down Expand Up @@ -417,10 +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(),
verticalArrangement = Arrangement.spacedBy(3.dp),
verticalArrangement = Arrangement.spacedBy((-2).dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
Expand All @@ -429,6 +431,9 @@ private fun MarketRecentSearchChip(
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)),
modifier = Modifier.widthIn(min = 34.dp),
)
if (recentSearch.type == RecentSearchType.PERPETUAL) {
Spacer(modifier = Modifier.width(3.dp))
Expand All @@ -449,11 +454,12 @@ private fun MarketRecentSearchChip(
Text(
text = formatSearchPercent(search.change),
color = searchChangeColor(search.change, quoteColorReversed),
fontSize = 12.sp,
lineHeight = 12.sp,
fontSize = 13.sp,
lineHeight = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)),
modifier = Modifier.widthIn(min = 34.dp),
)
}
}
Expand Down Expand Up @@ -523,7 +529,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@ 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
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
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
Expand Down Expand Up @@ -131,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)
}
}

Expand All @@ -142,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<RecentSearch>) {
_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<MarketRecentSearch>()
for (search in searches) {
resolvedSearches += resolveRecentSearch(search)
Expand All @@ -162,7 +179,7 @@ internal class MarketSearchViewModel
when (search.type) {
RecentSearchType.MARKET ->
search.primaryKey
?.let { tokenRepository.findMarketItemByCoinId(it) }
?.let { findSpotMarket(it) }
?.priceChangePercentage24H
?.toBigDecimalOrNull()
RecentSearchType.PERPETUAL ->
Expand Down Expand Up @@ -205,15 +222,18 @@ internal class MarketSearchViewModel

private suspend fun searchSpotMarkets(query: String): List<MarketItem> =
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) {
Expand All @@ -222,11 +242,48 @@ internal class MarketSearchViewModel

private suspend fun searchPerpetualMarkets(query: String) =
try {
initialPerpetualSyncJob.join()
perpsMarketRepository.searchMarkets(query)
perpsMarketRepository.searchMarketsOnlineFirst(query).sortedForMarketSearch(
query = query,
symbol = PerpsMarket::tokenSymbol,
name = PerpsMarket::displaySymbol,
volume = PerpsMarket::volume,
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
emptyList()
}
}
}

internal suspend fun searchSpotMarketsOnlineFirst(
query: String,
searchLocalMarkets: suspend (escapedQuery: String) -> List<Market>,
refreshOnlineMarkets: suspend (query: String) -> Unit,
resolveMarketItem: suspend (Market) -> MarketItem,
): List<MarketItem> {
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) }
.sortedForMarketSearch(
query = normalizedQuery,
symbol = MarketItem::symbol,
name = MarketItem::name,
volume = MarketItem::totalVolume,
)
}
33 changes: 33 additions & 0 deletions app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading