Compare commits
2
Commits
1.6.0
..
05b80f21f4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05b80f21f4 | ||
|
|
114eb78bc2 |
No files matched your search
@@ -5,6 +5,7 @@
|
||||
<p align="center">
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#architecture">Architecture</a> •
|
||||
<a href="#dictionary-pipeline">Pipeline</a> •
|
||||
<a href="#build">Build</a>
|
||||
</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.
|
||||
- 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.
|
||||
- Faithful article rendering with DSL indentation, margins, and named colors.
|
||||
- Offline search after dictionaries are indexed.
|
||||
- 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.
|
||||
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
|
||||
- Background processing with progress shown in Settings and notifications.
|
||||
- Dictionary management with enable, disable, update, and delete actions.
|
||||
- Material 3 interface with dynamic color, light, dark, and system themes.
|
||||
- English and Russian localization.
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- Kotlin 2.4.20
|
||||
- Jetpack Compose (BOM 2026.09.00)
|
||||
- Kotlin 2.4.10
|
||||
- Jetpack Compose (BOM 2026.08.00)
|
||||
- Material 3
|
||||
- Coroutines and Flow 1.11.0
|
||||
- DataStore Preferences 1.2.1
|
||||
- Paging 3.5.1
|
||||
- OkHttp 5.5.0
|
||||
- OkHttp 5.4.0
|
||||
- kotlinx.serialization 1.11.0
|
||||
- AboutLibraries 15.2.0 metadata generation
|
||||
- Android Gradle Plugin 9.4.1
|
||||
- AboutLibraries 15.0.4 metadata generation
|
||||
- Android Gradle Plugin 9.3.1
|
||||
|
||||
## Build
|
||||
|
||||
@@ -96,7 +111,7 @@ The main search screen remains usable as long as at least one indexed dictionary
|
||||
./gradlew :app:assembleRelease
|
||||
```
|
||||
|
||||
The release APK targets `arm64-v8a`.
|
||||
The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ android {
|
||||
applicationId = "com.example.research"
|
||||
minSdk = project.property("minSdk").toString().toInt()
|
||||
targetSdk = project.property("targetSdk").toString().toInt()
|
||||
versionCode = 9
|
||||
versionName = "1.6.0"
|
||||
versionCode = 8
|
||||
versionName = "1.5.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -109,7 +109,7 @@ object DictionaryProgressModel {
|
||||
return Snapshot(
|
||||
progress = p,
|
||||
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 {
|
||||
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(
|
||||
progress = p,
|
||||
|
||||
@@ -32,7 +32,6 @@ class DictionaryProgressPresenter(
|
||||
|
||||
fun beginFlow() {
|
||||
terminalActive = false
|
||||
notifications.cancelTerminalNotification()
|
||||
}
|
||||
|
||||
override fun showImportSuccess() {
|
||||
|
||||
@@ -6,6 +6,5 @@ interface NotificationPort {
|
||||
fun showDownloadSuccess()
|
||||
fun showError()
|
||||
fun cancelProgressNotification()
|
||||
fun cancelTerminalNotification()
|
||||
fun hasActiveProgressNotification(): Boolean
|
||||
}
|
||||
@@ -13,7 +13,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "download_progress_channel"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val TERMINAL_NOTIFICATION_ID = 2
|
||||
}
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private var lastNotificationKey: Int = 0
|
||||
@@ -92,7 +91,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
||||
.setContentIntent(mainActivityPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
safeNotifyTerminal(notification)
|
||||
safeNotify(notification)
|
||||
}
|
||||
fun showErrorNotification() {
|
||||
if (!canShowNotification()) return
|
||||
@@ -104,17 +103,13 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
||||
.setContentIntent(mainActivityPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
safeNotifyTerminal(notification)
|
||||
safeNotify(notification)
|
||||
}
|
||||
fun cancelNotification() {
|
||||
lastNotificationKey = 0
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
override fun cancelTerminalNotification() {
|
||||
notificationManager.cancel(TERMINAL_NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
|
||||
|
||||
fun showUnifiedProgressNotification(
|
||||
@@ -166,14 +161,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
||||
// 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() {
|
||||
if (!canShowNotification()) return
|
||||
lastNotificationKey = 0
|
||||
@@ -184,7 +171,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
||||
.setContentIntent(mainActivityPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
safeNotifyTerminal(notification)
|
||||
safeNotify(notification)
|
||||
}
|
||||
fun createForegroundNotification(): Notification {
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
|
||||
@@ -102,15 +102,12 @@ data class DictionarySource(
|
||||
|
||||
fun validateTemplate(template: String): ValidationResult {
|
||||
val trimmed = template.trim()
|
||||
val lowerTemplate = trimmed.lowercase()
|
||||
return when {
|
||||
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
|
||||
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
|
||||
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
|
||||
ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
|
||||
!lowerTemplate.endsWith(".gz") &&
|
||||
!lowerTemplate.endsWith(".dsl") &&
|
||||
!lowerTemplate.endsWith(".dsl.dz") ->
|
||||
!trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
|
||||
ValidationResult.Error(ValidationError.INVALID_EXTENSION)
|
||||
!isValidUrlStructure(trimmed) ->
|
||||
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
|
||||
@@ -135,15 +132,8 @@ data class DictionarySource(
|
||||
val parts = afterProtocol.split("/")
|
||||
if (parts.size < 2) return false
|
||||
|
||||
val hostWithPort = parts[0]
|
||||
if (hostWithPort.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
|
||||
val domain = parts[0]
|
||||
if (domain.isEmpty()) return false
|
||||
if (domain != "localhost" && !domain.contains(".")) return false
|
||||
|
||||
val filename = parts.last()
|
||||
|
||||
@@ -182,7 +182,7 @@ class LocalDictionaryRepository(
|
||||
progressTracker = ProgressTracker(filesToScan)
|
||||
|
||||
existingDictionaries.forEach { dict ->
|
||||
progressTracker?.updateFileProgress(File(dict.path).name, 1.0f)
|
||||
progressTracker?.updateFileProgress(dict.name, 1.0f)
|
||||
}
|
||||
|
||||
mutableIndexingProgress.emit(
|
||||
|
||||
+4
-20
@@ -64,14 +64,7 @@ class DictionaryChecker(
|
||||
val previousDate = DateUtils.getPreviousDateString()
|
||||
val dictFiles = getDictFiles(folderUri, files)
|
||||
|
||||
val datedSources = enabledSources.filter { source ->
|
||||
DictionarySource.hasDatePlaceholder(source.urlTemplate)
|
||||
}
|
||||
if (datedSources.isEmpty()) {
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
val allHaveToday = datedSources.all { source ->
|
||||
val allHaveToday = enabledSources.all { source ->
|
||||
dictFiles.hasFileForSource(source, currentDate)
|
||||
}
|
||||
|
||||
@@ -79,28 +72,19 @@ class DictionaryChecker(
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
val allHaveYesterday = datedSources.all { source ->
|
||||
val allHaveYesterday = enabledSources.all { source ->
|
||||
dictFiles.hasFileForSource(source, previousDate)
|
||||
}
|
||||
|
||||
if (allHaveYesterday) {
|
||||
val serverHasToday = datedSources.any { source ->
|
||||
val serverHasToday = enabledSources.any { source ->
|
||||
isSourceAvailableOnServer(source, currentDate)
|
||||
}
|
||||
|
||||
return@withContext !serverHasToday
|
||||
}
|
||||
|
||||
val serverCanOfferNewer = datedSources.any { source ->
|
||||
when (probeSource(source, currentDate)) {
|
||||
SourceAvailability.AVAILABLE -> true
|
||||
SourceAvailability.NOT_FOUND ->
|
||||
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
|
||||
SourceAvailability.UNKNOWN -> false
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext !serverCanOfferNewer
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
||||
false
|
||||
|
||||
+1
-7
@@ -88,12 +88,6 @@ class DictionaryForegroundService : Service() {
|
||||
stopForTimeout(startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
cancelAllWork()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
stopForTimeout(startId)
|
||||
}
|
||||
@@ -131,7 +125,7 @@ class DictionaryForegroundService : Service() {
|
||||
startForegroundService()
|
||||
}
|
||||
ACTION_FINISH -> {
|
||||
stopForegroundService(removeNotification = true)
|
||||
stopForegroundService(removeNotification = false)
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.example.research.ui.article
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -12,7 +11,6 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.testTagsAsResourceId
|
||||
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.unit.IntSize
|
||||
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
|
||||
fun ArticleContent(
|
||||
articleState: ArticleState?,
|
||||
@@ -116,11 +63,7 @@ fun ArticleContent(
|
||||
is ArticleState.Success -> {
|
||||
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
|
||||
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
lineHeightStyle = LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Proportional,
|
||||
trim = LineHeightStyle.Trim.Both,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
val indentStep = 16.dp
|
||||
|
||||
@@ -180,42 +123,36 @@ fun ArticleContent(
|
||||
val isHeavyArticle =
|
||||
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))
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = Spacing.listItemVertical),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { viewportSize = it }
|
||||
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
||||
.padding(end = 16.dp)
|
||||
.testTag("article_content")
|
||||
.semantics {
|
||||
testTagsAsResourceId = true
|
||||
}
|
||||
} else {
|
||||
merged
|
||||
}
|
||||
}
|
||||
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = Spacing.listItemVertical),
|
||||
modifier = listModifier
|
||||
.testTag("article_content")
|
||||
.semantics {
|
||||
testTagsAsResourceId = true
|
||||
}
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = displayBlocks,
|
||||
key = { index, _ ->
|
||||
"$articleSaveKey:$index"
|
||||
},
|
||||
contentType = { _, _ -> "article_block" }
|
||||
) { index, block ->
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = articleState.dslContent,
|
||||
key = { index, _ ->
|
||||
// Use stable key combining article save key and index
|
||||
"$articleSaveKey:$index"
|
||||
},
|
||||
contentType = { _, _ -> "article_block" }
|
||||
) { index, block ->
|
||||
val followedByContinuation =
|
||||
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
|
||||
val textContent = @Composable {
|
||||
Box(
|
||||
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(
|
||||
block = block,
|
||||
@@ -232,18 +169,13 @@ fun ArticleContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isHeavyArticle) {
|
||||
textContent()
|
||||
} else {
|
||||
SelectionContainer { textContent() }
|
||||
}
|
||||
}
|
||||
}
|
||||
val articleListModifier = modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { viewportSize = it }
|
||||
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
||||
.padding(end = 16.dp)
|
||||
if (isHeavyArticle) {
|
||||
articleList(articleListModifier)
|
||||
} else {
|
||||
SelectionContainer { articleList(articleListModifier) }
|
||||
}
|
||||
}
|
||||
is ArticleState.Error -> {
|
||||
Column(
|
||||
@@ -275,9 +207,7 @@ fun ArticleTitleBar(
|
||||
articleState: ArticleState?,
|
||||
showBackButton: Boolean,
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = false,
|
||||
selectionReset: Int = 0,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val title = when (articleState) {
|
||||
is ArticleState.Loading -> articleState.title
|
||||
@@ -296,14 +226,9 @@ fun ArticleTitleBar(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
|
||||
.then(
|
||||
if (compact) {
|
||||
Modifier.padding(vertical = Spacing.listItemVertical)
|
||||
} else {
|
||||
Modifier
|
||||
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
|
||||
.height(Spacing.topBarHeight)
|
||||
}
|
||||
.padding(
|
||||
top = Spacing.listItemVertical,
|
||||
bottom = Spacing.listItemVertical
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -323,21 +248,17 @@ fun ArticleTitleBar(
|
||||
}
|
||||
}
|
||||
|
||||
key(selectionReset) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.dictionaryTitleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(start = if (showBackButton) Spacing.searchTextStart - Spacing.iconStart - 24.dp else 16.dp)
|
||||
.testTag("article_title")
|
||||
.semantics { testTagsAsResourceId = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.dictionaryTitleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(start = if (showBackButton) Spacing.searchTextStart - Spacing.iconStart - 24.dp else 16.dp)
|
||||
.testTag("article_title")
|
||||
.semantics { testTagsAsResourceId = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,31 +271,22 @@ internal fun ArticleScreen(
|
||||
onRefClick: ((String) -> Unit)? = null,
|
||||
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
|
||||
) {
|
||||
val titleSelectionReset = remember { mutableIntStateOf(0) }
|
||||
Box(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { titleSelectionReset.intValue++ }
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ArticleTitleBar(
|
||||
articleState = articleState,
|
||||
showBackButton = true,
|
||||
onNavigateBack = onNavigateBack,
|
||||
selectionReset = titleSelectionReset.intValue
|
||||
)
|
||||
},
|
||||
contentWindowInsets = AppWindowInsets.contentWithNavigation
|
||||
) { padding ->
|
||||
ArticleContent(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ArticleTitleBar(
|
||||
articleState = articleState,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onRefClick = onRefClick,
|
||||
onScrollPositionChanged = onScrollPositionChanged,
|
||||
modifier = Modifier.padding(padding)
|
||||
showBackButton = true,
|
||||
onNavigateBack = onNavigateBack
|
||||
)
|
||||
}
|
||||
},
|
||||
contentWindowInsets = AppWindowInsets.contentWithNavigation
|
||||
) { padding ->
|
||||
ArticleContent(
|
||||
articleState = articleState,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onRefClick = onRefClick,
|
||||
onScrollPositionChanged = onScrollPositionChanged,
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,11 @@ object DslAnnotatedParser {
|
||||
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) {
|
||||
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
|
||||
}
|
||||
@@ -85,7 +90,7 @@ object DslAnnotatedParser {
|
||||
val lazyRefs = mutableListOf<LazyRef>()
|
||||
var accentNext = false
|
||||
var currentIndent = 0
|
||||
var lastAppended: Char? = null
|
||||
var mClosedPendingBreak = false
|
||||
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
|
||||
val labelStyle = SpanStyle(color = colorScheme.labelText)
|
||||
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
|
||||
@@ -103,40 +108,32 @@ object DslAnnotatedParser {
|
||||
}
|
||||
|
||||
fun flushBlock() {
|
||||
val text = builder.toAnnotatedString()
|
||||
if (text.any { !it.isWhitespace() }) {
|
||||
if (builder.length > 0) {
|
||||
result.add(DslBlock(
|
||||
text = text,
|
||||
text = builder.toAnnotatedString(),
|
||||
indent = currentIndent,
|
||||
lazyRefs = lazyRefs.toList()
|
||||
))
|
||||
builder = AnnotatedString.Builder()
|
||||
stack.forEach { builder.pushStyle(it) }
|
||||
lazyRefs.clear()
|
||||
}
|
||||
builder = AnnotatedString.Builder()
|
||||
stack.forEach { builder.pushStyle(it) }
|
||||
lazyRefs.clear()
|
||||
lastAppended = null
|
||||
mClosedPendingBreak = false
|
||||
}
|
||||
|
||||
fun appendChar(char: Char, hard: Boolean = false) {
|
||||
if (char == ' ' && !hard) {
|
||||
if (lastAppended == null || lastAppended == ' ') return
|
||||
fun appendChar(char: Char) {
|
||||
if (mClosedPendingBreak) {
|
||||
mClosedPendingBreak = false
|
||||
if (refStack.isEmpty() && !char.isWhitespace()) {
|
||||
flushBlock()
|
||||
}
|
||||
}
|
||||
lastAppended = char
|
||||
builder.append(char)
|
||||
if (refStack.isNotEmpty()) {
|
||||
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 {
|
||||
if (tagName == "m" || tagName == "m0" || tagName == "m1") return 0
|
||||
val margin = tagName.drop(1).toIntOrNull() ?: return 0
|
||||
@@ -150,7 +147,7 @@ object DslAnnotatedParser {
|
||||
when (val char = dsl[i]) {
|
||||
'\\' -> {
|
||||
if (i + 1 < length) {
|
||||
appendChar(dsl[i + 1], hard = true)
|
||||
appendChar(dsl[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
@@ -190,11 +187,10 @@ object DslAnnotatedParser {
|
||||
} else if (tagName == "'") {
|
||||
accentNext = false
|
||||
}
|
||||
pop()
|
||||
if (tagName.startsWith("m")) {
|
||||
flushBlock()
|
||||
currentIndent = 0
|
||||
mClosedPendingBreak = true
|
||||
}
|
||||
pop()
|
||||
} else {
|
||||
when (tagName) {
|
||||
"'" -> {
|
||||
@@ -209,12 +205,12 @@ object DslAnnotatedParser {
|
||||
}
|
||||
}
|
||||
"p" -> {
|
||||
push(labelStyle.copy(fontStyle = FontStyle.Italic))
|
||||
push(labelStyle)
|
||||
}
|
||||
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
|
||||
if (lastAppended != null && !lastAppended!!.isWhitespace()) {
|
||||
flushBlock()
|
||||
if (builder.length > 0) {
|
||||
if (refStack.isEmpty()) flushBlock() else builder.append("\n")
|
||||
}
|
||||
currentIndent = marginIndent(tagName)
|
||||
push(exampleStyle)
|
||||
@@ -247,18 +243,6 @@ object DslAnnotatedParser {
|
||||
appendChar(char)
|
||||
i++
|
||||
}
|
||||
'\n' -> {
|
||||
if (refStack.isEmpty()) {
|
||||
flushBlock()
|
||||
} else {
|
||||
appendChar(char)
|
||||
}
|
||||
i++
|
||||
}
|
||||
'\t' -> {
|
||||
appendText("\u2002")
|
||||
i++
|
||||
}
|
||||
else -> {
|
||||
appendChar(char)
|
||||
|
||||
@@ -411,35 +395,6 @@ object DslAnnotatedParser {
|
||||
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)
|
||||
fun clearRefCache() {
|
||||
refCache.evictAll()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -92,22 +90,15 @@ fun AdaptiveMainScreen(
|
||||
)
|
||||
},
|
||||
detailPane = {
|
||||
val titleSelectionReset = remember { mutableIntStateOf(0) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { titleSelectionReset.intValue++ }
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
ArticleTitleBar(
|
||||
articleState = articleState,
|
||||
showBackButton = isShowingDetail && articleState != null,
|
||||
onNavigateBack = onNavigateBackFromArticle,
|
||||
compact = true,
|
||||
selectionReset = titleSelectionReset.intValue,
|
||||
modifier = Modifier.windowInsetsPadding(
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -51,7 +50,6 @@ internal fun SearchResultsPane(
|
||||
var restoredScrollPosition by remember { mutableStateOf(false) }
|
||||
var pendingScrollToTop by remember { mutableStateOf(false) }
|
||||
var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
|
||||
var staleEntries by remember { mutableStateOf(emptyList<IndexEntry>()) }
|
||||
val imeBottomPadding = with(LocalDensity.current) {
|
||||
WindowInsets.ime.getBottom(this).toDp()
|
||||
}
|
||||
@@ -74,22 +72,9 @@ internal fun SearchResultsPane(
|
||||
restoredScrollPosition = true
|
||||
pendingScrollToTop = query.isNotBlank()
|
||||
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) {
|
||||
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
|
||||
sawRefreshForPendingScroll = true
|
||||
@@ -140,7 +125,6 @@ internal fun SearchResultsPane(
|
||||
listState = listState,
|
||||
showDictionaryName = showDictionaryName,
|
||||
contentPadding = PaddingValues(bottom = imeBottomPadding),
|
||||
staleEntries = shownStale,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,8 @@ fun SearchResultsList(
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
showDictionaryName: Boolean = true,
|
||||
contentPadding: PaddingValues = PaddingValues(),
|
||||
staleEntries: List<IndexEntry> = emptyList(),
|
||||
contentPadding: PaddingValues = PaddingValues()
|
||||
) {
|
||||
val showStale = staleEntries.isNotEmpty() && results.itemCount == 0
|
||||
val rowCount = if (showStale) staleEntries.size else results.itemCount
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.testTag("search_results")
|
||||
@@ -44,18 +41,11 @@ fun SearchResultsList(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(
|
||||
count = rowCount,
|
||||
key = { index ->
|
||||
val entry = if (showStale) staleEntries.getOrNull(index)
|
||||
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" }
|
||||
count = results.itemCount,
|
||||
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
|
||||
contentType = results.itemContentType { "search_result" }
|
||||
) { index ->
|
||||
val entry = if (showStale) staleEntries.getOrNull(index)
|
||||
else if (index in 0 until results.itemCount) results.peek(index) else null
|
||||
if (entry != null) {
|
||||
results[index]?.let { entry ->
|
||||
Box(
|
||||
modifier = Modifier.animateItem(
|
||||
fadeInSpec = if (index < 8) tween(150) else null,
|
||||
|
||||
+1
-10
@@ -1,12 +1,7 @@
|
||||
package com.example.research.ui.settings.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -120,11 +115,7 @@ fun DictionaryManagement(
|
||||
testTag = "dictionary_header"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically(animationSpec = tween(250)) + fadeIn(tween(250)),
|
||||
exit = shrinkVertically(animationSpec = tween(200)) + fadeOut(tween(200)),
|
||||
) {
|
||||
if (isExpanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
<string name="notification_download_error_title">Ошибка загрузки</string>
|
||||
<string name="notification_download_error_text">Не удалось скачать словари</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_create_output_file">Не удалось создать выходной файл</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_url_empty">URL не может быть пустым</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_invalid_url">Неверный формат URL</string>
|
||||
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
<string name="notification_download_error_title">Download error</string>
|
||||
<string name="notification_download_error_text">Failed to download 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_create_output_file">Failed to create output file</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_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_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_invalid_url">Invalid URL format</string>
|
||||
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
- 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
|
||||
@@ -1,16 +1,7 @@
|
||||
- Приложение запускается быстрее
|
||||
- Плавные анимации переходов между экранами
|
||||
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
|
||||
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
|
||||
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
|
||||
- Адреса источников принимают формат host:port
|
||||
- Загрузка словарей автоматически повторяется при переключении сети
|
||||
- Уведомления о прогрессе загрузки стали надёжнее
|
||||
- Результаты поиска очищаются мгновенно при стирании запроса
|
||||
- Остаточные файлы после отменённых загрузок удаляются автоматически
|
||||
- Повышена стабильность приложения
|
||||
- Ускорена индексация словарей
|
||||
- Обновлены тулинг сборки и документация
|
||||
- Длинные статьи со ссылками больше не подвисают при прокрутке
|
||||
- Большие списки результатов поиска отображаются быстрее
|
||||
- Тёмные цвета статей читаемы в тёмной теме
|
||||
@@ -1,12 +1,12 @@
|
||||
[versions]
|
||||
aboutlibraries = "15.2.0"
|
||||
activity_compose = "1.13.0"
|
||||
agp = "9.4.1"
|
||||
agp = "9.3.1"
|
||||
compose_bom = "2026.09.00"
|
||||
core = "1.19.1"
|
||||
core = "1.19.0"
|
||||
datastore_preferences = "1.2.1"
|
||||
documentfile = "1.1.0"
|
||||
kotlin = "2.4.20"
|
||||
kotlin = "2.4.10"
|
||||
lifecycle_runtime_ktx = "2.11.0"
|
||||
okhttp = "5.5.0"
|
||||
paging = "3.5.1"
|
||||
|
||||
Reference in new issue
Block a user