2 Commits
Author SHA1 Message Date
OneWay 05b80f21f4 fix(search): drop the open article when the query is cleared 2026-09-19 15:17:53 +08:00
OneWay 114eb78bc2 chore(deps): bump Compose BOM to 2026.09.00
Measure-pass defect in compose ui 1.12.0: layoutChildrenBlock clobbered
the isPlacingForAlignment flag of child nodes during alignment
placement, so sheet anchors were recalculated from a stale content size.

compose ui 1.12.1 preserves and restores child placement flags, so the
anchor is computed from a consistent content size on the first frame.
2026-09-19 15:17:15 +08:00
21 changed files with 98 additions and 327 deletions

No files matched your search

+25 -10
View File
@@ -5,6 +5,7 @@
<p align="center"> <p align="center">
<a href="#screenshots">Screenshots</a> &bull; <a href="#screenshots">Screenshots</a> &bull;
<a href="#features">Features</a> &bull; <a href="#features">Features</a> &bull;
<a href="#architecture">Architecture</a> &bull;
<a href="#dictionary-pipeline">Pipeline</a> &bull; <a href="#dictionary-pipeline">Pipeline</a> &bull;
<a href="#build">Build</a> <a href="#build">Build</a>
</p> </p>
@@ -22,17 +23,31 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
- Fast headword search across multiple DSL dictionaries. - Fast headword search across multiple DSL dictionaries.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files. - Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases. - Download dictionaries from configured URLs.
- Automatic extraction, DictZip compression, and indexing. - Automatic extraction, DictZip compression, and indexing.
- Faithful article rendering with DSL indentation, margins, and named colors.
- Offline search after dictionaries are indexed. - Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries. - Random-access article loading from DictZip dictionaries.
- Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification. - Background processing with progress shown in Settings and notifications.
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe. - Dictionary management with enable, disable, update, and delete actions.
- Material 3 interface with dynamic color, light, dark, and system themes. - Material 3 interface with dynamic color, light, dark, and system themes.
- English and Russian localization. - English and Russian localization.
- About screen with open source license information. - About screen with open source license information.
## Architecture
ReSearch is an Android app written in Kotlin and Jetpack Compose.
```text
com.example.research/
├── common/ # shared progress, UI, file, archive, notification utilities
├── core/ # domain models, source validation, operation results
├── data/ # DictZip, DSL parsing, indexing, paging, repositories
├── feature/ # search, dictionary download, local import workflows
└── ui/ # Compose screens, article view, settings, navigation
```
State is exposed through Kotlin Flow and collected by Compose screens. Long-running work uses coroutines and foreground services where Android requires visible progress.
## Dictionary Pipeline ## Dictionary Pipeline
ReSearch supports both downloaded and locally imported dictionaries. ReSearch supports both downloaded and locally imported dictionaries.
@@ -79,16 +94,16 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack ## Tech Stack
- Kotlin 2.4.20 - Kotlin 2.4.10
- Jetpack Compose (BOM 2026.09.00) - 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.1 - Paging 3.5.1
- OkHttp 5.5.0 - OkHttp 5.4.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.11.0
- AboutLibraries 15.2.0 metadata generation - AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.4.1 - Android Gradle Plugin 9.3.1
## Build ## Build
@@ -96,7 +111,7 @@ The main search screen remains usable as long as at least one indexed dictionary
./gradlew :app:assembleRelease ./gradlew :app:assembleRelease
``` ```
The release APK targets `arm64-v8a`. The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
## License ## License
+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 = 9 versionCode = 8
versionName = "1.6.0" versionName = "1.5.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -109,7 +109,7 @@ object DictionaryProgressModel {
return Snapshot( return Snapshot(
progress = p, progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100), percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_preparing_indexing_title, titleRes = R.string.notification_indexing_title,
) )
} }
@@ -120,7 +120,7 @@ object DictionaryProgressModel {
} else { } else {
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT
} }
val pct = (p * 100f).toInt().coerceIn(0, 99) val pct = (p * 100f).toInt().coerceIn(0, 100)
return Snapshot( return Snapshot(
progress = p, progress = p,
@@ -32,7 +32,6 @@ class DictionaryProgressPresenter(
fun beginFlow() { fun beginFlow() {
terminalActive = false terminalActive = false
notifications.cancelTerminalNotification()
} }
override fun showImportSuccess() { override fun showImportSuccess() {
@@ -6,6 +6,5 @@ interface NotificationPort {
fun showDownloadSuccess() fun showDownloadSuccess()
fun showError() fun showError()
fun cancelProgressNotification() fun cancelProgressNotification()
fun cancelTerminalNotification()
fun hasActiveProgressNotification(): Boolean fun hasActiveProgressNotification(): Boolean
} }
@@ -13,7 +13,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
companion object { companion object {
const val CHANNEL_ID = "download_progress_channel" const val CHANNEL_ID = "download_progress_channel"
const val NOTIFICATION_ID = 1 const val NOTIFICATION_ID = 1
const val TERMINAL_NOTIFICATION_ID = 2
} }
private val notificationManager = NotificationManagerCompat.from(context) private val notificationManager = NotificationManagerCompat.from(context)
private var lastNotificationKey: Int = 0 private var lastNotificationKey: Int = 0
@@ -92,7 +91,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(notification)
} }
fun showErrorNotification() { fun showErrorNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
@@ -104,17 +103,13 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(notification)
} }
fun cancelNotification() { fun cancelNotification() {
lastNotificationKey = 0 lastNotificationKey = 0
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
} }
override fun cancelTerminalNotification() {
notificationManager.cancel(TERMINAL_NOTIFICATION_ID)
}
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0 override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
fun showUnifiedProgressNotification( fun showUnifiedProgressNotification(
@@ -166,14 +161,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
// Notification permission denied or security policy blocked // Notification permission denied or security policy blocked
} }
} }
@SuppressLint("MissingPermission")
private fun safeNotifyTerminal(notification: Notification) {
try {
notificationManager.notify(TERMINAL_NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
}
}
fun showImportSuccessNotification() { fun showImportSuccessNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
lastNotificationKey = 0 lastNotificationKey = 0
@@ -184,7 +171,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(notification)
} }
fun createForegroundNotification(): Notification { fun createForegroundNotification(): Notification {
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -102,15 +102,12 @@ data class DictionarySource(
fun validateTemplate(template: String): ValidationResult { fun validateTemplate(template: String): ValidationResult {
val trimmed = template.trim() val trimmed = template.trim()
val lowerTemplate = trimmed.lowercase()
return when { return when {
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY) trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG) trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") -> !trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
ValidationResult.Error(ValidationError.INVALID_PROTOCOL) ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
!lowerTemplate.endsWith(".gz") && !trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
!lowerTemplate.endsWith(".dsl") &&
!lowerTemplate.endsWith(".dsl.dz") ->
ValidationResult.Error(ValidationError.INVALID_EXTENSION) ValidationResult.Error(ValidationError.INVALID_EXTENSION)
!isValidUrlStructure(trimmed) -> !isValidUrlStructure(trimmed) ->
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE) ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
@@ -135,15 +132,8 @@ data class DictionarySource(
val parts = afterProtocol.split("/") val parts = afterProtocol.split("/")
if (parts.size < 2) return false if (parts.size < 2) return false
val hostWithPort = parts[0] val domain = parts[0]
if (hostWithPort.isEmpty()) return false if (domain.isEmpty()) return false
val colonIndex = hostWithPort.lastIndexOf(':')
if (colonIndex >= 0) {
val port = hostWithPort.substring(colonIndex + 1).toIntOrNull()
if (port == null || port !in 1..65535) return false
}
val domain = if (colonIndex >= 0) hostWithPort.substring(0, colonIndex) else hostWithPort
if (domain != "localhost" && !domain.contains(".")) return false if (domain != "localhost" && !domain.contains(".")) return false
val filename = parts.last() val filename = parts.last()
@@ -182,7 +182,7 @@ class LocalDictionaryRepository(
progressTracker = ProgressTracker(filesToScan) progressTracker = ProgressTracker(filesToScan)
existingDictionaries.forEach { dict -> existingDictionaries.forEach { dict ->
progressTracker?.updateFileProgress(File(dict.path).name, 1.0f) progressTracker?.updateFileProgress(dict.name, 1.0f)
} }
mutableIndexingProgress.emit( mutableIndexingProgress.emit(
@@ -64,14 +64,7 @@ class DictionaryChecker(
val previousDate = DateUtils.getPreviousDateString() val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files) val dictFiles = getDictFiles(folderUri, files)
val datedSources = enabledSources.filter { source -> val allHaveToday = enabledSources.all { source ->
DictionarySource.hasDatePlaceholder(source.urlTemplate)
}
if (datedSources.isEmpty()) {
return@withContext true
}
val allHaveToday = datedSources.all { source ->
dictFiles.hasFileForSource(source, currentDate) dictFiles.hasFileForSource(source, currentDate)
} }
@@ -79,28 +72,19 @@ class DictionaryChecker(
return@withContext true return@withContext true
} }
val allHaveYesterday = datedSources.all { source -> val allHaveYesterday = enabledSources.all { source ->
dictFiles.hasFileForSource(source, previousDate) dictFiles.hasFileForSource(source, previousDate)
} }
if (allHaveYesterday) { if (allHaveYesterday) {
val serverHasToday = datedSources.any { source -> val serverHasToday = enabledSources.any { source ->
isSourceAvailableOnServer(source, currentDate) isSourceAvailableOnServer(source, currentDate)
} }
return@withContext !serverHasToday return@withContext !serverHasToday
} }
val serverCanOfferNewer = datedSources.any { source -> false
when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
}
}
return@withContext !serverCanOfferNewer
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e) Log.e(TAG, "Error checking currency: ${e.message}", e)
false false
@@ -88,12 +88,6 @@ class DictionaryForegroundService : Service() {
stopForTimeout(startId) stopForTimeout(startId)
} }
override fun onTaskRemoved(rootIntent: Intent?) {
cancelAllWork()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onTimeout(startId: Int, fgsType: Int) { override fun onTimeout(startId: Int, fgsType: Int) {
stopForTimeout(startId) stopForTimeout(startId)
} }
@@ -131,7 +125,7 @@ class DictionaryForegroundService : Service() {
startForegroundService() startForegroundService()
} }
ACTION_FINISH -> { ACTION_FINISH -> {
stopForegroundService(removeNotification = true) stopForegroundService(removeNotification = false)
} }
ACTION_STOP -> { ACTION_STOP -> {
try { try {
@@ -1,7 +1,6 @@
package com.example.research.ui.article package com.example.research.ui.article
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -12,7 +11,6 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -20,9 +18,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -53,54 +48,6 @@ fun ArticleRoute(
) )
} }
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
private fun mergeDslBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
val merged = ArrayList<DslBlock>(blocks.size / 4)
var i = 0
while (i < blocks.size) {
val builder = AnnotatedString.Builder()
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
var j = i
while (j < blocks.size) {
val block = blocks[j]
repeat(block.indent) { builder.append(' ') }
val paraStart = builder.length
builder.append(block.text)
block.lazyRefs.forEach { ref ->
refs.add(
ref.copy(
startIndex = ref.startIndex + paraStart,
endIndex = ref.endIndex + paraStart
)
)
}
val more = j + 1 < blocks.size &&
(j + 1 - i) < MERGED_BLOCK_MAX_ITEMS &&
builder.length < MERGED_BLOCK_MAX_CHARS
if (more) {
builder.append('\n')
}
j++
if (!more) {
break
}
}
merged.add(
DslBlock(
text = builder.toAnnotatedString(),
indent = 0,
lazyRefs = refs,
continuesPrevious = blocks[i].continuesPrevious
)
)
i = j
}
return merged
}
@Composable @Composable
fun ArticleContent( fun ArticleContent(
articleState: ArticleState?, articleState: ArticleState?,
@@ -116,11 +63,7 @@ fun ArticleContent(
is ArticleState.Success -> { is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}" val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy( val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Proportional,
trim = LineHeightStyle.Trim.Both,
),
) )
val indentStep = 16.dp val indentStep = 16.dp
@@ -180,42 +123,36 @@ fun ArticleContent(
val isHeavyArticle = val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
articleState.dslContent,
baseTextStyle,
darkTheme
) {
val merged =
mergeDslBlocks(articleState.dslContent)
if (darkTheme) {
merged.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
}
} else {
merged
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn( LazyColumn(
state = listState, state = listState,
contentPadding = PaddingValues(top = Spacing.listItemVertical), contentPadding = PaddingValues(top = Spacing.listItemVertical),
modifier = listModifier modifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
.testTag("article_content") .testTag("article_content")
.semantics { .semantics {
testTagsAsResourceId = true testTagsAsResourceId = true
} }
) { ) {
itemsIndexed( itemsIndexed(
items = displayBlocks, items = articleState.dslContent,
key = { index, _ -> key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index" "$articleSaveKey:$index"
}, },
contentType = { _, _ -> "article_block" } contentType = { _, _ -> "article_block" }
) { index, block -> ) { index, block ->
val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(start = Spacing.contentStart + indentStep * block.indent) .padding(
start = Spacing.contentStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) { ) {
ArticleLinkText( ArticleLinkText(
block = block, block = block,
@@ -232,17 +169,12 @@ fun ArticleContent(
) )
} }
} }
}
}
val articleListModifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
if (isHeavyArticle) { if (isHeavyArticle) {
articleList(articleListModifier) textContent()
} else { } else {
SelectionContainer { articleList(articleListModifier) } SelectionContainer { textContent() }
}
}
} }
} }
is ArticleState.Error -> { is ArticleState.Error -> {
@@ -275,9 +207,7 @@ fun ArticleTitleBar(
articleState: ArticleState?, articleState: ArticleState?,
showBackButton: Boolean, showBackButton: Boolean,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier
compact: Boolean = false,
selectionReset: Int = 0,
) { ) {
val title = when (articleState) { val title = when (articleState) {
is ArticleState.Loading -> articleState.title is ArticleState.Loading -> articleState.title
@@ -296,14 +226,9 @@ fun ArticleTitleBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing) .windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.then( .padding(
if (compact) { top = Spacing.listItemVertical,
Modifier.padding(vertical = Spacing.listItemVertical) bottom = Spacing.listItemVertical
} else {
Modifier
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
.height(Spacing.topBarHeight)
}
), ),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -323,8 +248,6 @@ fun ArticleTitleBar(
} }
} }
key(selectionReset) {
SelectionContainer {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.dictionaryTitleLarge, style = MaterialTheme.typography.dictionaryTitleLarge,
@@ -338,8 +261,6 @@ fun ArticleTitleBar(
) )
} }
} }
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -350,20 +271,12 @@ internal fun ArticleScreen(
onRefClick: ((String) -> Unit)? = null, onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null, onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) { ) {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Box(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
) {
Scaffold( Scaffold(
topBar = { topBar = {
ArticleTitleBar( ArticleTitleBar(
articleState = articleState, articleState = articleState,
showBackButton = true, showBackButton = true,
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack
selectionReset = titleSelectionReset.intValue
) )
}, },
contentWindowInsets = AppWindowInsets.contentWithNavigation contentWindowInsets = AppWindowInsets.contentWithNavigation
@@ -376,5 +289,4 @@ internal fun ArticleScreen(
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)
) )
} }
}
} }
@@ -70,6 +70,11 @@ object DslAnnotatedParser {
val endIndex: Int val endIndex: Int
) )
// Cache for applyLazyRefs results to avoid recreating AnnotatedString with links
// Size: 32 blocks - covers typical visible + prefetch range in LazyColumn
// Memory estimate: ~20KB per block average (AnnotatedString with LinkAnnotations)
// Total: ~640KB worst case
// LRU eviction automatic via LruCache
private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) { private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) {
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1 override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
} }
@@ -85,7 +90,7 @@ object DslAnnotatedParser {
val lazyRefs = mutableListOf<LazyRef>() val lazyRefs = mutableListOf<LazyRef>()
var accentNext = false var accentNext = false
var currentIndent = 0 var currentIndent = 0
var lastAppended: Char? = null var mClosedPendingBreak = false
val exampleStyle = SpanStyle(color = colorScheme.secondaryText) val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText) val labelStyle = SpanStyle(color = colorScheme.labelText)
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline) val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
@@ -103,40 +108,32 @@ object DslAnnotatedParser {
} }
fun flushBlock() { fun flushBlock() {
val text = builder.toAnnotatedString() if (builder.length > 0) {
if (text.any { !it.isWhitespace() }) {
result.add(DslBlock( result.add(DslBlock(
text = text, text = builder.toAnnotatedString(),
indent = currentIndent, indent = currentIndent,
lazyRefs = lazyRefs.toList() lazyRefs = lazyRefs.toList()
)) ))
}
builder = AnnotatedString.Builder() builder = AnnotatedString.Builder()
stack.forEach { builder.pushStyle(it) } stack.forEach { builder.pushStyle(it) }
lazyRefs.clear() lazyRefs.clear()
lastAppended = null }
mClosedPendingBreak = false
} }
fun appendChar(char: Char, hard: Boolean = false) { fun appendChar(char: Char) {
if (char == ' ' && !hard) { if (mClosedPendingBreak) {
if (lastAppended == null || lastAppended == ' ') return mClosedPendingBreak = false
if (refStack.isEmpty() && !char.isWhitespace()) {
flushBlock()
}
} }
lastAppended = char
builder.append(char) builder.append(char)
if (refStack.isNotEmpty()) { if (refStack.isNotEmpty()) {
refStack.last().second.append(char) refStack.last().second.append(char)
} }
} }
fun appendText(value: String) {
if (value.isEmpty()) return
builder.append(value)
if (refStack.isNotEmpty()) {
refStack.last().second.append(value)
}
lastAppended = value.last()
}
fun marginIndent(tagName: String): Int { fun marginIndent(tagName: String): Int {
if (tagName == "m" || tagName == "m0" || tagName == "m1") return 0 if (tagName == "m" || tagName == "m0" || tagName == "m1") return 0
val margin = tagName.drop(1).toIntOrNull() ?: return 0 val margin = tagName.drop(1).toIntOrNull() ?: return 0
@@ -150,7 +147,7 @@ object DslAnnotatedParser {
when (val char = dsl[i]) { when (val char = dsl[i]) {
'\\' -> { '\\' -> {
if (i + 1 < length) { if (i + 1 < length) {
appendChar(dsl[i + 1], hard = true) appendChar(dsl[i + 1])
i += 2 i += 2
continue continue
} }
@@ -190,11 +187,10 @@ object DslAnnotatedParser {
} else if (tagName == "'") { } else if (tagName == "'") {
accentNext = false accentNext = false
} }
pop()
if (tagName.startsWith("m")) { if (tagName.startsWith("m")) {
flushBlock() mClosedPendingBreak = true
currentIndent = 0
} }
pop()
} else { } else {
when (tagName) { when (tagName) {
"'" -> { "'" -> {
@@ -209,12 +205,12 @@ object DslAnnotatedParser {
} }
} }
"p" -> { "p" -> {
push(labelStyle.copy(fontStyle = FontStyle.Italic)) push(labelStyle)
} }
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold)) "b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> { "m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
if (lastAppended != null && !lastAppended!!.isWhitespace()) { if (builder.length > 0) {
flushBlock() if (refStack.isEmpty()) flushBlock() else builder.append("\n")
} }
currentIndent = marginIndent(tagName) currentIndent = marginIndent(tagName)
push(exampleStyle) push(exampleStyle)
@@ -247,18 +243,6 @@ object DslAnnotatedParser {
appendChar(char) appendChar(char)
i++ i++
} }
'\n' -> {
if (refStack.isEmpty()) {
flushBlock()
} else {
appendChar(char)
}
i++
}
'\t' -> {
appendText("\u2002")
i++
}
else -> { else -> {
appendChar(char) appendChar(char)
@@ -411,35 +395,6 @@ object DslAnnotatedParser {
linkText = Color(0xFF1E88E5), linkText = Color(0xFF1E88E5),
) )
private val DARK_THEME_COLORS: Map<Color, Color> = mapOf(
Color(0xFF000000) to Color(0xFFE8E6E3),
Color(0xFF000080) to Color(0xFF8FA8E0),
Color(0xFF00008B) to Color(0xFF8FA8E0),
Color(0xFF0000FF) to Color(0xFF7C9CE8),
Color(0xFF006400) to Color(0xFF66BB6A),
Color(0xFF008000) to Color(0xFF66BB6A),
Color(0xFF8B0000) to Color(0xFFE57373),
Color(0xFF800000) to Color(0xFFE57373),
Color(0xFF800080) to Color(0xFFBA68C8),
Color(0xFFA52A2A) to Color(0xFFBCAAA4),
Color(0xFF008080) to Color(0xFF4DB6AC),
Color(0xFF808000) to Color(0xFFC0CA33),
)
fun remapForDarkTheme(text: AnnotatedString): AnnotatedString {
if (text.spanStyles.none { it.item.color in DARK_THEME_COLORS }) return text
val builder = AnnotatedString.Builder(text.text)
text.spanStyles.forEach { range ->
val style = DARK_THEME_COLORS[range.item.color]
?.let { range.item.copy(color = it) } ?: range.item
builder.addStyle(style, range.start, range.end)
}
text.paragraphStyles.forEach { range ->
builder.addStyle(range.item, range.start, range.end)
}
return builder.toAnnotatedString()
}
// Allow external cache clearing (e.g., when dictionaries change) // Allow external cache clearing (e.g., when dictionaries change)
fun clearRefCache() { fun clearRefCache() {
refCache.evictAll() refCache.evictAll()
@@ -1,7 +1,5 @@
package com.example.research.ui.main package com.example.research.ui.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -92,22 +90,15 @@ fun AdaptiveMainScreen(
) )
}, },
detailPane = { detailPane = {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
) { ) {
ArticleTitleBar( ArticleTitleBar(
articleState = articleState, articleState = articleState,
showBackButton = isShowingDetail && articleState != null, showBackButton = isShowingDetail && articleState != null,
onNavigateBack = onNavigateBackFromArticle, onNavigateBack = onNavigateBackFromArticle,
compact = true,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding( modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top) WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
) )
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -51,7 +50,6 @@ internal fun SearchResultsPane(
var restoredScrollPosition by remember { mutableStateOf(false) } var restoredScrollPosition by remember { mutableStateOf(false) }
var pendingScrollToTop by remember { mutableStateOf(false) } var pendingScrollToTop by remember { mutableStateOf(false) }
var sawRefreshForPendingScroll by remember { mutableStateOf(false) } var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
var staleEntries by remember { mutableStateOf(emptyList<IndexEntry>()) }
val imeBottomPadding = with(LocalDensity.current) { val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp() WindowInsets.ime.getBottom(this).toDp()
} }
@@ -74,21 +72,8 @@ internal fun SearchResultsPane(
restoredScrollPosition = true restoredScrollPosition = true
pendingScrollToTop = query.isNotBlank() pendingScrollToTop = query.isNotBlank()
sawRefreshForPendingScroll = false sawRefreshForPendingScroll = false
if (query.isBlank()) {
staleEntries = emptyList()
} }
} }
}
val loaded = results.itemSnapshotList.items.filterNotNull()
SideEffect {
if (results.loadState.refresh is LoadState.NotLoading && loaded.isNotEmpty()) {
staleEntries = loaded
}
}
val pendingReload = results.itemCount == 0 &&
results.loadState.refresh is LoadState.Loading
val shownStale = if (pendingReload && searchState.text.isNotBlank()) staleEntries else emptyList()
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) { LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) { if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
@@ -140,7 +125,6 @@ internal fun SearchResultsPane(
listState = listState, listState = listState,
showDictionaryName = showDictionaryName, showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding), contentPadding = PaddingValues(bottom = imeBottomPadding),
staleEntries = shownStale,
) )
} }
} }
@@ -30,11 +30,8 @@ fun SearchResultsList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
showDictionaryName: Boolean = true, showDictionaryName: Boolean = true,
contentPadding: PaddingValues = PaddingValues(), contentPadding: PaddingValues = PaddingValues()
staleEntries: List<IndexEntry> = emptyList(),
) { ) {
val showStale = staleEntries.isNotEmpty() && results.itemCount == 0
val rowCount = if (showStale) staleEntries.size else results.itemCount
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.testTag("search_results") .testTag("search_results")
@@ -44,18 +41,11 @@ fun SearchResultsList(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items( items(
count = rowCount, count = results.itemCount,
key = { index -> key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
val entry = if (showStale) staleEntries.getOrNull(index) contentType = results.itemContentType { "search_result" }
else if (index in 0 until results.itemCount) results.peek(index) else null
entry?.let { "${it.dictionaryPath}:${it.offset.value}:${it.word}" }
?: "search_result_$index"
},
contentType = { "search_result" }
) { index -> ) { index ->
val entry = if (showStale) staleEntries.getOrNull(index) results[index]?.let { entry ->
else if (index in 0 until results.itemCount) results.peek(index) else null
if (entry != null) {
Box( Box(
modifier = Modifier.animateItem( modifier = Modifier.animateItem(
fadeInSpec = if (index < 8) tween(150) else null, fadeInSpec = if (index < 8) tween(150) else null,
@@ -1,12 +1,7 @@
package com.example.research.ui.settings.components package com.example.research.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -120,11 +115,7 @@ fun DictionaryManagement(
testTag = "dictionary_header" testTag = "dictionary_header"
) )
AnimatedVisibility( if (isExpanded) {
visible = isExpanded,
enter = expandVertically(animationSpec = tween(250)) + fadeIn(tween(250)),
exit = shrinkVertically(animationSpec = tween(200)) + fadeOut(tween(200)),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
+1 -2
View File
@@ -47,7 +47,6 @@
<string name="notification_download_error_title">Ошибка загрузки</string> <string name="notification_download_error_title">Ошибка загрузки</string>
<string name="notification_download_error_text">Не удалось скачать словари</string> <string name="notification_download_error_text">Не удалось скачать словари</string>
<string name="notification_indexing_title">Индексация словарей</string> <string name="notification_indexing_title">Индексация словарей</string>
<string name="notification_preparing_indexing_title">Подготовка словарей к индексации</string>
<string name="archive_failed_open_source_file">Не удалось открыть исходный файл</string> <string name="archive_failed_open_source_file">Не удалось открыть исходный файл</string>
<string name="archive_failed_create_output_file">Не удалось создать выходной файл</string> <string name="archive_failed_create_output_file">Не удалось создать выходной файл</string>
<string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string> <string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string>
@@ -77,7 +76,7 @@
<string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string> <string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string>
<string name="dictionary_source_url_empty">URL не может быть пустым</string> <string name="dictionary_source_url_empty">URL не может быть пустым</string>
<string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string> <string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string>
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dsl.dz</string> <string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dz</string>
<string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string> <string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string>
<string name="dictionary_source_invalid_url">Неверный формат URL</string> <string name="dictionary_source_invalid_url">Неверный формат URL</string>
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string> <string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
+1 -2
View File
@@ -47,7 +47,6 @@
<string name="notification_download_error_title">Download error</string> <string name="notification_download_error_title">Download error</string>
<string name="notification_download_error_text">Failed to download dictionaries</string> <string name="notification_download_error_text">Failed to download dictionaries</string>
<string name="notification_indexing_title">Indexing dictionaries</string> <string name="notification_indexing_title">Indexing dictionaries</string>
<string name="notification_preparing_indexing_title">Preparing dictionaries for indexing</string>
<string name="archive_failed_open_source_file">Failed to open source file</string> <string name="archive_failed_open_source_file">Failed to open source file</string>
<string name="archive_failed_create_output_file">Failed to create output file</string> <string name="archive_failed_create_output_file">Failed to create output file</string>
<string name="archive_failed_delete_file">Failed to delete file: %1$s</string> <string name="archive_failed_delete_file">Failed to delete file: %1$s</string>
@@ -75,7 +74,7 @@
<string name="dictionary_source_duplicate_prefix">This dictionary already exists</string> <string name="dictionary_source_duplicate_prefix">This dictionary already exists</string>
<string name="dictionary_source_url_empty">URL cannot be empty</string> <string name="dictionary_source_url_empty">URL cannot be empty</string>
<string name="dictionary_source_invalid_protocol">Must start with http:// or https://</string> <string name="dictionary_source_invalid_protocol">Must start with http:// or https://</string>
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.dz</string> <string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dz</string>
<string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string> <string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string>
<string name="dictionary_source_invalid_url">Invalid URL format</string> <string name="dictionary_source_invalid_url">Invalid URL format</string>
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string> <string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
@@ -1,16 +1,7 @@
- The app starts faster - The app starts faster
- Smooth screen transition animations - Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout
- The article title can be copied, tapping the article clears the selection
- Source URLs accept host:port addresses
- Dictionary downloads retry automatically when the network switches - Dictionary downloads retry automatically when the network switches
- Download progress notifications are more reliable - Download progress notifications are more reliable
- Search results clear instantly when the query is erased - Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically - Leftover files from cancelled downloads are now removed automatically
- Improved app stability
- Faster dictionary indexing
- Updated build tooling and documentation - Updated build tooling and documentation
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
@@ -1,16 +1,7 @@
- Приложение запускается быстрее - Приложение запускается быстрее
- Плавные анимации переходов между экранами - Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
- Адреса источников принимают формат host:port
- Загрузка словарей автоматически повторяется при переключении сети - Загрузка словарей автоматически повторяется при переключении сети
- Уведомления о прогрессе загрузки стали надёжнее - Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса - Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически - Остаточные файлы после отменённых загрузок удаляются автоматически
- Повышена стабильность приложения
- Ускорена индексация словарей
- Обновлены тулинг сборки и документация - Обновлены тулинг сборки и документация
- Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме
+3 -3
View File
@@ -1,12 +1,12 @@
[versions] [versions]
aboutlibraries = "15.2.0" aboutlibraries = "15.2.0"
activity_compose = "1.13.0" activity_compose = "1.13.0"
agp = "9.4.1" agp = "9.3.1"
compose_bom = "2026.09.00" compose_bom = "2026.09.00"
core = "1.19.1" 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.20" kotlin = "2.4.10"
lifecycle_runtime_ktx = "2.11.0" lifecycle_runtime_ktx = "2.11.0"
okhttp = "5.5.0" okhttp = "5.5.0"
paging = "3.5.1" paging = "3.5.1"