6 Commits
Author SHA1 Message Date
OneWay 692fb2117e fix(download): unblock search after a failed update
A failed update left DownloadState.Error in the state flow forever:
settings replayed it as a snackbar on every recomposition and search
stayed locked until the next app start.
2026-10-21 04:00:00 +08:00
OneWay 3bb4e09b04 chore(deps): bump aboutlibraries to 15.3.0 2026-10-15 13:52:00 +08:00
OneWay ff4ba62bbd chore(deps): bump paging to 3.5.3 2026-10-15 13:41:07 +08:00
OneWay 123615e1f8 fix(download): expire the sticky error state
A download error stayed in the state flow forever: the dictionaries
section replayed it as a snackbar on every recomposition and screens
created later picked it up long after the failure, even while a newer
download was already running. Error states now decay to idle after the
same grace period as success, unless a newer state has replaced them.
2026-10-15 13:38:00 +08:00
OneWay 6889dd2c45 fix(settings): keep the update badge after a failed download
Errors of downloads this screen did not start no longer touch the
status; when the prior status is unknown or still unresolved, today's
persisted status is restored or a fresh evaluation runs instead of
fabricating UpToDate.
2026-10-15 13:34:09 +08:00
OneWay f22028d126 Search results: one ordering pass instead of double ranking
The search pipeline ranked and deduplicated matches twice: inside the
searcher per dictionary and again after merging across dictionaries.
The extra pass ran before the required ordering and duplicated the
dedup work. The searcher now returns article-deduplicated matches and
the paging source applies a single ordering pass: display length
ascending, then natural numeric-aware order. Paging configuration
knobs that a full-page source cannot use are dropped.
2026-10-15 13:06:05 +08:00
9 changed files with 62 additions and 59 deletions

No files matched your search

