5 Commits
Author SHA1 Message Date
OneWay c7c080939c Release 1.6.0
- The app starts faster
- 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
- Download progress notifications are more reliable
- Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically
- Improved app stability
- Faster dictionary indexing
- 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
2026-09-24 13:00:00 +08:00
OneWay 2c8668dab3 build: update core to 1.19.1 2026-09-24 12:00:00 +08:00
OneWay e4e50db08e fix(ui): clear article title selection on any tap
The title SelectionContainer covered only the text, so a tap on the empty bar area or the article body never cleared the highlight. A tap anywhere on the article screen now recreates the container and drops the selection.
2026-09-24 10:00:00 +08:00
OneWay 98ec38f272 fix(ui): remap dark article colors in dark theme
Colors set by dictionaries such as navy, dark red and brown are nearly invisible on the dark background. Swap the dark named colors for lighter tones when rendering in the dark theme; the light theme keeps the original colors.
2026-09-24 09:00:00 +08:00
OneWay 6040ca8db5 fix(ui): merge article blocks to fix scroll jank
Link-heavy articles split every line into its own list item, so a
fast fling composed a dozen items in one frame and froze. Merge small
blocks into multi-paragraph items: indent via em space and a uniform
line pitch via LineHeightStyle(Proportional, Trim.Both).
2026-09-23 09:30:00 +08:00
8 changed files with 157 additions and 41 deletions

No files matched your search

+10 -25
View File
@@ -5,7 +5,6 @@
<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>
@@ -23,31 +22,17 @@ 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. - Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
- 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. - Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification.
- Dictionary management with enable, disable, update, and delete actions. - Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
- 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.
@@ -94,16 +79,16 @@ 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.20
- Jetpack Compose (BOM 2026.08.00) - Jetpack Compose (BOM 2026.09.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.4.0 - OkHttp 5.5.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.11.0
- AboutLibraries 15.0.4 metadata generation - AboutLibraries 15.2.0 metadata generation
- Android Gradle Plugin 9.3.1 - Android Gradle Plugin 9.4.1
## Build ## Build
@@ -111,7 +96,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`. English and Russian resources are packaged together. The release APK targets `arm64-v8a`.
## 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 = 8 versionCode = 9
versionName = "1.5.0" versionName = "1.6.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -1,6 +1,7 @@
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
@@ -11,6 +12,7 @@ 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
@@ -18,6 +20,9 @@ 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
@@ -48,6 +53,54 @@ 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?,
@@ -63,7 +116,11 @@ 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
@@ -123,6 +180,22 @@ 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 -> val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -134,22 +207,15 @@ fun ArticleContent(
} }
) { ) {
itemsIndexed( itemsIndexed(
items = articleState.dslContent, items = displayBlocks,
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( .padding(start = Spacing.contentStart + indentStep * block.indent)
start = Spacing.contentStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) { ) {
ArticleLinkText( ArticleLinkText(
block = block, block = block,
@@ -166,8 +232,6 @@ fun ArticleContent(
) )
} }
} }
textContent()
}
} }
} }
val articleListModifier = modifier val articleListModifier = modifier
@@ -213,6 +277,7 @@ fun ArticleTitleBar(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
compact: Boolean = false, compact: Boolean = false,
selectionReset: Int = 0,
) { ) {
val title = when (articleState) { val title = when (articleState) {
is ArticleState.Loading -> articleState.title is ArticleState.Loading -> articleState.title
@@ -258,6 +323,7 @@ fun ArticleTitleBar(
} }
} }
key(selectionReset) {
SelectionContainer { SelectionContainer {
Text( Text(
text = title, text = title,
@@ -273,6 +339,7 @@ fun ArticleTitleBar(
} }
} }
} }
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -283,12 +350,20 @@ 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
@@ -301,4 +376,5 @@ internal fun ArticleScreen(
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)
) )
} }
}
} }
@@ -411,6 +411,35 @@ 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,5 +1,7 @@
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
@@ -90,9 +92,14 @@ 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(
@@ -100,6 +107,7 @@ fun AdaptiveMainScreen(
showBackButton = isShowingDetail && articleState != null, showBackButton = isShowingDetail && articleState != null,
onNavigateBack = onNavigateBackFromArticle, onNavigateBack = onNavigateBackFromArticle,
compact = true, compact = true,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding( modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top) WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
) )
@@ -1,7 +1,16 @@
- 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,7 +1,16 @@
- Приложение запускается быстрее - Приложение запускается быстрее
- Плавные анимации переходов между экранами - Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
- Адреса источников принимают формат host:port
- Загрузка словарей автоматически повторяется при переключении сети - Загрузка словарей автоматически повторяется при переключении сети
- Уведомления о прогрессе загрузки стали надёжнее - Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса - Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически - Остаточные файлы после отменённых загрузок удаляются автоматически
- Повышена стабильность приложения
- Ускорена индексация словарей
- Обновлены тулинг сборки и документация - Обновлены тулинг сборки и документация
- Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме
+1 -1
View File
@@ -3,7 +3,7 @@ aboutlibraries = "15.2.0"
activity_compose = "1.13.0" activity_compose = "1.13.0"
agp = "9.4.1" agp = "9.4.1"
compose_bom = "2026.09.00" compose_bom = "2026.09.00"
core = "1.19.0" core = "1.19.1"
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.20"