diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e669d1..cb068c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,8 +92,8 @@ android { applicationId = "com.example.research" minSdk = project.property("minSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt() - versionCode = 6 - versionName = "1.3.0" + versionCode = 7 + versionName = "1.4.0" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/com/example/research/core/domain/usecase/DictionarySourceFileMatcher.kt b/app/src/main/java/com/example/research/core/domain/usecase/DictionarySourceFileMatcher.kt new file mode 100644 index 0000000..d0435d4 --- /dev/null +++ b/app/src/main/java/com/example/research/core/domain/usecase/DictionarySourceFileMatcher.kt @@ -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, + dictionaries: List, + ): List = sources.filter { source -> + dictionaries.any { dictionary -> matches(source, dictionary) } + } + + fun installedSourcesForFileNames( + sources: List, + fileNames: Collection, + ): List = sources.filter { source -> + fileNames.any { fileName -> matches(source, fileName) } + } +} diff --git a/app/src/main/java/com/example/research/core/domain/usecase/DictionarySourceValidator.kt b/app/src/main/java/com/example/research/core/domain/usecase/DictionarySourceValidator.kt index 10ec31a..4ed40f2 100644 --- a/app/src/main/java/com/example/research/core/domain/usecase/DictionarySourceValidator.kt +++ b/app/src/main/java/com/example/research/core/domain/usecase/DictionarySourceValidator.kt @@ -43,12 +43,4 @@ class DictionarySourceValidator { return ValidationResult.Valid } - fun findMatchingSourceWithPrecomputedPrefixes( - dictionaryPrefix: String, - sourcesWithPrefixes: List> - ): DictionarySource? { - return sourcesWithPrefixes.find { (_, sourcePrefix) -> - sourcePrefix != null && dictionaryPrefix.equals(sourcePrefix, ignoreCase = true) - }?.first - } } diff --git a/app/src/main/java/com/example/research/core/domain/usecase/ManageDictionarySourcesUseCase.kt b/app/src/main/java/com/example/research/core/domain/usecase/ManageDictionarySourcesUseCase.kt index 9abfca9..7239740 100644 --- a/app/src/main/java/com/example/research/core/domain/usecase/ManageDictionarySourcesUseCase.kt +++ b/app/src/main/java/com/example/research/core/domain/usecase/ManageDictionarySourcesUseCase.kt @@ -30,17 +30,23 @@ class ManageDictionarySourcesUseCase( preferencesManager.removeDictionarySource(sourceId) } - suspend fun removeSourceForDictionary(dictionary: Dictionary) { - val dictionaryPrefix = dictionary.name - val sources = preferencesManager.dictionarySources.first() - val sourcesWithPrefixes = sources.map { source -> - source to DictionarySource.extractPrefix(source.urlTemplate) - } + suspend fun removeSourceForDictionary( + dictionary: Dictionary, + remainingDictionaryFileNames: Collection?, + ) { + if (remainingDictionaryFileNames == null) return - val matchingSource = validator.findMatchingSourceWithPrecomputedPrefixes(dictionaryPrefix, sourcesWithPrefixes) - if (matchingSource != null) { - preferencesManager.removeDictionarySource(matchingSource.id) - } + val sources = preferencesManager.dictionarySources.first() + val sourceIdsToRemove = sources + .filter { source -> + DictionarySourceFileMatcher.matches(source, dictionary) && + remainingDictionaryFileNames.none { fileName -> + DictionarySourceFileMatcher.matches(source, fileName) + } + } + .map(DictionarySource::id) + + preferencesManager.removeDictionarySources(sourceIdsToRemove) } sealed class AddSourceResult { diff --git a/app/src/main/java/com/example/research/data/repository/LocalDictionaryRepository.kt b/app/src/main/java/com/example/research/data/repository/LocalDictionaryRepository.kt index 51c53c6..452e812 100644 --- a/app/src/main/java/com/example/research/data/repository/LocalDictionaryRepository.kt +++ b/app/src/main/java/com/example/research/data/repository/LocalDictionaryRepository.kt @@ -123,6 +123,24 @@ class LocalDictionaryRepository( fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true + suspend fun listDictionaryPayloadFileNames(path: String): List? = + 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 = withContext(ioDispatcher) { if (isIndexingInProgress()) { return@withContext OperationResult.Error( @@ -371,9 +389,7 @@ class LocalDictionaryRepository( val dir = File(path) if (!dir.exists() || !dir.isDirectory) return emptyList() - return dir.listFiles { f -> - f.isFile && (f.name.endsWith(".dsl") || f.name.endsWith(".dsl.dz") || f.name.endsWith(".dsl.gz")) - }?.map { file -> + return dir.listFiles { file -> isDictionaryPayloadFile(file) }?.map { file -> DiscoveredFile( name = file.name, localPath = file.absolutePath, @@ -382,6 +398,13 @@ class LocalDictionaryRepository( } ?: 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> = withContext(defaultDispatcher) { if (query.isBlank()) return@withContext OperationResult.Success(emptyList()) @@ -460,53 +483,56 @@ class LocalDictionaryRepository( } suspend fun deleteDictionary(dictionary: Dictionary): OperationResult { - try { - dictionaryStateMutex.withLock { removeDictionaryFromState(dictionary.path) } - - launch(ioDispatcher) { - try { - val deleteErrors = mutableListOf() - - val dictFile = File(dictionary.path) - if (dictFile.exists()) { - if (!dictFile.delete()) { - deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}") - } - } - - val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) { - File(dictionary.indexPath) - } else { - File("${dictionary.indexPath}.idx") - } - - deleteIndexFiles(indexFile, deleteErrors) - - if (!dictFile.exists()) { - dictionaryStateMutex.withLock { - preferencesManager.removeDictionaryActiveState(dictionary.path) - removeDictionaryFromState(dictionary.path) - } - } - - try { - engine.getIndexSearcher().trimMemory(80) - } catch (e: Exception) { - Log.w(TAG, "Failed to trim memory after deletion: ${e.message}") - } - - if (deleteErrors.isNotEmpty()) { - val message = deleteErrors.joinToString("; ") - Log.w(TAG, "Dictionary deletion completed with errors: $message") - } - } catch (e: Exception) { - Log.e(TAG, "Background deletion failed: ${e.message}", e) + return try { + val (failure, cleanupErrors) = withContext(ioDispatcher) { + val dictFile = File(dictionary.path) + if (dictFile.exists() && !dictFile.delete()) { + return@withContext Pair( + "Failed to delete dictionary file: ${dictFile.absolutePath}", + emptyList(), + ) } + + val deleteErrors = mutableListOf() + val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) { + File(dictionary.indexPath) + } else { + File("${dictionary.indexPath}.idx") + } + + deleteIndexFiles(indexFile, deleteErrors) + Pair>(null, deleteErrors) } - return OperationResult.Success(Unit) + 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 { + engine.getIndexSearcher().trimMemory(80) + } catch (e: Exception) { + Log.w(TAG, "Failed to trim memory after deletion: ${e.message}") + } + + if (cleanupErrors.isNotEmpty()) { + Log.w( + TAG, + "Dictionary deletion completed with errors: ${cleanupErrors.joinToString("; ")}" + ) + } + + OperationResult.Success(Unit) } catch (e: Exception) { - return OperationResult.Error("Failed to delete dictionary", e) + OperationResult.Error("Failed to delete dictionary", e) } } diff --git a/app/src/main/java/com/example/research/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/example/research/ui/settings/SettingsViewModel.kt index 63bd181..e68aa69 100644 --- a/app/src/main/java/com/example/research/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/example/research/ui/settings/SettingsViewModel.kt @@ -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() + private val pendingSourceUrls = ConcurrentHashMap.newKeySet() 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().getString(R.string.error_delete_dictionary) + ) + } } } } private fun addDictionarySources(urlTemplates: List) { viewModelScope.launch { - try { - val validUrls = mutableListOf() + val validUrls = mutableListOf() - 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, dictionaries: List - ): List { - val installedFileNames = dictionaries.map { File(it.path).name } - return sources.filter { source -> - installedFileNames.any { fileName -> - DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName) - } - } - } + ): List = + 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) { diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 333d78f..d67a7b1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -96,6 +96,7 @@ • Не индексирован Удалить словарь + Не удалось удалить словарь Нажмите для обновления Импорт словарей Импорт завершён diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad62b8a..a6eba8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,6 +92,7 @@ • Not indexed Delete dictionary + Failed to delete dictionary Tap to update all dictionaries Importing dictionaries Import completed diff --git a/fastlane/metadata/android/en-US/changelogs/7.txt b/fastlane/metadata/android/en-US/changelogs/7.txt new file mode 100644 index 0000000..8bbc916 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/7.txt @@ -0,0 +1 @@ +- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL diff --git a/fastlane/metadata/android/ru-RU/changelogs/7.txt b/fastlane/metadata/android/ru-RU/changelogs/7.txt new file mode 100644 index 0000000..32424ea --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/7.txt @@ -0,0 +1 @@ +- Исправлено сопоставление URL-источников с установленными файлами — удалённый словарь теперь можно повторно скачать по тому же URL