5 Commits
Author SHA1 Message Date
OneWay bc3fa85b9d Release 1.5.0
- The dictionary update card now returns after a failed download
- Fixed headwords and articles with escaped characters being displayed incorrectly
- Fixed slow rendering of articles containing many bracket characters
2026-08-18 00:16:27 +03:00
OneWay 457bbe43b6 Unescape any escaped character in article text
Apply the general DSL rule \X -> X instead of unescaping only brackets
and parentheses, so sequences like \! no longer show the backslash.
2026-08-18 00:15:53 +03:00
OneWay f751ac0d36 Harden DSL parsing against escapes and malformed tags
Fixed escape-aware headword parsing for \{, \}, \[ and trailing backslash.
Fixed O(n^2) tag scan and nested bracket handling in article rendering.
2026-08-16 11:13:46 +03:00
OneWay 910bc8313c Release 1.4.0
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
2026-08-06 14:26:03 +03:00
OneWay 9d18d0c927 Release 1.3.0
- Blocked swipe-to-delete on dictionary rows while a download, extract,
  import or indexing pass is running. Deleting mid-update removed the
  dictionary's source from preferences while the pipeline kept its own
  snapshot, so the post-update rescan re-created the dictionary with its
  update source permanently lost.
- Fixed the dictionary icon leaving the row while swiping to delete: the
  item now clips to its bounds and the delete button is a static overlay
  instead of a counter-translated child of the sliding row.
2026-08-02 11:09:47 +03:00
22 changed files with 403 additions and 194 deletions
+3 -3
View File
@@ -95,15 +95,15 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack ## Tech Stack
- Kotlin 2.4.10 - Kotlin 2.4.10
- Jetpack Compose (BOM 2026.06.01) - Jetpack Compose (BOM 2026.08.00)
- Material 3 - Material 3
- Coroutines and Flow 1.11.0 - Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1 - DataStore Preferences 1.2.1
- Paging 3.5.0 - Paging 3.5.1
- OkHttp 5.4.0 - OkHttp 5.4.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.11.0
- AboutLibraries 15.0.4 metadata generation - AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.3.0 - Android Gradle Plugin 9.3.1
## Build ## Build
+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 = 5 versionCode = 8
versionName = "1.2.1" versionName = "1.5.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,17 +30,23 @@ 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 {
@@ -24,10 +24,11 @@ object DslHeadwordParser {
} }
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) { if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
val unescaped = unescape(trimmed)
return ParsedHeadword( return ParsedHeadword(
simplified = trimmed, simplified = unescaped,
displayText = trimmed, displayText = unescaped,
searchableText = trimmed.lowercase() searchableText = unescaped.lowercase()
) )
} }
@@ -60,12 +61,12 @@ object DslHeadwordParser {
val searchableText = createSearchableText(trimmed) val searchableText = createSearchableText(trimmed)
return ParsedHeadword( return ParsedHeadword(
simplified = simplified, simplified = unescape(simplified),
displayText = displayText, displayText = unescape(displayText),
searchableText = searchableText searchableText = unescape(searchableText)
) )
} }
private fun buildDisplayText( private fun buildDisplayText(
reconstructedPrefix: String, reconstructedPrefix: String,
simplified: String, simplified: String,
@@ -96,7 +97,7 @@ object DslHeadwordParser {
.orEmpty() .orEmpty()
} }
} }
private fun createSearchableText(originalHeadword: String): String { private fun createSearchableText(originalHeadword: String): String {
return removeFormattingTags( return removeFormattingTags(
removeCurlyBraceMatches(originalHeadword, removeEmpty = true) removeCurlyBraceMatches(originalHeadword, removeEmpty = true)
@@ -129,8 +130,17 @@ object DslHeadwordParser {
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
return buildString(value.length) { return buildString(value.length) {
value.forEach { char -> var index = 0
if (char != '{' && char != '}') append(char) while (index < value.length) {
val char = value[index]
if (char == '\\' && index + 1 < value.length) {
append(char)
append(value[index + 1])
index += 2
} else {
if (char != '{' && char != '}') append(char)
index++
}
} }
} }
} }
@@ -138,6 +148,10 @@ object DslHeadwordParser {
private fun firstFormattingTagIndex(value: String): Int { private fun firstFormattingTagIndex(value: String): Int {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[' && formattingTagEnd(value, index) > index) { if (value[index] == '[' && formattingTagEnd(value, index) > index) {
return index return index
} }
@@ -149,8 +163,12 @@ object DslHeadwordParser {
private fun firstCurlyContent(value: String): String? { private fun firstCurlyContent(value: String): String? {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '{') { if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1) val end = matchingCurlyEnd(value, index + 1)
if (end > index + 1) { if (end > index + 1) {
return value.substring(index + 1, end) return value.substring(index + 1, end)
} }
@@ -161,7 +179,7 @@ object DslHeadwordParser {
} }
private fun substringBeforeFirstBracket(value: String): String { private fun substringBeforeFirstBracket(value: String): String {
val bracketIndex = value.indexOf('[') val bracketIndex = indexOfUnescaped(value, '[')
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
} }
@@ -171,8 +189,12 @@ object DslHeadwordParser {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '{') { if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1) val end = matchingCurlyEnd(value, index + 1)
if (end >= 0 && (removeEmpty || end > index + 1)) { if (end >= 0 && (removeEmpty || end > index + 1)) {
if (builder == null) { if (builder == null) {
builder = StringBuilder(value.length) builder = StringBuilder(value.length)
@@ -197,6 +219,10 @@ object DslHeadwordParser {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[') { if (value[index] == '[') {
val tagEnd = formattingTagEnd(value, index) val tagEnd = formattingTagEnd(value, index)
if (tagEnd > index) { if (tagEnd > index) {
@@ -217,6 +243,56 @@ object DslHeadwordParser {
}?.toString() ?: value }?.toString() ?: value
} }
private fun matchingCurlyEnd(value: String, startIndex: Int): Int {
var index = startIndex
while (index < value.length) {
when {
value[index] == '\\' && index + 1 < value.length -> index += 2
value[index] == '{' -> return -1
value[index] == '}' -> return index
else -> index++
}
}
return -1
}
private fun indexOfUnescaped(
value: String,
target: Char,
startIndex: Int = 0,
): Int {
var index = startIndex.coerceAtLeast(0)
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
} else if (value[index] == target) {
return index
} else {
index++
}
}
return -1
}
private fun unescape(value: String): String {
val firstEscape = value.indexOf('\\')
if (firstEscape < 0) return value
return buildString(value.length) {
append(value, 0, firstEscape)
var index = firstEscape
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
append(value[index + 1])
index += 2
} else {
append(value[index])
index++
}
}
}
}
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int { private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
val tokenStart = openBracketIndex + 1 val tokenStart = openBracketIndex + 1
if (tokenStart >= value.length) return -1 if (tokenStart >= value.length) return -1
@@ -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,53 +483,56 @@ 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) {
val dictFile = File(dictionary.path)
launch(ioDispatcher) { if (dictFile.exists() && !dictFile.delete()) {
try { return@withContext Pair(
val deleteErrors = mutableListOf<String>() "Failed to delete dictionary file: ${dictFile.absolutePath}",
emptyList<String>(),
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)
} }
val deleteErrors = mutableListOf<String>()
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
File(dictionary.indexPath)
} else {
File("${dictionary.indexPath}.idx")
}
deleteIndexFiles(indexFile, deleteErrors)
Pair<String?, List<String>>(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) { } catch (e: Exception) {
return OperationResult.Error("Failed to delete dictionary", e) OperationResult.Error("Failed to delete dictionary", e)
} }
} }
@@ -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)
} }
@@ -23,7 +23,6 @@ object DslAnnotatedParser {
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5", "br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
"ref", "ex", "e", "trn", "com", "lang", "sup", "'" "ref", "ex", "e", "trn", "com", "lang", "sup", "'"
) )
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
data class ColorScheme( data class ColorScheme(
val secondaryText: Color, val secondaryText: Color,
@@ -101,15 +100,13 @@ object DslAnnotatedParser {
'\\' -> { '\\' -> {
if (i + 1 < length) { if (i + 1 < length) {
val next = dsl[i + 1] val next = dsl[i + 1]
if (next in ESCAPED_CHARS) { builder.append(next)
builder.append(next) if (refStack.isNotEmpty()) {
if (refStack.isNotEmpty()) { refStack.last().second.append(next)
refStack.last().second.append(next)
}
lastChar = next
i += 2
continue
} }
lastChar = next
i += 2
continue
} }
builder.append(char) builder.append(char)
if (refStack.isNotEmpty()) { if (refStack.isNotEmpty()) {
@@ -119,7 +116,7 @@ object DslAnnotatedParser {
i++ i++
} }
'[' -> { '[' -> {
val end = dsl.indexOf(']', i + 1) val end = tagCloseIndex(dsl, i + 1)
if (end != -1) { if (end != -1) {
val tagStart = i + 1 val tagStart = i + 1
var tagEnd = end var tagEnd = end
@@ -228,6 +225,19 @@ object DslAnnotatedParser {
splitOversizedBlocks(result) splitOversizedBlocks(result)
} }
private fun tagCloseIndex(value: String, startIndex: Int): Int {
var index = startIndex
while (index < value.length) {
when {
value[index] == '\\' && index + 1 < value.length -> index += 2
value[index] == '[' -> return -1
value[index] == ']' -> return index
else -> index++
}
}
return -1
}
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> { private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
return blocks.flatMap { block -> return blocks.flatMap { block ->
@@ -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,10 +80,12 @@ 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
private var errorStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init { init {
setupStateObservation() setupStateObservation()
@@ -146,37 +149,37 @@ class SettingsViewModel(
is DownloadState.Loading, is DownloadState.Extracting -> { is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true isDownloadInProgress = true
cancelRefreshPending = false cancelRefreshPending = false
errorStateHandled = false
} }
is DownloadState.Success -> { is DownloadState.Success -> {
pendingSourceUrls.clear() pendingSourceUrls.clear()
} }
is DownloadState.Error -> { is DownloadState.Error -> {
isDownloadInProgress = false isDownloadInProgress = false
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) { if (!errorStateHandled) {
DictionaryStatus.Empty errorStateHandled = true
} else { dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.UpToDate DictionaryStatus.Empty
} } else when (statusBeforeDownload) {
if (pendingSourceUrls.isNotEmpty()) { DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
else -> statusBeforeDownload
pendingSourceUrls.forEach { urlTemplate -> }
val source = dictionaryState.dictionarySources.find { if (pendingSourceUrls.isNotEmpty()) {
it.urlTemplate == urlTemplate pendingSourceUrls.forEach { urlTemplate ->
} val source = dictionaryState.dictionarySources.find {
if (source != null) { it.urlTemplate == urlTemplate
val hasDictionary = dictionaryState.dictionaries.any { dict ->
DictionarySource.matchesDictionaryFile(
urlTemplate,
File(dict.path).name
)
} }
if (source != null) {
if (!hasDictionary) { val hasDictionary = dictionaryState.dictionaries.any { dictionary ->
manageDictionarySourcesUseCase.removeSource(source.id) DictionarySourceFileMatcher.matches(source, dictionary)
}
if (!hasDictionary) {
manageDictionarySourcesUseCase.removeSource(source.id)
}
} }
} }
pendingSourceUrls.clear()
} }
pendingSourceUrls.clear()
} }
} }
is DownloadState.Cancelled -> { is DownloadState.Cancelled -> {
@@ -264,26 +267,35 @@ 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() 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 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) val enabledSources = installedEnabledSources(sources, actualDictionaries)
if (enabledSources.isEmpty()) { if (enabledSources.isEmpty()) {
@@ -338,45 +350,62 @@ 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 ->
val trimmed = urlTemplate.trim() val trimmed = urlTemplate.trim()
if (trimmed.isEmpty() || trimmed.length > 2048) { if (trimmed.isEmpty() || trimmed.length > 2048) {
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)
} }
} }
} }
@@ -390,6 +419,7 @@ class SettingsViewModel(
return@launch return@launch
} }
statusBeforeDownload = dictionaryStatus.value
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
isDownloadInProgress = true isDownloadInProgress = true
@@ -413,6 +443,7 @@ class SettingsViewModel(
return@launch return@launch
} }
statusBeforeDownload = dictionaryStatus.value
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
val installedSources = installedEnabledSources( val installedSources = installedEnabledSources(
@@ -448,14 +479,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,17 +526,17 @@ 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
} else if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false isDownloadInProgress = false
} else { } else {
if (isDownloadInProgress) { dictionaryStatus.value = DictionaryStatus.Checking
dictionaryStatus.value = DictionaryStatus.UpToDate val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
isDownloadInProgress = false dictionaryStatus.value = status
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
} }
if (result.data > 0) { if (result.data > 0) {
@@ -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)
)
}
}
} }
} }
@@ -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
) )
} }
} }
+2
View File
@@ -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>
+2
View File
@@ -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,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
@@ -0,0 +1,3 @@
- The dictionary update card now returns after a failed download
- Fixed headwords and articles with escaped characters being displayed incorrectly
- Fixed slow rendering of articles containing many bracket characters
@@ -0,0 +1,2 @@
- Словари больше нельзя удалить во время обновления — раньше при этом терялся источник обновления
- Исправлен выход иконки словаря за границы строки при свайпе
@@ -0,0 +1 @@
- Исправлено сопоставление URL-источников с установленными файлами — удалённый словарь теперь можно повторно скачать по тому же URL
@@ -0,0 +1,3 @@
- Карточка обновления словарей теперь появляется снова после неудачной загрузки
- Исправлено отображение заголовков и статей с экранированными символами
- Исправлено медленное отображение статей с большим количеством скобок
+2 -2
View File
@@ -2,14 +2,14 @@
aboutlibraries = "15.0.4" aboutlibraries = "15.0.4"
activity_compose = "1.13.0" activity_compose = "1.13.0"
agp = "9.3.1" agp = "9.3.1"
compose_bom = "2026.06.01" compose_bom = "2026.08.00"
core = "1.19.0" core = "1.19.0"
datastore_preferences = "1.2.1" datastore_preferences = "1.2.1"
documentfile = "1.1.0" documentfile = "1.1.0"
kotlin = "2.4.10" kotlin = "2.4.10"
lifecycle_runtime_ktx = "2.11.0" lifecycle_runtime_ktx = "2.11.0"
okhttp = "5.4.0" okhttp = "5.4.0"
paging = "3.5.0" paging = "3.5.1"
profileinstaller = "1.4.1" profileinstaller = "1.4.1"
kotlinx_coroutines = "1.11.0" kotlinx_coroutines = "1.11.0"
kotlinx_serialization = "1.11.0" kotlinx_serialization = "1.11.0"