Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
910bc8313c | ||
|
|
9d18d0c927 | ||
|
|
69bbb97b2a | ||
|
|
c8adf537d4 |
@@ -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 = 4
|
versionCode = 7
|
||||||
versionName = "1.2.0"
|
versionName = "1.4.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@@ -114,12 +114,9 @@ android {
|
|||||||
abiFilters += "arm64-v8a"
|
abiFilters += "arm64-v8a"
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinifyEnabled = true
|
optimization {
|
||||||
isShrinkResources = true
|
enable = true
|
||||||
proguardFiles(
|
}
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
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)
|
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()
|
||||||
|
|||||||
+54
-28
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class DownloadManager(
|
|||||||
mutableDownloadProgressState.asStateFlow()
|
mutableDownloadProgressState.asStateFlow()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private var downloadJob: Job? = null
|
private var downloadJob: Job? = null
|
||||||
|
private var cancelCleanupJob: Job? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DownloadManager"
|
private const val TAG = "DownloadManager"
|
||||||
@@ -44,6 +45,7 @@ class DownloadManager(
|
|||||||
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
|
||||||
|
cancelCleanupJob = null
|
||||||
downloadJob = downloadScope.launch {
|
downloadJob = downloadScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
try {
|
try {
|
||||||
@@ -94,7 +96,7 @@ class DownloadManager(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException) {
|
||||||
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
||||||
downloadScope.launch {
|
cancelCleanupJob = downloadScope.launch {
|
||||||
try {
|
try {
|
||||||
dictionaryRepository.deleteUnprocessedFiles()
|
dictionaryRepository.deleteUnprocessedFiles()
|
||||||
} catch (cleanupException: Exception) {
|
} catch (cleanupException: Exception) {
|
||||||
@@ -124,6 +126,7 @@ class DownloadManager(
|
|||||||
fun startDownloadForSources(sourceUrls: List<String>) {
|
fun startDownloadForSources(sourceUrls: List<String>) {
|
||||||
if (downloadState.value is DownloadState.Loading) return
|
if (downloadState.value is DownloadState.Loading) return
|
||||||
if (!downloadScope.isActive) return
|
if (!downloadScope.isActive) return
|
||||||
|
cancelCleanupJob = null
|
||||||
downloadJob = downloadScope.launch {
|
downloadJob = downloadScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
try {
|
try {
|
||||||
@@ -168,7 +171,7 @@ class DownloadManager(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException) {
|
||||||
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
||||||
downloadScope.launch {
|
cancelCleanupJob = downloadScope.launch {
|
||||||
try {
|
try {
|
||||||
dictionaryRepository.deleteUnprocessedFiles()
|
dictionaryRepository.deleteUnprocessedFiles()
|
||||||
} catch (cleanupException: Exception) {
|
} catch (cleanupException: Exception) {
|
||||||
@@ -195,10 +198,12 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun cancelDownload() {
|
fun cancelDownload() {
|
||||||
downloadJob?.cancel()
|
val jobToCancel = downloadJob
|
||||||
|
jobToCancel?.cancel()
|
||||||
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
||||||
downloadScope.launch {
|
downloadScope.launch {
|
||||||
delay(TERMINAL_STATE_DURATION)
|
jobToCancel?.join()
|
||||||
|
cancelCleanupJob?.join()
|
||||||
if (downloadState.value == DownloadState.Cancelled) {
|
if (downloadState.value == DownloadState.Cancelled) {
|
||||||
updateDownloadProgressState(DownloadState.Idle, 0f)
|
updateDownloadProgressState(DownloadState.Idle, 0f)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -343,6 +343,8 @@ class DictionaryDownloader(
|
|||||||
context.getString(R.string.download_connection_reset)
|
context.getString(R.string.download_connection_reset)
|
||||||
e is SecurityException ->
|
e is SecurityException ->
|
||||||
context.getString(R.string.download_no_write_permission)
|
context.getString(R.string.download_no_write_permission)
|
||||||
|
e.isNetworkError() ->
|
||||||
|
context.getString(R.string.download_network_failed)
|
||||||
else ->
|
else ->
|
||||||
context.getString(R.string.download_error)
|
context.getString(R.string.download_error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
@@ -135,7 +136,9 @@ private fun SearchField(
|
|||||||
Icon(
|
Icon(
|
||||||
painter = searchIcon,
|
painter = searchIcon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier
|
||||||
|
.offset(x = 4.dp)
|
||||||
|
.size(24.dp)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = if (searchState.text.isNotEmpty()) {
|
trailingIcon = if (searchState.text.isNotEmpty()) {
|
||||||
@@ -146,6 +149,7 @@ private fun SearchField(
|
|||||||
onClearQuery()
|
onClearQuery()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.padding(end = 12.dp)
|
||||||
.testTag("clear_search")
|
.testTag("clear_search")
|
||||||
.semantics { testTagsAsResourceId = true }
|
.semantics { testTagsAsResourceId = true }
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+40
-18
@@ -30,6 +30,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
@@ -81,7 +82,8 @@ fun DictionaryListItem(
|
|||||||
dictionary: Dictionary,
|
dictionary: Dictionary,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
canDelete: Boolean = true
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -93,7 +95,9 @@ fun DictionaryListItem(
|
|||||||
val offsetAnim = remember { Animatable(0f) }
|
val offsetAnim = remember { Animatable(0f) }
|
||||||
|
|
||||||
var rawOffset by remember { mutableFloatStateOf(0f) }
|
var rawOffset by remember { mutableFloatStateOf(0f) }
|
||||||
val isDeleteRevealed by remember { derivedStateOf { rawOffset <= -swipeThresholdPx } }
|
val isDeleteRevealed by remember(canDelete) {
|
||||||
|
derivedStateOf { canDelete && rawOffset <= -swipeThresholdPx }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(isDeleteRevealed) {
|
LaunchedEffect(isDeleteRevealed) {
|
||||||
if (isDeleteRevealed) {
|
if (isDeleteRevealed) {
|
||||||
@@ -101,6 +105,13 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(canDelete) {
|
||||||
|
if (!canDelete) {
|
||||||
|
rawOffset = 0f
|
||||||
|
offsetAnim.snapTo(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val currentOnDelete by rememberUpdatedState(onDelete)
|
val currentOnDelete by rememberUpdatedState(onDelete)
|
||||||
val currentOnToggle by rememberUpdatedState(onToggle)
|
val currentOnToggle by rememberUpdatedState(onToggle)
|
||||||
|
|
||||||
@@ -121,6 +132,8 @@ fun DictionaryListItem(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.height(DictionaryItemHeight)
|
||||||
|
.clipToBounds()
|
||||||
.testTag("dictionary_item")
|
.testTag("dictionary_item")
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -129,7 +142,8 @@ fun DictionaryListItem(
|
|||||||
.height(DictionaryItemHeight)
|
.height(DictionaryItemHeight)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
.graphicsLayer { translationX = offsetAnim.value }
|
.graphicsLayer { translationX = offsetAnim.value }
|
||||||
.pointerInput(maxSwipePx, swipeThresholdPx) {
|
.pointerInput(canDelete, maxSwipePx, swipeThresholdPx) {
|
||||||
|
if (!canDelete) return@pointerInput
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
@@ -222,25 +236,11 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDeleteRevealed) {
|
if (isDeleteRevealed) {
|
||||||
IconButton(
|
Box(
|
||||||
onClick = {
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
currentOnDelete()
|
|
||||||
scope.launch { offsetAnim.snapTo(0f) }
|
|
||||||
rawOffset = 0f
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(end = ItemHorizontalPadding)
|
.padding(end = ItemHorizontalPadding)
|
||||||
.graphicsLayer { translationX = -offsetAnim.value }
|
|
||||||
.size(IconButtonSize)
|
.size(IconButtonSize)
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_delete),
|
|
||||||
contentDescription = stringResource(R.string.dictionary_delete),
|
|
||||||
tint = DeleteIconColor,
|
|
||||||
modifier = Modifier.size(IconSize)
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Switch(
|
Switch(
|
||||||
checked = dictionary.isActive,
|
checked = dictionary.isActive,
|
||||||
@@ -257,5 +257,27 @@ fun DictionaryListItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDeleteRevealed) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
currentOnDelete()
|
||||||
|
scope.launch { offsetAnim.snapTo(0f) }
|
||||||
|
rawOffset = 0f
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.padding(end = ItemHorizontalPadding)
|
||||||
|
.size(IconButtonSize)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_delete),
|
||||||
|
contentDescription = stringResource(R.string.dictionary_delete),
|
||||||
|
tint = DeleteIconColor,
|
||||||
|
modifier = Modifier.size(IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -166,7 +166,8 @@ fun DictionaryManagement(
|
|||||||
onDeleteDictionary = onDeleteDictionary,
|
onDeleteDictionary = onDeleteDictionary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(backgroundModifier)
|
.then(backgroundModifier),
|
||||||
|
canDelete = !isInProgress
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isInProgress) {
|
if (!isInProgress) {
|
||||||
@@ -246,7 +247,8 @@ private fun DictionaryListSection(
|
|||||||
dictionaries: List<Dictionary>,
|
dictionaries: List<Dictionary>,
|
||||||
onToggleDictionary: (String) -> Unit,
|
onToggleDictionary: (String) -> Unit,
|
||||||
onDeleteDictionary: (Dictionary) -> Unit,
|
onDeleteDictionary: (Dictionary) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
canDelete: Boolean = true
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -256,7 +258,8 @@ private fun DictionaryListSection(
|
|||||||
DictionaryListItem(
|
DictionaryListItem(
|
||||||
dictionary = dictionary,
|
dictionary = dictionary,
|
||||||
onToggle = { onToggleDictionary(dictionary.path) },
|
onToggle = { onToggleDictionary(dictionary.path) },
|
||||||
onDelete = { onDeleteDictionary(dictionary) }
|
onDelete = { onDeleteDictionary(dictionary) },
|
||||||
|
canDelete = canDelete
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<string name="button_cancel_download">Отмена</string>
|
<string name="button_cancel_download">Отмена</string>
|
||||||
<string name="action_ok">ОК</string>
|
<string name="action_ok">ОК</string>
|
||||||
<string name="download_error">Ошибка загрузки словарей</string>
|
<string name="download_error">Ошибка загрузки словарей</string>
|
||||||
|
<string name="download_network_failed">Нет связи с сервером словарей. Проверьте сеть и попробуйте снова</string>
|
||||||
<string name="language">Язык</string>
|
<string name="language">Язык</string>
|
||||||
<string name="language_english">Английский</string>
|
<string name="language_english">Английский</string>
|
||||||
<string name="language_russian">Русский</string>
|
<string name="language_russian">Русский</string>
|
||||||
@@ -95,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>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<string name="button_cancel_download">Cancel</string>
|
<string name="button_cancel_download">Cancel</string>
|
||||||
<string name="action_ok">OK</string>
|
<string name="action_ok">OK</string>
|
||||||
<string name="download_error">Dictionary download failed</string>
|
<string name="download_error">Dictionary download failed</string>
|
||||||
|
<string name="download_network_failed">No connection to the dictionary server. Check your network and try again</string>
|
||||||
<string name="language">Language</string>
|
<string name="language">Language</string>
|
||||||
<string name="language_english">English</string>
|
<string name="language_english">English</string>
|
||||||
<string name="language_russian">Russian</string>
|
<string name="language_russian">Russian</string>
|
||||||
@@ -91,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 the dictionary update card taking too long to reappear after cancelling a download
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Dictionaries can no longer be deleted while an update is running, which used to lose their update source
|
||||||
|
- Fixed the dictionary icon sliding outside the row while swiping to delete
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
- Исправлена лишняя задержка перед повторным появлением карточки обновления словаря после отмены загрузки
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Словари больше нельзя удалить во время обновления — раньше при этом терялся источник обновления
|
||||||
|
- Исправлен выход иконки словаря за границы строки при свайпе
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Исправлено сопоставление URL-источников с установленными файлами — удалённый словарь теперь можно повторно скачать по тому же URL
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -1,7 +1,7 @@
|
|||||||
[versions]
|
[versions]
|
||||||
aboutlibraries = "15.0.4"
|
aboutlibraries = "15.0.4"
|
||||||
activity_compose = "1.13.0"
|
activity_compose = "1.13.0"
|
||||||
agp = "9.3.0"
|
agp = "9.3.1"
|
||||||
compose_bom = "2026.06.01"
|
compose_bom = "2026.06.01"
|
||||||
core = "1.19.0"
|
core = "1.19.0"
|
||||||
datastore_preferences = "1.2.1"
|
datastore_preferences = "1.2.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user