Release 1.4.0

- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
This commit is contained in:
OneWay committed 2026-08-06 14:26:03 +03:00
1 parent 9d18d0c927
commit 910bc8313c
10 files changed
+200 -129

No files matched your search

@@ -11,6 +11,7 @@ import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
import com.example.research.core.domain.usecase.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult
@@ -30,7 +31,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.ConcurrentHashMap
private data class DictionaryStateInputs(
val theme: AppTheme,
@@ -79,7 +80,7 @@ class SettingsViewModel(
private val language = MutableStateFlow("system")
private val hasCompletedStartupScan = MutableStateFlow(false)
private val pendingSourceUrls = mutableSetOf<String>()
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
private var isDownloadInProgress = false
private var cancelRefreshPending = false
@@ -158,19 +159,14 @@ class SettingsViewModel(
DictionaryStatus.UpToDate
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find {
it.urlTemplate == urlTemplate
}
if (source != null) {
val hasDictionary = dictionaryState.dictionaries.any { dict ->
DictionarySource.matchesDictionaryFile(
urlTemplate,
File(dict.path).name
)
val hasDictionary = dictionaryState.dictionaries.any { dictionary ->
DictionarySourceFileMatcher.matches(source, dictionary)
}
if (!hasDictionary) {
manageDictionarySourcesUseCase.removeSource(source.id)
}
@@ -264,26 +260,35 @@ class SettingsViewModel(
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
return try {
val actualDictionaries = localDictionaryRepository.dictionaries.first()
val hasDictionaries = actualDictionaries.isNotEmpty()
var sources = preferencesManager.dictionarySources.first()
if (pendingSourceUrls.isEmpty() &&
downloadManager.downloadState.value !is DownloadState.Loading &&
downloadManager.downloadState.value !is DownloadState.Extracting
) {
val dictionaryFileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
)
if (dictionaryFileNames != null) {
val installedSources = DictionarySourceFileMatcher.installedSourcesForFileNames(
sources,
dictionaryFileNames,
)
val installedSourceIds = installedSources
.mapTo(mutableSetOf(), DictionarySource::id)
val staleSourceIds = sources
.filterNot { it.id in installedSourceIds }
.map(DictionarySource::id)
if (staleSourceIds.isNotEmpty()) {
preferencesManager.removeDictionarySources(staleSourceIds)
sources = installedSources
}
}
}
if (!hasDictionaries) {
if (actualDictionaries.isEmpty()) {
return DictionaryStatus.Empty
}
var sources = preferencesManager.dictionarySources.first()
if (downloadManager.downloadState.value !is DownloadState.Loading &&
downloadManager.downloadState.value !is DownloadState.Extracting
) {
val installedSources = installedSources(sources, actualDictionaries)
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id)
val staleSourceIds = sources
.filterNot { it.id in installedSourceIds }
.map(DictionarySource::id)
if (staleSourceIds.isNotEmpty()) {
preferencesManager.removeDictionarySources(staleSourceIds)
sources = installedSources
}
}
val enabledSources = installedEnabledSources(sources, actualDictionaries)
if (enabledSources.isEmpty()) {
@@ -338,45 +343,62 @@ class SettingsViewModel(
private fun deleteDictionary(dictionary: Dictionary) {
viewModelScope.launch {
localDictionaryRepository.deleteDictionary(dictionary)
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary)
val remainingDictionaries = localDictionaryRepository.dictionaries.first()
if (remainingDictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
when (localDictionaryRepository.deleteDictionary(dictionary)) {
is OperationResult.Success -> {
val remainingDictionaryFileNames =
localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
)
manageDictionarySourcesUseCase.removeSourceForDictionary(
dictionary = dictionary,
remainingDictionaryFileNames = remainingDictionaryFileNames,
)
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
is OperationResult.Error -> {
effectChannel.send(
getApplication<Application>().getString(R.string.error_delete_dictionary)
)
}
}
}
}
private fun addDictionarySources(urlTemplates: List<String>) {
viewModelScope.launch {
try {
val validUrls = mutableListOf<String>()
val validUrls = mutableListOf<String>()
urlTemplates.forEach { urlTemplate ->
val trimmed = urlTemplate.trim()
if (trimmed.isEmpty() || trimmed.length > 2048) {
return@forEach
}
urlTemplates.forEach { urlTemplate ->
val trimmed = urlTemplate.trim()
if (trimmed.isEmpty() || trimmed.length > 2048) {
return@forEach
}
val normalizedUrl = DictionarySource.normalizeTemplate(trimmed)
if (!pendingSourceUrls.add(normalizedUrl)) {
return@forEach
}
try {
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
validUrls.add(trimmed)
}
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
pendingSourceUrls.remove(normalizedUrl)
// Validation failed, skip this source
}
}
} catch (e: Exception) {
pendingSourceUrls.remove(normalizedUrl)
android.util.Log.e("SettingsViewModel", "Error adding dictionary source", e)
}
}
if (validUrls.isNotEmpty()) {
startDownloadForSources(validUrls)
}
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
if (validUrls.isNotEmpty()) {
startDownloadForSources(validUrls)
}
}
}
@@ -448,14 +470,8 @@ class SettingsViewModel(
private fun installedSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>
): List<DictionarySource> {
val installedFileNames = dictionaries.map { File(it.path).name }
return sources.filter { source ->
installedFileNames.any { fileName ->
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
}
}
}
): List<DictionarySource> =
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
private fun cancelDownload() {
// Cancel the ViewModel-side scan job so no further status recomputes
@@ -501,17 +517,17 @@ class SettingsViewModel(
is OperationResult.Success -> {
val dictionaries = localDictionaryRepository.dictionaries.first()
if (dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
isDownloadInProgress = false
} else if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
if (result.data > 0) {