Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
910bc8313c |
@@ -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
|
||||
|
||||
+27
@@ -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) }
|
||||
}
|
||||
}
|
||||
-8
@@ -43,12 +43,4 @@ class DictionarySourceValidator {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+15
-9
@@ -30,18 +30,24 @@ 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<String>?,
|
||||
) {
|
||||
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 {
|
||||
object Success : AddSourceResult()
|
||||
|
||||
+54
-28
@@ -123,6 +123,24 @@ class LocalDictionaryRepository(
|
||||
|
||||
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) {
|
||||
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<List<IndexEntry>> = withContext(defaultDispatcher) {
|
||||
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
|
||||
|
||||
@@ -460,20 +483,17 @@ class LocalDictionaryRepository(
|
||||
}
|
||||
|
||||
suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
|
||||
try {
|
||||
dictionaryStateMutex.withLock { removeDictionaryFromState(dictionary.path) }
|
||||
|
||||
launch(ioDispatcher) {
|
||||
try {
|
||||
val deleteErrors = mutableListOf<String>()
|
||||
|
||||
return try {
|
||||
val (failure, cleanupErrors) = withContext(ioDispatcher) {
|
||||
val dictFile = File(dictionary.path)
|
||||
if (dictFile.exists()) {
|
||||
if (!dictFile.delete()) {
|
||||
deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}")
|
||||
}
|
||||
if (dictFile.exists() && !dictFile.delete()) {
|
||||
return@withContext Pair(
|
||||
"Failed to delete dictionary file: ${dictFile.absolutePath}",
|
||||
emptyList<String>(),
|
||||
)
|
||||
}
|
||||
|
||||
val deleteErrors = mutableListOf<String>()
|
||||
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
|
||||
File(dictionary.indexPath)
|
||||
} else {
|
||||
@@ -481,12 +501,20 @@ class LocalDictionaryRepository(
|
||||
}
|
||||
|
||||
deleteIndexFiles(indexFile, deleteErrors)
|
||||
|
||||
if (!dictFile.exists()) {
|
||||
dictionaryStateMutex.withLock {
|
||||
preferencesManager.removeDictionaryActiveState(dictionary.path)
|
||||
removeDictionaryFromState(dictionary.path)
|
||||
Pair<String?, List<String>>(null, deleteErrors)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -495,18 +523,16 @@ class LocalDictionaryRepository(
|
||||
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)
|
||||
}
|
||||
if (cleanupErrors.isNotEmpty()) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Dictionary deletion completed with errors: ${cleanupErrors.joinToString("; ")}"
|
||||
)
|
||||
}
|
||||
|
||||
return OperationResult.Success(Unit)
|
||||
OperationResult.Success(Unit)
|
||||
} 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.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,18 +260,21 @@ class SettingsViewModel(
|
||||
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
|
||||
return try {
|
||||
val actualDictionaries = localDictionaryRepository.dictionaries.first()
|
||||
val hasDictionaries = actualDictionaries.isNotEmpty()
|
||||
|
||||
if (!hasDictionaries) {
|
||||
return DictionaryStatus.Empty
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
val installedSources = installedSources(sources, actualDictionaries)
|
||||
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id)
|
||||
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)
|
||||
@@ -284,6 +283,12 @@ class SettingsViewModel(
|
||||
sources = installedSources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actualDictionaries.isEmpty()) {
|
||||
return DictionaryStatus.Empty
|
||||
}
|
||||
|
||||
val enabledSources = installedEnabledSources(sources, actualDictionaries)
|
||||
|
||||
if (enabledSources.isEmpty()) {
|
||||
@@ -338,21 +343,31 @@ 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>()
|
||||
|
||||
urlTemplates.forEach { urlTemplate ->
|
||||
@@ -361,23 +376,30 @@ class SettingsViewModel(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +517,11 @@ 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) {
|
||||
} else if (isDownloadInProgress) {
|
||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||
isDownloadInProgress = false
|
||||
} else {
|
||||
@@ -512,7 +529,6 @@ class SettingsViewModel(
|
||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
||||
dictionaryStatus.value = status
|
||||
}
|
||||
}
|
||||
|
||||
if (result.data > 0) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
</plurals>
|
||||
<string name="dictionary_not_indexed">• Не индексирован</string>
|
||||
<string name="dictionary_delete">Удалить словарь</string>
|
||||
<string name="error_delete_dictionary">Не удалось удалить словарь</string>
|
||||
<string name="dictionaries_tap_to_update">Нажмите для обновления</string>
|
||||
<string name="notification_import_title">Импорт словарей</string>
|
||||
<string name="notification_import_success_title">Импорт завершён</string>
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
</plurals>
|
||||
<string name="dictionary_not_indexed">• Not indexed</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="notification_import_title">Importing dictionaries</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
|
||||
Reference in New Issue
Block a user