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:
2026-08-06 14:26:03 +03:00
parent 9d18d0c927
commit 910bc8313c
10 changed files with 200 additions and 129 deletions
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research" applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt() minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 6 versionCode = 7
versionName = "1.3.0" versionName = "1.4.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -0,0 +1,27 @@
package com.example.research.core.domain.usecase
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import java.io.File
object DictionarySourceFileMatcher {
fun matches(source: DictionarySource, fileName: String): Boolean =
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
fun matches(source: DictionarySource, dictionary: Dictionary): Boolean =
matches(source, File(dictionary.path).name)
fun installedSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>,
): List<DictionarySource> = sources.filter { source ->
dictionaries.any { dictionary -> matches(source, dictionary) }
}
fun installedSourcesForFileNames(
sources: List<DictionarySource>,
fileNames: Collection<String>,
): List<DictionarySource> = sources.filter { source ->
fileNames.any { fileName -> matches(source, fileName) }
}
}
@@ -43,12 +43,4 @@ class DictionarySourceValidator {
return ValidationResult.Valid return ValidationResult.Valid
} }
fun findMatchingSourceWithPrecomputedPrefixes(
dictionaryPrefix: String,
sourcesWithPrefixes: List<Pair<DictionarySource, String?>>
): DictionarySource? {
return sourcesWithPrefixes.find { (_, sourcePrefix) ->
sourcePrefix != null && dictionaryPrefix.equals(sourcePrefix, ignoreCase = true)
}?.first
}
} }
@@ -30,18 +30,24 @@ class ManageDictionarySourcesUseCase(
preferencesManager.removeDictionarySource(sourceId) preferencesManager.removeDictionarySource(sourceId)
} }
suspend fun removeSourceForDictionary(dictionary: Dictionary) { suspend fun removeSourceForDictionary(
val dictionaryPrefix = dictionary.name dictionary: Dictionary,
val sources = preferencesManager.dictionarySources.first() remainingDictionaryFileNames: Collection<String>?,
val sourcesWithPrefixes = sources.map { source -> ) {
source to DictionarySource.extractPrefix(source.urlTemplate) if (remainingDictionaryFileNames == null) return
}
val matchingSource = validator.findMatchingSourceWithPrecomputedPrefixes(dictionaryPrefix, sourcesWithPrefixes) val sources = preferencesManager.dictionarySources.first()
if (matchingSource != null) { val sourceIdsToRemove = sources
preferencesManager.removeDictionarySource(matchingSource.id) .filter { source ->
DictionarySourceFileMatcher.matches(source, dictionary) &&
remainingDictionaryFileNames.none { fileName ->
DictionarySourceFileMatcher.matches(source, fileName)
} }
} }
.map(DictionarySource::id)
preferencesManager.removeDictionarySources(sourceIdsToRemove)
}
sealed class AddSourceResult { sealed class AddSourceResult {
object Success : AddSourceResult() object Success : AddSourceResult()
@@ -123,6 +123,24 @@ class LocalDictionaryRepository(
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
suspend fun listDictionaryPayloadFileNames(path: String): List<String>? =
withContext(ioDispatcher) {
try {
val directory = File(path)
when {
!directory.exists() -> {
if (directory.parentFile?.isDirectory == true) emptyList() else null
}
!directory.isDirectory -> null
else -> directory.listFiles { file -> isDictionaryPayloadFile(file) }
?.map(File::getName)
}
} catch (e: SecurityException) {
Log.w(TAG, "Failed to list dictionary files: ${e.message}")
null
}
}
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) { suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
if (isIndexingInProgress()) { if (isIndexingInProgress()) {
return@withContext OperationResult.Error( return@withContext OperationResult.Error(
@@ -371,9 +389,7 @@ class LocalDictionaryRepository(
val dir = File(path) val dir = File(path)
if (!dir.exists() || !dir.isDirectory) return emptyList() if (!dir.exists() || !dir.isDirectory) return emptyList()
return dir.listFiles { f -> return dir.listFiles { file -> isDictionaryPayloadFile(file) }?.map { file ->
f.isFile && (f.name.endsWith(".dsl") || f.name.endsWith(".dsl.dz") || f.name.endsWith(".dsl.gz"))
}?.map { file ->
DiscoveredFile( DiscoveredFile(
name = file.name, name = file.name,
localPath = file.absolutePath, localPath = file.absolutePath,
@@ -382,6 +398,13 @@ class LocalDictionaryRepository(
} ?: emptyList() } ?: emptyList()
} }
private fun isDictionaryPayloadFile(file: File): Boolean =
file.isFile && (
file.name.endsWith(".dsl") ||
file.name.endsWith(".dsl.dz") ||
file.name.endsWith(".dsl.gz")
)
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) { suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
if (query.isBlank()) return@withContext OperationResult.Success(emptyList()) if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
@@ -460,20 +483,17 @@ class LocalDictionaryRepository(
} }
suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> { suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
try { return try {
dictionaryStateMutex.withLock { removeDictionaryFromState(dictionary.path) } val (failure, cleanupErrors) = withContext(ioDispatcher) {
launch(ioDispatcher) {
try {
val deleteErrors = mutableListOf<String>()
val dictFile = File(dictionary.path) val dictFile = File(dictionary.path)
if (dictFile.exists()) { if (dictFile.exists() && !dictFile.delete()) {
if (!dictFile.delete()) { return@withContext Pair(
deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}") "Failed to delete dictionary file: ${dictFile.absolutePath}",
} emptyList<String>(),
)
} }
val deleteErrors = mutableListOf<String>()
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) { val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
File(dictionary.indexPath) File(dictionary.indexPath)
} else { } else {
@@ -481,12 +501,20 @@ class LocalDictionaryRepository(
} }
deleteIndexFiles(indexFile, deleteErrors) deleteIndexFiles(indexFile, deleteErrors)
Pair<String?, List<String>>(null, deleteErrors)
if (!dictFile.exists()) {
dictionaryStateMutex.withLock {
preferencesManager.removeDictionaryActiveState(dictionary.path)
removeDictionaryFromState(dictionary.path)
} }
if (failure != null) {
return OperationResult.Error(failure)
}
dictionaryStateMutex.withLock {
try {
preferencesManager.removeDictionaryActiveState(dictionary.path)
} catch (e: Exception) {
Log.w(TAG, "Failed to clear dictionary active state: ${e.message}")
}
removeDictionaryFromState(dictionary.path)
} }
try { try {
@@ -495,18 +523,16 @@ class LocalDictionaryRepository(
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}") Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
} }
if (deleteErrors.isNotEmpty()) { if (cleanupErrors.isNotEmpty()) {
val message = deleteErrors.joinToString("; ") Log.w(
Log.w(TAG, "Dictionary deletion completed with errors: $message") TAG,
} "Dictionary deletion completed with errors: ${cleanupErrors.joinToString("; ")}"
} catch (e: Exception) { )
Log.e(TAG, "Background deletion failed: ${e.message}", e)
}
} }
return OperationResult.Success(Unit) OperationResult.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
return OperationResult.Error("Failed to delete dictionary", e) OperationResult.Error("Failed to delete dictionary", e)
} }
} }
@@ -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.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress 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.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult import com.example.research.core.util.OperationResult
@@ -30,7 +31,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.util.concurrent.ConcurrentHashMap
private data class DictionaryStateInputs( private data class DictionaryStateInputs(
val theme: AppTheme, val theme: AppTheme,
@@ -79,7 +80,7 @@ class SettingsViewModel(
private val language = MutableStateFlow("system") private val language = MutableStateFlow("system")
private val hasCompletedStartupScan = MutableStateFlow(false) private val hasCompletedStartupScan = MutableStateFlow(false)
private val pendingSourceUrls = mutableSetOf<String>() private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
private var isDownloadInProgress = false private var isDownloadInProgress = false
private var cancelRefreshPending = false private var cancelRefreshPending = false
@@ -158,19 +159,14 @@ class SettingsViewModel(
DictionaryStatus.UpToDate DictionaryStatus.UpToDate
} }
if (pendingSourceUrls.isNotEmpty()) { if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate -> pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find { val source = dictionaryState.dictionarySources.find {
it.urlTemplate == urlTemplate it.urlTemplate == urlTemplate
} }
if (source != null) { if (source != null) {
val hasDictionary = dictionaryState.dictionaries.any { dict -> val hasDictionary = dictionaryState.dictionaries.any { dictionary ->
DictionarySource.matchesDictionaryFile( DictionarySourceFileMatcher.matches(source, dictionary)
urlTemplate,
File(dict.path).name
)
} }
if (!hasDictionary) { if (!hasDictionary) {
manageDictionarySourcesUseCase.removeSource(source.id) manageDictionarySourcesUseCase.removeSource(source.id)
} }
@@ -264,18 +260,21 @@ class SettingsViewModel(
private suspend fun evaluateDictionaryStatus(): DictionaryStatus { private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
return try { return try {
val actualDictionaries = localDictionaryRepository.dictionaries.first() val actualDictionaries = localDictionaryRepository.dictionaries.first()
val hasDictionaries = actualDictionaries.isNotEmpty()
if (!hasDictionaries) {
return DictionaryStatus.Empty
}
var sources = preferencesManager.dictionarySources.first() var sources = preferencesManager.dictionarySources.first()
if (downloadManager.downloadState.value !is DownloadState.Loading && if (pendingSourceUrls.isEmpty() &&
downloadManager.downloadState.value !is DownloadState.Loading &&
downloadManager.downloadState.value !is DownloadState.Extracting downloadManager.downloadState.value !is DownloadState.Extracting
) { ) {
val installedSources = installedSources(sources, actualDictionaries) val dictionaryFileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id) preferencesManager.dictionaryPath
)
if (dictionaryFileNames != null) {
val installedSources = DictionarySourceFileMatcher.installedSourcesForFileNames(
sources,
dictionaryFileNames,
)
val installedSourceIds = installedSources
.mapTo(mutableSetOf(), DictionarySource::id)
val staleSourceIds = sources val staleSourceIds = sources
.filterNot { it.id in installedSourceIds } .filterNot { it.id in installedSourceIds }
.map(DictionarySource::id) .map(DictionarySource::id)
@@ -284,6 +283,12 @@ class SettingsViewModel(
sources = installedSources sources = installedSources
} }
} }
}
if (actualDictionaries.isEmpty()) {
return DictionaryStatus.Empty
}
val enabledSources = installedEnabledSources(sources, actualDictionaries) val enabledSources = installedEnabledSources(sources, actualDictionaries)
if (enabledSources.isEmpty()) { if (enabledSources.isEmpty()) {
@@ -338,21 +343,31 @@ class SettingsViewModel(
private fun deleteDictionary(dictionary: Dictionary) { private fun deleteDictionary(dictionary: Dictionary) {
viewModelScope.launch { viewModelScope.launch {
localDictionaryRepository.deleteDictionary(dictionary) when (localDictionaryRepository.deleteDictionary(dictionary)) {
is OperationResult.Success -> {
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary) val remainingDictionaryFileNames =
localDictionaryRepository.listDictionaryPayloadFileNames(
val remainingDictionaries = localDictionaryRepository.dictionaries.first() preferencesManager.dictionaryPath
)
if (remainingDictionaries.isEmpty()) { manageDictionarySourcesUseCase.removeSourceForDictionary(
dictionaryStatus.value = DictionaryStatus.Empty 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>) { private fun addDictionarySources(urlTemplates: List<String>) {
viewModelScope.launch { viewModelScope.launch {
try {
val validUrls = mutableListOf<String>() val validUrls = mutableListOf<String>()
urlTemplates.forEach { urlTemplate -> urlTemplates.forEach { urlTemplate ->
@@ -361,23 +376,30 @@ class SettingsViewModel(
return@forEach return@forEach
} }
val normalizedUrl = DictionarySource.normalizeTemplate(trimmed)
if (!pendingSourceUrls.add(normalizedUrl)) {
return@forEach
}
try {
when (manageDictionarySourcesUseCase.addSource(trimmed)) { when (manageDictionarySourcesUseCase.addSource(trimmed)) {
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> { is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
validUrls.add(trimmed) validUrls.add(trimmed)
} }
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> { is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
pendingSourceUrls.remove(normalizedUrl)
// Validation failed, skip this source // Validation failed, skip this source
} }
} }
} catch (e: Exception) {
pendingSourceUrls.remove(normalizedUrl)
android.util.Log.e("SettingsViewModel", "Error adding dictionary source", e)
}
} }
if (validUrls.isNotEmpty()) { if (validUrls.isNotEmpty()) {
startDownloadForSources(validUrls) startDownloadForSources(validUrls)
} }
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
}
} }
} }
@@ -448,14 +470,8 @@ class SettingsViewModel(
private fun installedSources( private fun installedSources(
sources: List<DictionarySource>, sources: List<DictionarySource>,
dictionaries: List<Dictionary> dictionaries: List<Dictionary>
): List<DictionarySource> { ): List<DictionarySource> =
val installedFileNames = dictionaries.map { File(it.path).name } DictionarySourceFileMatcher.installedSources(sources, dictionaries)
return sources.filter { source ->
installedFileNames.any { fileName ->
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
}
}
}
private fun cancelDownload() { private fun cancelDownload() {
// Cancel the ViewModel-side scan job so no further status recomputes // Cancel the ViewModel-side scan job so no further status recomputes
@@ -501,10 +517,11 @@ class SettingsViewModel(
is OperationResult.Success -> { is OperationResult.Success -> {
val dictionaries = localDictionaryRepository.dictionaries.first() val dictionaries = localDictionaryRepository.dictionaries.first()
if (dictionaries.isEmpty()) { if (dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
isDownloadInProgress = false isDownloadInProgress = false
} else { } else if (isDownloadInProgress) {
if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false isDownloadInProgress = false
} else { } else {
@@ -512,7 +529,6 @@ class SettingsViewModel(
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() } val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status dictionaryStatus.value = status
} }
}
if (result.data > 0) { if (result.data > 0) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
+1
View File
@@ -96,6 +96,7 @@
</plurals> </plurals>
<string name="dictionary_not_indexed">• Не индексирован</string> <string name="dictionary_not_indexed">• Не индексирован</string>
<string name="dictionary_delete">Удалить словарь</string> <string name="dictionary_delete">Удалить словарь</string>
<string name="error_delete_dictionary">Не удалось удалить словарь</string>
<string name="dictionaries_tap_to_update">Нажмите для обновления</string> <string name="dictionaries_tap_to_update">Нажмите для обновления</string>
<string name="notification_import_title">Импорт словарей</string> <string name="notification_import_title">Импорт словарей</string>
<string name="notification_import_success_title">Импорт завершён</string> <string name="notification_import_success_title">Импорт завершён</string>
+1
View File
@@ -92,6 +92,7 @@
</plurals> </plurals>
<string name="dictionary_not_indexed">• Not indexed</string> <string name="dictionary_not_indexed">• Not indexed</string>
<string name="dictionary_delete">Delete dictionary</string> <string name="dictionary_delete">Delete dictionary</string>
<string name="error_delete_dictionary">Failed to delete dictionary</string>
<string name="dictionaries_tap_to_update">Tap to update all dictionaries</string> <string name="dictionaries_tap_to_update">Tap to update all dictionaries</string>
<string name="notification_import_title">Importing dictionaries</string> <string name="notification_import_title">Importing dictionaries</string>
<string name="notification_import_success_title">Import completed</string> <string name="notification_import_success_title">Import completed</string>
@@ -0,0 +1 @@
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
@@ -0,0 +1 @@
- Исправлено сопоставление URL-источников с установленными файлами — удалённый словарь теперь можно повторно скачать по тому же URL