+2 -2
View File
@@ -90,10 +90,10 @@ The main search screen remains usable as long as at least one indexed dictionary
- Material 3 1.5.0-beta01
- Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1
- Paging 3.5.2
- Paging 3.5.3
- OkHttp 5.5.0
- kotlinx.serialization 1.11.0
- AboutLibraries 15.2.0 metadata generation
- AboutLibraries 15.3.0 metadata generation
- Android Gradle Plugin 9.4.1
## Build
@@ -5,10 +5,9 @@ import androidx.paging.PagingState
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.performance.ReSearchTrace
import com.example.research.core.util.sanitizeQuery
import com.example.research.data.parser.IndexSearcher
import com.example.research.data.parser.SearchRankingContext
import com.example.research.data.parser.rankBySearchRelevance
import com.example.research.data.parser.sortByCharCountThenNatural
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@@ -68,9 +67,8 @@ class IndexEntryPagingSource(
}
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val ranked = rawResults
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
.sortByCharCountThenNatural()
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
LoadResult.Page(
@@ -122,9 +122,7 @@ class IndexSearcher(private val context: Context) {
}
}
}
(prefixMatches + substringMatches)
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
.distinctBy { it.articleKey() }
(prefixMatches + substringMatches).distinctBy { it.articleKey() }
}
resultsCache.put(cacheKey, results)
results
@@ -2,50 +2,37 @@ package com.example.research.data.parser
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.NaturalStringComparator
import com.example.research.core.util.foldLatinDiacritics
internal data class SearchRankingContext(
val normalizedQuery: String,
val foldedQuery: String = normalizedQuery.foldLatinDiacritics(),
)
private data class SortKey(
val matchTier: Int,
val exactMatch: Int,
val charCount: Int,
val word: String,
val entry: IndexEntry,
)
internal fun List<IndexEntry>.rankBySearchRelevance(
ranking: SearchRankingContext
): List<IndexEntry> {
internal fun List<IndexEntry>.sortByCharCountThenNatural(): List<IndexEntry> {
if (size < 2) return this
return map { entry ->
val word = entry.word
val charCounts = IntArray(size)
var minCount = Int.MAX_VALUE
var maxCount = Int.MIN_VALUE
for (i in indices) {
val entry = this[i]
val displayKey = entry.originalWord
.trim()
.ifEmpty { entry.word.trim() }
val count = displayKey.codePointCount(0, displayKey.length)
charCounts[i] = count
if (count < minCount) minCount = count
if (count > maxCount) maxCount = count
}
SortKey(
matchTier = when {
!entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0
entry.isAlias && word.startsWith(ranking.foldedQuery) -> 1
!entry.isAlias && word.foldLatinDiacritics().startsWith(ranking.foldedQuery) -> 1
else -> 2
},
exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
charCount = displayKey.codePointCount(0, displayKey.length),
word = word,
entry = entry,
)
val buckets = arrayOfNulls<ArrayList<IndexEntry>>(maxCount - minCount + 1)
for (i in indices) {
val bucketIndex = charCounts[i] - minCount
(buckets[bucketIndex] ?: ArrayList<IndexEntry>().also { buckets[bucketIndex] = it })
.add(this[i])
}
.sortedWith(
compareBy<SortKey> { it.matchTier }
.thenBy { it.exactMatch }
.thenBy { it.charCount }
.thenBy(NaturalStringComparator) { it.word }
)
.map { it.entry }
val naturalOrder = compareBy<IndexEntry, String>(NaturalStringComparator) { it.word }
val result = ArrayList<IndexEntry>(size)
for (bucket in buckets) {
if (bucket == null) continue
if (bucket.size > 1) bucket.sortWith(naturalOrder)
result.addAll(bucket)
}
return result
}
@@ -52,6 +52,13 @@ class DownloadManager(
var onDownloadSuccess: (() -> Unit)? = null
var onTerminal: ((DownloadState) -> Unit)? = null
private suspend fun expireErrorStateToIdle() {
delay(TERMINAL_STATE_DURATION)
if (downloadState.value is DownloadState.Error) {
updateDownloadProgressState(DownloadState.Idle, 0f)
}
}
fun startDownload() {
if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return
@@ -79,6 +86,7 @@ class DownloadManager(
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
expireErrorStateToIdle()
return@withLock
}
@@ -135,6 +143,7 @@ class DownloadManager(
}
}
expireErrorStateToIdle()
}
} finally {
downloadJob = null
@@ -174,6 +183,7 @@ class DownloadManager(
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
expireErrorStateToIdle()
return@withLock
}
@@ -230,6 +240,7 @@ class DownloadManager(
}
}
expireErrorStateToIdle()
}
} finally {
downloadJob = null
@@ -112,9 +112,7 @@ class SearchViewModel(
Pager(
config = PagingConfig(
pageSize = 12,
prefetchDistance = 1,
enablePlaceholders = false,
initialLoadSize = 12
enablePlaceholders = false
),
pagingSourceFactory = {
IndexEntryPagingSource(
@@ -64,7 +64,11 @@ fun MainRoute(
dictionary.isActive && dictionary.articleCount > 0
}
settingsState.downloadState is DownloadState.Idle &&
val downloadSettled = settingsState.downloadState.let { state ->
state is DownloadState.Idle || state is DownloadState.Error
}
downloadSettled &&
!settingsState.indexingProgress.isIndexing &&
settingsState.importState !is ImportState.Importing &&
settingsState.importState !is ImportState.Extracting &&
@@ -216,14 +216,21 @@ class SettingsViewModel(
}
}
is DownloadState.Error -> {
val startedHere = statusBeforeDownload != DictionaryStatus.Unknown ||
isDownloadInProgress
isDownloadInProgress = false
if (!errorStateHandled) {
errorStateHandled = true
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else when (statusBeforeDownload) {
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
else -> statusBeforeDownload
if (dictionaryState.dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
} else if (startedHere) {
when (statusBeforeDownload) {
DictionaryStatus.Unknown, DictionaryStatus.Checking ->
viewModelScope.launch {
restoreOrEvaluateDictionaryStatus()
}
else -> dictionaryStatus.value = statusBeforeDownload
}
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
+2 -2
View File
@@ -1,5 +1,5 @@
[versions]
aboutlibraries = "15.2.0"
aboutlibraries = "15.3.0"
activity_compose = "1.13.0"
agp = "9.4.1"
compose_bom = "2026.09.00"
@@ -10,7 +10,7 @@ kotlin = "2.4.21"
lifecycle_runtime_ktx = "2.11.0"
material3 = "1.5.0-beta01"
okhttp = "5.5.0"
paging = "3.5.2"
paging = "3.5.3"
profileinstaller = "1.4.1"
kotlinx_coroutines = "1.11.0"
kotlinx_serialization = "1.11.0"