Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692fb2117e | ||
|
|
3bb4e09b04 | ||
|
|
ff4ba62bbd | ||
|
|
123615e1f8 | ||
|
|
6889dd2c45 | ||
|
|
f22028d126 | ||
|
|
e20af9c1ff |
No files matched your search
@@ -90,10 +90,10 @@ The main search screen remains usable as long as at least one indexed dictionary
|
|||||||
- Material 3 1.5.0-beta01
|
- Material 3 1.5.0-beta01
|
||||||
- Coroutines and Flow 1.11.0
|
- Coroutines and Flow 1.11.0
|
||||||
- DataStore Preferences 1.2.1
|
- DataStore Preferences 1.2.1
|
||||||
- Paging 3.5.2
|
- Paging 3.5.3
|
||||||
- OkHttp 5.5.0
|
- OkHttp 5.5.0
|
||||||
- kotlinx.serialization 1.11.0
|
- kotlinx.serialization 1.11.0
|
||||||
- AboutLibraries 15.2.0 metadata generation
|
- AboutLibraries 15.3.0 metadata generation
|
||||||
- Android Gradle Plugin 9.4.1
|
- Android Gradle Plugin 9.4.1
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import androidx.paging.PagingState
|
|||||||
import com.example.research.core.domain.model.Dictionary
|
import com.example.research.core.domain.model.Dictionary
|
||||||
import com.example.research.core.domain.model.IndexEntry
|
import com.example.research.core.domain.model.IndexEntry
|
||||||
import com.example.research.core.performance.ReSearchTrace
|
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.IndexSearcher
|
||||||
import com.example.research.data.parser.SearchRankingContext
|
import com.example.research.data.parser.sortByCharCountThenNatural
|
||||||
import com.example.research.data.parser.rankBySearchRelevance
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@@ -68,9 +67,8 @@ class IndexEntryPagingSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
|
||||||
val ranked = rawResults
|
val ranked = rawResults
|
||||||
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
|
.sortByCharCountThenNatural()
|
||||||
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
|
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
|
||||||
|
|
||||||
LoadResult.Page(
|
LoadResult.Page(
|
||||||
|
|||||||
@@ -122,9 +122,7 @@ class IndexSearcher(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(prefixMatches + substringMatches)
|
(prefixMatches + substringMatches).distinctBy { it.articleKey() }
|
||||||
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
|
|
||||||
.distinctBy { it.articleKey() }
|
|
||||||
}
|
}
|
||||||
resultsCache.put(cacheKey, results)
|
resultsCache.put(cacheKey, results)
|
||||||
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.domain.model.IndexEntry
|
||||||
import com.example.research.core.util.NaturalStringComparator
|
import com.example.research.core.util.NaturalStringComparator
|
||||||
import com.example.research.core.util.foldLatinDiacritics
|
|
||||||
|
|
||||||
internal data class SearchRankingContext(
|
internal fun List<IndexEntry>.sortByCharCountThenNatural(): List<IndexEntry> {
|
||||||
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> {
|
|
||||||
if (size < 2) return this
|
if (size < 2) return this
|
||||||
|
|
||||||
return map { entry ->
|
val charCounts = IntArray(size)
|
||||||
val word = entry.word
|
var minCount = Int.MAX_VALUE
|
||||||
|
var maxCount = Int.MIN_VALUE
|
||||||
|
for (i in indices) {
|
||||||
|
val entry = this[i]
|
||||||
val displayKey = entry.originalWord
|
val displayKey = entry.originalWord
|
||||||
.trim()
|
.trim()
|
||||||
.ifEmpty { entry.word.trim() }
|
.ifEmpty { entry.word.trim() }
|
||||||
|
val count = displayKey.codePointCount(0, displayKey.length)
|
||||||
SortKey(
|
charCounts[i] = count
|
||||||
matchTier = when {
|
if (count < minCount) minCount = count
|
||||||
!entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0
|
if (count > maxCount) maxCount = count
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedWith(
|
|
||||||
compareBy<SortKey> { it.matchTier }
|
val buckets = arrayOfNulls<ArrayList<IndexEntry>>(maxCount - minCount + 1)
|
||||||
.thenBy { it.exactMatch }
|
for (i in indices) {
|
||||||
.thenBy { it.charCount }
|
val bucketIndex = charCounts[i] - minCount
|
||||||
.thenBy(NaturalStringComparator) { it.word }
|
(buckets[bucketIndex] ?: ArrayList<IndexEntry>().also { buckets[bucketIndex] = it })
|
||||||
)
|
.add(this[i])
|
||||||
.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 onDownloadSuccess: (() -> Unit)? = null
|
||||||
var onTerminal: ((DownloadState) -> 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() {
|
fun startDownload() {
|
||||||
if (downloadState.value is DownloadState.Loading) return
|
if (downloadState.value is DownloadState.Loading) return
|
||||||
if (!downloadScope.isActive) return
|
if (!downloadScope.isActive) return
|
||||||
@@ -79,6 +86,7 @@ class DownloadManager(
|
|||||||
updateDownloadProgressState(
|
updateDownloadProgressState(
|
||||||
DownloadState.Error(sourceUnavailableMessage), 0f
|
DownloadState.Error(sourceUnavailableMessage), 0f
|
||||||
)
|
)
|
||||||
|
expireErrorStateToIdle()
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +143,7 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
expireErrorStateToIdle()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
downloadJob = null
|
downloadJob = null
|
||||||
@@ -174,6 +183,7 @@ class DownloadManager(
|
|||||||
updateDownloadProgressState(
|
updateDownloadProgressState(
|
||||||
DownloadState.Error(sourceUnavailableMessage), 0f
|
DownloadState.Error(sourceUnavailableMessage), 0f
|
||||||
)
|
)
|
||||||
|
expireErrorStateToIdle()
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +240,7 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
expireErrorStateToIdle()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
downloadJob = null
|
downloadJob = null
|
||||||
|
|||||||
@@ -112,9 +112,7 @@ class SearchViewModel(
|
|||||||
Pager(
|
Pager(
|
||||||
config = PagingConfig(
|
config = PagingConfig(
|
||||||
pageSize = 12,
|
pageSize = 12,
|
||||||
prefetchDistance = 1,
|
enablePlaceholders = false
|
||||||
enablePlaceholders = false,
|
|
||||||
initialLoadSize = 12
|
|
||||||
),
|
),
|
||||||
pagingSourceFactory = {
|
pagingSourceFactory = {
|
||||||
IndexEntryPagingSource(
|
IndexEntryPagingSource(
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ fun MainRoute(
|
|||||||
dictionary.isActive && dictionary.articleCount > 0
|
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.indexingProgress.isIndexing &&
|
||||||
settingsState.importState !is ImportState.Importing &&
|
settingsState.importState !is ImportState.Importing &&
|
||||||
settingsState.importState !is ImportState.Extracting &&
|
settingsState.importState !is ImportState.Extracting &&
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
private const val STATUS_UP_TO_DATE = "UpToDate"
|
||||||
|
private const val STATUS_NEEDS_UPDATE = "NeedsUpdate"
|
||||||
|
private const val STATUS_EMPTY = "Empty"
|
||||||
|
|
||||||
|
private data class PersistedDictionaryStatus(
|
||||||
|
val name: String,
|
||||||
|
val date: String,
|
||||||
|
val sourceIds: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
private data class DictionaryStateInputs(
|
private data class DictionaryStateInputs(
|
||||||
val theme: AppTheme,
|
val theme: AppTheme,
|
||||||
val dictionarySources: List<DictionarySource>,
|
val dictionarySources: List<DictionarySource>,
|
||||||
@@ -115,14 +125,18 @@ class SettingsViewModel(
|
|||||||
private fun persistDictionaryStatus() {
|
private fun persistDictionaryStatus() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dictionaryStatus.collect { status ->
|
dictionaryStatus.collect { status ->
|
||||||
val persistedName = when (status) {
|
val persisted = when (status) {
|
||||||
DictionaryStatus.UpToDate -> "UpToDate"
|
DictionaryStatus.UpToDate ->
|
||||||
is DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
|
"$STATUS_UP_TO_DATE|${DateUtils.getCurrentDateString()}"
|
||||||
DictionaryStatus.Empty -> "Empty"
|
is DictionaryStatus.NeedsUpdate ->
|
||||||
|
"$STATUS_NEEDS_UPDATE|${DateUtils.getCurrentDateString()}|" +
|
||||||
|
status.sourceIds.joinToString(",")
|
||||||
|
DictionaryStatus.Empty ->
|
||||||
|
"$STATUS_EMPTY|${DateUtils.getCurrentDateString()}"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (persistedName != null) {
|
if (persisted != null) {
|
||||||
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
|
preferencesManager.saveDictionaryStatus(persisted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,14 +216,21 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DownloadState.Error -> {
|
is DownloadState.Error -> {
|
||||||
|
val startedHere = statusBeforeDownload != DictionaryStatus.Unknown ||
|
||||||
|
isDownloadInProgress
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
if (!errorStateHandled) {
|
if (!errorStateHandled) {
|
||||||
errorStateHandled = true
|
errorStateHandled = true
|
||||||
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
if (dictionaryState.dictionaries.isEmpty()) {
|
||||||
DictionaryStatus.Empty
|
dictionaryStatus.value = DictionaryStatus.Empty
|
||||||
} else when (statusBeforeDownload) {
|
} else if (startedHere) {
|
||||||
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
|
when (statusBeforeDownload) {
|
||||||
else -> statusBeforeDownload
|
DictionaryStatus.Unknown, DictionaryStatus.Checking ->
|
||||||
|
viewModelScope.launch {
|
||||||
|
restoreOrEvaluateDictionaryStatus()
|
||||||
|
}
|
||||||
|
else -> dictionaryStatus.value = statusBeforeDownload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (pendingSourceUrls.isNotEmpty()) {
|
if (pendingSourceUrls.isNotEmpty()) {
|
||||||
pendingSourceUrls.forEach { urlTemplate ->
|
pendingSourceUrls.forEach { urlTemplate ->
|
||||||
@@ -283,7 +304,7 @@ class SettingsViewModel(
|
|||||||
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
|
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
|
||||||
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
|
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
|
||||||
} else {
|
} else {
|
||||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
restoreOrEvaluateDictionaryStatus()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -551,6 +572,71 @@ class SettingsViewModel(
|
|||||||
): List<DictionarySource> =
|
): List<DictionarySource> =
|
||||||
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
|
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
|
||||||
|
|
||||||
|
private suspend fun restoreOrEvaluateDictionaryStatus() {
|
||||||
|
val restored = restorePersistedDictionaryStatus()
|
||||||
|
if (restored != null) {
|
||||||
|
dictionaryStatus.value = restored
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
|
dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun restorePersistedDictionaryStatus(): DictionaryStatus? {
|
||||||
|
val persisted = readPersistedDictionaryStatus() ?: return null
|
||||||
|
if (persisted.date != DateUtils.getCurrentDateString()) return null
|
||||||
|
return when (persisted.name) {
|
||||||
|
STATUS_UP_TO_DATE ->
|
||||||
|
if (withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }) {
|
||||||
|
DictionaryStatus.UpToDate
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
STATUS_NEEDS_UPDATE -> {
|
||||||
|
val updatableIds = withContext(Dispatchers.IO) { sourcesMissingTodaysFile() }
|
||||||
|
val restorableIds = persisted.sourceIds intersect updatableIds
|
||||||
|
if (restorableIds.isNotEmpty()) DictionaryStatus.NeedsUpdate(restorableIds) else null
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readPersistedDictionaryStatus(): PersistedDictionaryStatus? {
|
||||||
|
val raw = preferencesManager.dictionaryStatus() ?: return null
|
||||||
|
val parts = raw.split('|')
|
||||||
|
if (parts.isEmpty() || parts.first().isEmpty()) return null
|
||||||
|
return PersistedDictionaryStatus(
|
||||||
|
name = parts.first(),
|
||||||
|
date = parts.getOrElse(1) { "" },
|
||||||
|
sourceIds = parts.getOrElse(2) { "" }
|
||||||
|
.split(',')
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sourcesMissingTodaysFile(): Set<String> {
|
||||||
|
val sources = preferencesManager.dictionarySources.first()
|
||||||
|
val dictionaries = localDictionaryRepository.dictionaries.first()
|
||||||
|
val datedEnabledSources = installedEnabledSources(sources, dictionaries)
|
||||||
|
.filter { DictionarySource.hasDatePlaceholder(it.urlTemplate) }
|
||||||
|
if (datedEnabledSources.isEmpty()) return emptySet()
|
||||||
|
val fileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
|
||||||
|
preferencesManager.dictionaryPath
|
||||||
|
) ?: return emptySet()
|
||||||
|
val today = DateUtils.getCurrentDateString()
|
||||||
|
return datedEnabledSources
|
||||||
|
.filterNot { source -> fileNames.any { it.startsWith(fileBaseNameFor(source, today)) } }
|
||||||
|
.mapTo(mutableSetOf(), DictionarySource::id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileBaseNameFor(source: DictionarySource, date: String): String =
|
||||||
|
DictionarySource.extractFileName(source.urlTemplate, date)
|
||||||
|
.removeSuffix(".gz")
|
||||||
|
.removeSuffix(".dsl")
|
||||||
|
.removeSuffix(".dz")
|
||||||
|
|
||||||
private suspend fun installedDictionariesAreCurrent(): Boolean {
|
private suspend fun installedDictionariesAreCurrent(): Boolean {
|
||||||
val sources = preferencesManager.dictionarySources.first()
|
val sources = preferencesManager.dictionarySources.first()
|
||||||
val dictionaries = localDictionaryRepository.dictionaries.first()
|
val dictionaries = localDictionaryRepository.dictionaries.first()
|
||||||
@@ -562,11 +648,7 @@ class SettingsViewModel(
|
|||||||
) ?: return true
|
) ?: return true
|
||||||
val today = DateUtils.getCurrentDateString()
|
val today = DateUtils.getCurrentDateString()
|
||||||
return datedEnabledSources.all { source ->
|
return datedEnabledSources.all { source ->
|
||||||
val baseName = DictionarySource.extractFileName(source.urlTemplate, today)
|
fileNames.any { it.startsWith(fileBaseNameFor(source, today)) }
|
||||||
.removeSuffix(".gz")
|
|
||||||
.removeSuffix(".dsl")
|
|
||||||
.removeSuffix(".dz")
|
|
||||||
fileNames.any { it.startsWith(baseName) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,20 +704,7 @@ class SettingsViewModel(
|
|||||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
} else {
|
} else {
|
||||||
val savedStatus = preferencesManager.dictionaryStatus()
|
restoreOrEvaluateDictionaryStatus()
|
||||||
val savedStatusName = savedStatus?.substringBefore('|')
|
|
||||||
val savedStatusDate = savedStatus?.substringAfter('|', "")
|
|
||||||
val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate else null
|
|
||||||
if (restoredStatus != null &&
|
|
||||||
savedStatusDate == DateUtils.getCurrentDateString() &&
|
|
||||||
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
|
|
||||||
) {
|
|
||||||
dictionaryStatus.value = restoredStatus
|
|
||||||
} else {
|
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
|
||||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
|
||||||
dictionaryStatus.value = status
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data > 0) {
|
if (result.data > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
aboutlibraries = "15.2.0"
|
aboutlibraries = "15.3.0"
|
||||||
activity_compose = "1.13.0"
|
activity_compose = "1.13.0"
|
||||||
agp = "9.4.1"
|
agp = "9.4.1"
|
||||||
compose_bom = "2026.09.00"
|
compose_bom = "2026.09.00"
|
||||||
@@ -10,7 +10,7 @@ kotlin = "2.4.21"
|
|||||||
lifecycle_runtime_ktx = "2.11.0"
|
lifecycle_runtime_ktx = "2.11.0"
|
||||||
material3 = "1.5.0-beta01"
|
material3 = "1.5.0-beta01"
|
||||||
okhttp = "5.5.0"
|
okhttp = "5.5.0"
|
||||||
paging = "3.5.2"
|
paging = "3.5.3"
|
||||||
profileinstaller = "1.4.1"
|
profileinstaller = "1.4.1"
|
||||||
kotlinx_coroutines = "1.11.0"
|
kotlinx_coroutines = "1.11.0"
|
||||||
kotlinx_serialization = "1.11.0"
|
kotlinx_serialization = "1.11.0"
|
||||||
|
|||||||
Reference in new issue
Block a user