46 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
OneWay 71fea4472b fix(ui): read paging snapshot directly
Replace the per-index peek loop with the item snapshot list;
the loop walked the whole result set on every recomposition.
2026-09-23 09:00:00 +08:00
OneWay b5fc7e7246 fix(ui): keep previous search results while reloading
Keep showing the last results while a new search generation loads,
and guard list access: the paging snapshot can shrink mid-layout,
which threw and crashed on an empty result set.
2026-09-22 00:37:48 +08:00
OneWay 447d086cdf fix(dsl): close margin blocks at the closing tag
Blocks stayed open until the next character arrived, so following text
merged into them and trailing escapes dropped. Close each block right
at its tag and append escaped characters unconditionally.
2026-09-21 22:08:45 +08:00
OneWay ac9a22dbe1 fix(dsl): render [p] labels in italics
Style and grammar labels printed upright while sources mark the same
words in different ways, so identical labels rendered inconsistently.

Every [p] block now uses the label color with italic posture, and the
text scan for specific label words is removed.
2026-09-21 21:53:32 +08:00
OneWay cf29f19511 fix(dsl): render tabs as a fixed-width gap
A literal tab advanced text to the next platform tab stop, which pushed
the rest of the line far enough to wrap it mid-phrase.

Replace each tab with an en space so the separator keeps a fixed, modest
width and the line breaks like ordinary text.
2026-09-21 20:11:40 +08:00
OneWay ed1e1db7f2 chore(dsl): drop stale cache comments 2026-09-21 17:43:15 +08:00
OneWay a5b54def51 fix(dsl): keep single spaces after opening tags
Dictionaries use a space right after an opening tag as a visible
separator, and dropping it glued words together.

Drop only space runs at the start of a block and collapse runs to one
space everywhere else; spaces after opening tags are now kept.
2026-09-21 16:35:08 +08:00
OneWay ee19f84f91 fix(ui): align article title with the first search result in two panes
In landscape the detail pane rendered the title inside a fixed 56.dp
bar and centered the text in it, so the headword sat about 14 dp lower
than the first search result next to it. Add a compact variant of the
title bar without the fixed height; the title line now shares the top
offset of the first list row.
2026-09-21 14:39:12 +08:00
OneWay 4e42a269a7 fix(ui): clear text selection on tap anywhere in the article
Every block of the dictionary article was wrapped in its own
SelectionContainer, so a tap in one block never cleared a highlight
held in another. A single container now wraps the article list — a tap
anywhere clears the selection and selection can span adjacent blocks.
2026-09-21 13:25:41 +08:00
OneWay 4caf3a534d fix(ui): make the article title selectable
The title Text sat outside any SelectionContainer, so the headword
could not be copied.
2026-09-21 13:24:41 +08:00
OneWay c0c9211b82 fix(dsl): render each margin on its own line
Each [m] block occupies its own line in the article. Blank paragraphs
and whitespace-only lines are dropped.
2026-09-21 10:27:34 +08:00
OneWay f5880d72ad feat(ui): animate dictionaries section expand and collapse 2026-09-21 09:01:12 +08:00
OneWay 95f4317eaf fix: honest indexing progress and preparing phase label 2026-09-21 09:00:12 +08:00
OneWay df96deb1fc fix: clear stale terminal notification when a new flow starts 2026-09-21 08:59:12 +08:00
OneWay 451267688a fix: clear import progress notification stuck at 100%
The progress notification was detached from the foreground
service on pipeline finish, so a process killed mid-import left an
ongoing notification orphaned in the shade until the next app launch.
Terminal notifications now use their own id, and finishing the
pipeline removes the bound progress notification; swiping the app
from recents cleans it up as well.
2026-09-21 00:05:48 +08:00
OneWay 1d84eaf9c0 fix: reject bare .dz source URLs and make extension validation case-insensitive
A bare .dz file (DictZip without the .dsl prefix) is treated by the
pipeline as an orphaned file and deleted right after the download, so
a .dz source URL passed validation but silently produced no
dictionary. Accept only DictZip-compressed DSL (.dsl.dz; .gz already
covers .dsl.gz) and compare extensions case-insensitively.
2026-09-20 23:14:54 +08:00
OneWay 59115ff9a3 fix: ignore dateless sources when checking dictionary freshness
A source URL without the {DATE} placeholder is date-invariant, so its
HEAD probe always answers 200. Including such sources in the freshness
check made the checker report "server has a newer version" regardless
of the dated sources' state.
2026-09-20 23:11:54 +08:00
OneWay eb918ca09f fix: accept host:port in dictionary source URLs
URL validation rejected any host containing a colon, so sources on
non-standard ports could not be added. Strip the optional :port from
the host before applying the domain checks and validate the port
range (1-65535); the port is kept intact through template
normalization and URL building.
2026-09-20 21:45:07 +08:00
OneWay 638e886498 chore(deps): bump Android Gradle Plugin to 9.4.1 and Kotlin to 2.4.20 2026-09-19 16:21:09 +08:00
OneWay c2b2bf1c8a 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:20:16 +08:00
OneWay 30aa86f39f fix(search): drop the open article when the query is cleared 2026-09-19 15:20:16 +08:00
OneWay 8a506763a5 feat(ui): always use two-pane layout in landscape 2026-09-18 02:15:00 +08:00
OneWay 4ce39d0e4f feat(ui): smooth screen transitions and list entrances 2026-09-18 02:11:00 +08:00
OneWay 058330dd34 fix(ui): align article title bar with first search result 2026-09-18 02:05:00 +08:00
OneWay f39aa2a341 fix(ui): align content start offset between search list and article 2026-09-18 02:02:00 +08:00
OneWay f51684367a fix(dsl): render articles faithfully
Split lines on LF, CRLF and lone CR like the indexer, strip leading whitespace from body lines, break the paragraph after a closing margin tag, and apply named colors from [c] tags.
2026-09-18 01:50:00 +08:00
OneWay bddcb99177 fix: drop "No article selected" flash on back from article
ClearArticle nulls state before exit transition ends, so the outgoing screen briefly rendered the null-branch fallback in the title bar. Render empty title/content for null state; remove article_no_selected.
2026-09-15 23:19:29 +08:00
OneWay 2dadfa11e9 chore: add MIT license
Add the MIT LICENSE file (Copyright (c) 2026 OneWay), link it from the README, and draft the 1.6.0 changelog.
2026-09-15 18:39:00 +08:00
OneWay 2394c7cf5e fix(settings): evaluate update availability on operations and startup
Re-evaluate after indexing completes, persist the last status with its check date, and restore it on startup. When the saved date is older than today the status is re-evaluated once, so new dictionary updates stay visible without a server request on every launch.
2026-09-15 16:55:00 +08:00
OneWay 830f99578a build(release): strip Log warnings/errors from release dex 2026-09-15 14:51:49 +08:00
OneWay 214b9f91e4 perf(about): eliminate all-row recomposition on library expand 2026-09-14 20:04:51 +08:00
OneWay edf6a6074e fix: clean up orphaned and partial files after cancelled downloads
Treat inactive download contexts as cancellation, delete the partial file, and
rethrow the cancellation so the pipeline does not retain an incomplete payload.
Also remove orphaned .dsl/.dz files left behind by cancelled downloads.
Run the partial/orphan deletions on NonCancellable: the cleanup is invoked from
already-cancelled coroutines, where withContext(Dispatchers.IO) aborts before
executing the block and silently leaves the files behind.
2026-09-14 07:50:30 +08:00
OneWay 8319973161 fix: restore boundary progress publishes and animate the bar at constant velocity 2026-09-11 16:37:23 +03:00
OneWay 1631e5384c chore: drop the unused indexing_label string 2026-09-11 16:28:23 +03:00
OneWay 1d4990e5b7 refactor: slim IndexingProgress to a producer-supplied [0,1] progress
perFileProgress was never populated and its progress branch was
unreachable; currentFile, currentIndex, currentFileProgress and label
were write-only (no consumer read them); progressPercent had no
callers; the -1f aggregate sentinel is gone along with the fallback
branches that only fired when the tracker was absent.
2026-09-11 16:28:23 +03:00
OneWay 8c6e43f3db fix: latch terminal notification state and post pipeline progress on one thread
A late ImportState Success snapshot collected after the success
notification overwrote it with an ongoing progress record. Post
progress on Main behind a volatile terminal latch; notifications go
through NotificationPort, the pipeline lives in the coordinator, and
the service finish is injected.
2026-09-06 15:32:35 +08:00
OneWay 2f22144d19 fix: defer import errors until the copy loop finishes
Errors and skips are collected while the copy loop runs and reported
once it ends. The final import state is exhaustive - success, already
added, invalid name, or nothing supported found - so every import run
reaches a terminal state and the pipeline notification always resolves
instead of lingering as an indeterminate progress bar.
2026-09-05 21:55:01 +08:00
OneWay 9807a0cf27 fix: tie back arrow to pipeline idle state
Computed from operation states in SettingsViewModel instead of a fixed
delay; cancel and error release the arrow immediately.
2026-09-05 05:55:56 +08:00
OneWay de27eecd9e fix: key search result rows by dictionary, offset and word
Paging result rows had no keys and were matched by position: appended
pages and query diffs could not reuse row state. Keys derived from
dictionaryPath+offset+word are stable per row, and the paging source
collapses duplicate entries for one dictionary card: case and diacritic
alias variants of a headword share the normalized word, so entries are
deduplicated by dictionaryPath+word+offset before emitting.
2026-09-05 03:00:44 +08:00
OneWay c4b3a57ccb perf(about): parse aboutlibraries.json off the main thread
Bump AboutLibraries plugin to 15.0.4 -> 15.2.0; parse
aboutlibraries.json on Dispatchers.IO via produceState (header renders
immediately, rows fill in); drop animateContentSize from rows.
2026-09-05 00:37:41 +08:00
OneWay d96fad9cab fix: open Settings immediately on cold start when no dictionaries
Seed and synchronously read hadNoDictionaries before setContent so
Settings shows from the first frame until the startup scan confirms.
2026-09-04 19:09:19 +08:00
33 changed files with 782 additions and 232 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OneWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -26
View File
@@ -5,7 +5,6 @@
<p align="center">
<a href="#screenshots">Screenshots</a> &bull;
<a href="#features">Features</a> &bull;
<a href="#architecture">Architecture</a> &bull;
<a href="#dictionary-pipeline">Pipeline</a> &bull;
<a href="#build">Build</a>
</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.
- 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.
- 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.
- Dictionary management with enable, disable, update, and delete actions.
- 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.
- 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.
@@ -94,16 +79,16 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack
- Kotlin 2.4.10
- Jetpack Compose (BOM 2026.08.00)
- Kotlin 2.4.20
- Jetpack Compose (BOM 2026.09.00)
- Material 3
- Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1
- Paging 3.5.1
- OkHttp 5.4.0
- OkHttp 5.5.0
- kotlinx.serialization 1.11.0
- AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.3.1
- AboutLibraries 15.2.0 metadata generation
- Android Gradle Plugin 9.4.1
## Build
@@ -111,8 +96,8 @@ The main search screen remains usable as long as at least one indexed dictionary
./gradlew :app:assembleRelease
```
The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
The release APK targets `arm64-v8a`.
## License
MIT
[MIT](LICENSE)
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 8
versionName = "1.5.0"
versionCode = 9
versionName = "1.6.0"
vectorDrawables {
useSupportLibrary = true
@@ -26,6 +26,7 @@ import com.example.research.common.ui.AppLocaleProvider
import com.example.research.common.ui.BackgroundTextMeasurementProvider
import com.example.research.common.ui.theme.ReSearchTheme
import com.example.research.core.domain.model.AppTheme
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
import com.example.research.ui.navigation.AppNavigation
@@ -51,6 +52,8 @@ class MainActivity : ComponentActivity() {
}
}
private var initialSectionExpansion: SectionExpansionStates? = null
private val settingsViewModel: SettingsViewModel by viewModels {
val app = application as ReSearchApplication
val versionName = try {
@@ -69,6 +72,11 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager,
app.dictionaryProgressStateHolder,
versionName,
initialSectionExpansion ?: SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isDictionariesExpanded = true,
),
)
}
}
@@ -84,6 +92,7 @@ class MainActivity : ComponentActivity() {
val initialTheme = loadInitialTheme(app)
val initialLanguage = loadInitialLanguage(app)
val initialHadNoDictionaries = loadInitialHadNoDictionaries(app)
initialSectionExpansion = loadInitialSectionExpansion(app)
handleIntent(intent)
setContent {
@@ -177,6 +186,21 @@ class MainActivity : ComponentActivity() {
}
}
private fun loadInitialSectionExpansion(app: ReSearchApplication): SectionExpansionStates {
return try {
runBlocking {
app.preferencesManager.sectionExpansionStates()
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to load section expansion states: ${e.message}")
SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isDictionariesExpanded = true,
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@@ -109,7 +109,7 @@ object DictionaryProgressModel {
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_indexing_title,
titleRes = R.string.notification_preparing_indexing_title,
)
}
@@ -120,7 +120,7 @@ object DictionaryProgressModel {
} else {
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT
}
val pct = (p * 100f).toInt().coerceIn(0, 100)
val pct = (p * 100f).toInt().coerceIn(0, 99)
return Snapshot(
progress = p,
@@ -32,6 +32,7 @@ class DictionaryProgressPresenter(
fun beginFlow() {
terminalActive = false
notifications.cancelTerminalNotification()
}
override fun showImportSuccess() {
@@ -6,5 +6,6 @@ interface NotificationPort {
fun showDownloadSuccess()
fun showError()
fun cancelProgressNotification()
fun cancelTerminalNotification()
fun hasActiveProgressNotification(): Boolean
}
@@ -13,6 +13,7 @@ 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
@@ -91,7 +92,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
safeNotifyTerminal(notification)
}
fun showErrorNotification() {
if (!canShowNotification()) return
@@ -103,13 +104,17 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
safeNotifyTerminal(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(
@@ -161,6 +166,14 @@ 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
@@ -171,7 +184,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
safeNotifyTerminal(notification)
}
fun createForegroundNotification(): Notification {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -102,12 +102,15 @@ 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)
!trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
!lowerTemplate.endsWith(".gz") &&
!lowerTemplate.endsWith(".dsl") &&
!lowerTemplate.endsWith(".dsl.dz") ->
ValidationResult.Error(ValidationError.INVALID_EXTENSION)
!isValidUrlStructure(trimmed) ->
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
@@ -132,8 +135,15 @@ data class DictionarySource(
val parts = afterProtocol.split("/")
if (parts.size < 2) return false
val domain = parts[0]
if (domain.isEmpty()) 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
if (domain != "localhost" && !domain.contains(".")) return false
val filename = parts.last()
@@ -10,12 +10,19 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.research.core.domain.model.DictionarySource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
data class SectionExpansionStates(
val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean,
val isDictionariesExpanded: Boolean,
)
class PreferencesManager(private val context: Context) {
private object PreferencesKeys {
val THEME_MODE = stringPreferencesKey("theme_mode")
@@ -26,6 +33,7 @@ class PreferencesManager(private val context: Context) {
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
val DICTIONARY_STATUS = stringPreferencesKey("dictionary_status")
}
private val json = Json { ignoreUnknownKeys = true }
@@ -79,6 +87,24 @@ class PreferencesManager(private val context: Context) {
}
}
suspend fun dictionaryStatus(): String? =
context.dataStore.data.first()[PreferencesKeys.DICTIONARY_STATUS]
suspend fun saveDictionaryStatus(status: String) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.DICTIONARY_STATUS] = status
}
}
suspend fun sectionExpansionStates(): SectionExpansionStates =
context.dataStore.data.first().let { preferences ->
SectionExpansionStates(
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
)
}
suspend fun setDictionaryActive(path: String, isActive: Boolean) {
context.dataStore.edit { preferences ->
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
@@ -180,14 +180,19 @@ internal fun parseDslArticleContent(rawContent: String): String {
while (i < len) {
val lineStart = i
while (i < len && rawContent[i] != '\n') i++
while (i < len && rawContent[i] != '\n' && rawContent[i] != '\r') i++
val lineEnd = i
if (i < len && rawContent[i] == '\n') i++
if (i < len && rawContent[i] == '\r') {
i++
if (i < len && rawContent[i] == '\n') i++
} else if (i < len && rawContent[i] == '\n') {
i++
}
if (lineStart == lineEnd && i >= len) break
val line = rawContent.substring(lineStart, lineEnd)
val trimmed = line.trimEnd()
val trimmed = line.trim()
if (trimmed.isEmpty()) continue
if (skippingHeadwords) {
@@ -182,7 +182,7 @@ class LocalDictionaryRepository(
progressTracker = ProgressTracker(filesToScan)
existingDictionaries.forEach { dict ->
progressTracker?.updateFileProgress(dict.name, 1.0f)
progressTracker?.updateFileProgress(File(dict.path).name, 1.0f)
}
mutableIndexingProgress.emit(
@@ -64,7 +64,14 @@ class DictionaryChecker(
val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files)
val allHaveToday = enabledSources.all { source ->
val datedSources = enabledSources.filter { source ->
DictionarySource.hasDatePlaceholder(source.urlTemplate)
}
if (datedSources.isEmpty()) {
return@withContext true
}
val allHaveToday = datedSources.all { source ->
dictFiles.hasFileForSource(source, currentDate)
}
@@ -72,19 +79,28 @@ class DictionaryChecker(
return@withContext true
}
val allHaveYesterday = enabledSources.all { source ->
val allHaveYesterday = datedSources.all { source ->
dictFiles.hasFileForSource(source, previousDate)
}
if (allHaveYesterday) {
val serverHasToday = enabledSources.any { source ->
val serverHasToday = datedSources.any { source ->
isSourceAvailableOnServer(source, currentDate)
}
return@withContext !serverHasToday
}
false
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
} catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e)
false
@@ -88,6 +88,12 @@ 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)
}
@@ -125,7 +131,7 @@ class DictionaryForegroundService : Service() {
startForegroundService()
}
ACTION_FINISH -> {
stopForegroundService(removeNotification = false)
stopForegroundService(removeNotification = true)
}
ACTION_STOP -> {
try {
@@ -122,6 +122,7 @@ class SearchViewModel(
SearchAction.ClearQuery -> {
mutableSearchQuery.value = ""
savedStateHandle[KEY_SEARCH_QUERY] = ""
clearArticle()
}
is SearchAction.ArticleClicked -> {
articleHistory.clear()
@@ -136,6 +137,9 @@ class SearchViewModel(
fun onSearchQueryChanged(query: String) {
val sanitized = query.sanitizeQuery().trim()
mutableSearchQuery.value = sanitized
if (sanitized.isBlank()) {
clearArticle()
}
// Debounce SavedStateHandle writes to reduce disk I/O
debounceSaveQueryJob?.cancel()
debounceSaveQueryJob = viewModelScope.launch {
@@ -1,18 +1,25 @@
package com.example.research.ui.about
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -24,13 +31,17 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
@@ -44,6 +55,7 @@ import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -66,6 +78,15 @@ fun AboutScreen(
}
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
val onToggleLibrary = remember {
{ uniqueId: String ->
expandedLibraryId = if (expandedLibraryId == uniqueId) {
null
} else {
uniqueId
}
}
}
Scaffold(
topBar = {
@@ -93,24 +114,20 @@ fun AboutScreen(
.semantics { testTagsAsResourceId = true }
.padding(padding)
) {
item(key = "about_header") {
item(key = "about_header", contentType = "about_header") {
AboutHeader(versionName = versionName)
}
items(
items = librariesData?.libraries.orEmpty(),
key = AboutLibrary::uniqueId,
contentType = { "about_library" },
) { library ->
AboutLibraryRow(
modifier = Modifier.animateItem(fadeInSpec = tween(150)),
library = library,
licenses = librariesData?.licenses.orEmpty(),
expanded = expandedLibraryId == library.uniqueId,
onToggle = {
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
null
} else {
library.uniqueId
}
},
onToggle = onToggleLibrary,
onLicenseClick = { dialogLicense = it },
)
}
@@ -171,27 +188,33 @@ private fun AboutHeader(versionName: String) {
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AboutLibraryRow(
modifier: Modifier = Modifier,
library: AboutLibrary,
licenses: Map<String, AboutLicense>,
expanded: Boolean,
onToggle: () -> Unit,
onToggle: (String) -> Unit,
onLicenseClick: (AboutLicense) -> Unit,
) {
val uriHandler = LocalUriHandler.current
val website = library.website?.takeIf(String::isNotBlank)
val source = library.scm?.url?.takeIf(String::isNotBlank)
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = if (expanded) {
val background by animateColorAsState(
targetValue = if (expanded) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surface
},
onClick = onToggle
label = "aboutLibraryRowBackground",
)
Surface(
modifier = modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = background,
onClick = { onToggle(library.uniqueId) }
) {
Column(
modifier = Modifier
@@ -291,32 +314,79 @@ private fun AboutLibraryRow(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LicenseDialog(
license: AboutLicense,
onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(license.name) },
text = {
Text(
text = license.content ?: stringResource(R.string.license_text_unavailable),
modifier = Modifier.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_close))
}
},
dismissButton = {
license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website))
var open by remember { mutableStateOf(false) }
var wasOpen by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { open = true }
val scale by animateFloatAsState(
targetValue = if (open) 1f else 0.92f,
animationSpec = tween(180),
label = "licenseDialogScale",
)
val alpha by animateFloatAsState(
targetValue = if (open) 1f else 0f,
animationSpec = tween(180),
label = "licenseDialogAlpha",
)
LaunchedEffect(open) {
if (open) {
wasOpen = true
} else if (wasOpen) {
snapshotFlow { alpha }.first { it == 0f }
onDismiss()
}
}
BasicAlertDialog(
onDismissRequest = { open = false },
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
}
) {
val maxHeight = LocalConfiguration.current.screenHeightDp.dp - 96.dp
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
Column(modifier = Modifier.padding(24.dp).heightIn(max = maxHeight)) {
Text(
text = license.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = license.content ?: stringResource(R.string.license_text_unavailable),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website))
}
}
TextButton(onClick = { open = false }) {
Text(stringResource(R.string.action_close))
}
}
}
}
)
}
}
@@ -1,6 +1,7 @@
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
@@ -11,6 +12,7 @@ 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
@@ -18,6 +20,9 @@ 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
@@ -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
fun ArticleContent(
articleState: ArticleState?,
@@ -63,7 +116,11 @@ fun ArticleContent(
is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
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
@@ -123,35 +180,42 @@ fun ArticleContent(
val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
.testTag("article_content")
.semantics {
testTagsAsResourceId = true
}
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
articleState.dslContent,
baseTextStyle,
darkTheme
) {
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 {
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(
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 ->
Box(
modifier = Modifier
.padding(
start = Spacing.iconStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
.padding(start = Spacing.contentStart + indentStep * block.indent)
) {
ArticleLinkText(
block = block,
@@ -168,13 +232,18 @@ 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(
@@ -196,16 +265,7 @@ fun ArticleContent(
}
}
null -> {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.article_no_selected),
style = MaterialTheme.typography.bodyLarge
)
}
Box(modifier = modifier.fillMaxSize())
}
}
}
@@ -215,13 +275,15 @@ fun ArticleTitleBar(
articleState: ArticleState?,
showBackButton: Boolean,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
compact: Boolean = false,
selectionReset: Int = 0,
) {
val title = when (articleState) {
is ArticleState.Loading -> articleState.title
is ArticleState.Success -> articleState.title
is ArticleState.Error -> articleState.title
null -> stringResource(R.string.article_no_selected)
null -> ""
}
val backIcon = painterResource(R.drawable.ic_arrow_back)
@@ -230,20 +292,24 @@ fun ArticleTitleBar(
color = MaterialTheme.colorScheme.surface,
modifier = modifier.fillMaxWidth()
) {
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.padding(
top = Spacing.topBarPadding,
bottom = Spacing.topBarPadding
)
.height(Spacing.topBarHeight)
.then(
if (compact) {
Modifier.padding(vertical = Spacing.listItemVertical)
} else {
Modifier
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
.height(Spacing.topBarHeight)
}
),
verticalAlignment = Alignment.CenterVertically
) {
if (showBackButton) {
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = Spacing.iconStart)
.size(24.dp)
.clickable(onClick = onNavigateBack),
@@ -257,18 +323,21 @@ fun ArticleTitleBar(
}
}
Text(
text = title,
style = MaterialTheme.typography.dictionaryTitleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
.testTag("article_title")
.semantics { testTagsAsResourceId = true }
)
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 }
)
}
}
}
}
}
@@ -281,22 +350,31 @@ internal fun ArticleScreen(
onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) {
Scaffold(
topBar = {
ArticleTitleBar(
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(
articleState = articleState,
showBackButton = true,
onNavigateBack = onNavigateBack
onNavigateBack = onNavigateBack,
onRefClick = onRefClick,
onScrollPositionChanged = onScrollPositionChanged,
modifier = Modifier.padding(padding)
)
},
contentWindowInsets = AppWindowInsets.contentWithNavigation
) { padding ->
ArticleContent(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = onRefClick,
onScrollPositionChanged = onScrollPositionChanged,
modifier = Modifier.padding(padding)
)
}
}
}
@@ -24,6 +24,40 @@ object DslAnnotatedParser {
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
)
private val DSL_NAMED_COLORS = mapOf(
"black" to Color(0xFF000000),
"silver" to Color(0xFFC0C0C0),
"gray" to Color(0xFF808080),
"grey" to Color(0xFF808080),
"lightgray" to Color(0xFFD3D3D3),
"lightgrey" to Color(0xFFD3D3D3),
"darkgray" to Color(0xFFA9A9A9),
"darkgrey" to Color(0xFFA9A9A9),
"white" to Color(0xFFFFFFFF),
"maroon" to Color(0xFF800000),
"red" to Color(0xFFFF0000),
"darkred" to Color(0xFF8B0000),
"crimson" to Color(0xFFDC143C),
"brown" to Color(0xFFA52A2A),
"orange" to Color(0xFFFFA500),
"pink" to Color(0xFFFFC0CB),
"purple" to Color(0xFF800080),
"violet" to Color(0xFFEE82EE),
"fuchsia" to Color(0xFFFF00FF),
"magenta" to Color(0xFFFF00FF),
"olive" to Color(0xFF808000),
"yellow" to Color(0xFFFFFF00),
"green" to Color(0xFF008000),
"darkgreen" to Color(0xFF006400),
"lime" to Color(0xFF00FF00),
"teal" to Color(0xFF008080),
"aqua" to Color(0xFF00FFFF),
"cyan" to Color(0xFF00FFFF),
"navy" to Color(0xFF000080),
"blue" to Color(0xFF0000FF),
"darkblue" to Color(0xFF00008B),
)
data class ColorScheme(
val secondaryText: Color,
val labelText: Color,
@@ -36,11 +70,6 @@ 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
}
@@ -54,9 +83,9 @@ object DslAnnotatedParser {
val stack = ArrayDeque<SpanStyle>()
val refStack = ArrayDeque<Pair<Int, StringBuilder>>()
val lazyRefs = mutableListOf<LazyRef>()
var lastChar: Char? = null
var accentNext = false
var currentIndent = 0
var lastAppended: Char? = null
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText)
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
@@ -74,16 +103,38 @@ object DslAnnotatedParser {
}
fun flushBlock() {
if (builder.length > 0) {
val text = builder.toAnnotatedString()
if (text.any { !it.isWhitespace() }) {
result.add(DslBlock(
text = builder.toAnnotatedString(),
text = text,
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
}
fun appendChar(char: Char, hard: Boolean = false) {
if (char == ' ' && !hard) {
if (lastAppended == null || lastAppended == ' ') return
}
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 {
@@ -99,20 +150,11 @@ object DslAnnotatedParser {
when (val char = dsl[i]) {
'\\' -> {
if (i + 1 < length) {
val next = dsl[i + 1]
builder.append(next)
if (refStack.isNotEmpty()) {
refStack.last().second.append(next)
}
lastChar = next
appendChar(dsl[i + 1], hard = true)
i += 2
continue
}
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
appendChar(char)
i++
}
'[' -> {
@@ -149,6 +191,10 @@ object DslAnnotatedParser {
accentNext = false
}
pop()
if (tagName.startsWith("m")) {
flushBlock()
currentIndent = 0
}
} else {
when (tagName) {
"'" -> {
@@ -161,17 +207,15 @@ object DslAnnotatedParser {
} else {
builder.append("\n")
}
lastChar = '\n'
}
"p" -> {
push(labelStyle)
push(labelStyle.copy(fontStyle = FontStyle.Italic))
}
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
if (lastChar != '\n' && lastChar != null) {
if (refStack.isEmpty()) flushBlock() else builder.append("\n")
if (lastAppended != null && !lastAppended!!.isWhitespace()) {
flushBlock()
}
lastChar = '\n'
currentIndent = marginIndent(tagName)
push(exampleStyle)
}
@@ -181,7 +225,18 @@ object DslAnnotatedParser {
refStack.addLast(builder.length to StringBuilder())
}
"ex", "e" -> push(exampleStyle)
"c", "t", "trn", "lang", "sup" -> push(SpanStyle())
"c" -> {
val colorName = if (spaceIdx >= 0) {
tagWithParams.substring(spaceIdx + 1).trim().lowercase()
} else {
null
}
push(when {
colorName == null -> labelStyle
else -> DSL_NAMED_COLORS[colorName]?.let { SpanStyle(color = it) } ?: SpanStyle()
})
}
"t", "trn", "lang", "sup" -> push(SpanStyle())
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
}
}
@@ -189,18 +244,23 @@ object DslAnnotatedParser {
continue
}
}
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
appendChar(char)
i++
}
'\n' -> {
if (refStack.isEmpty()) {
flushBlock()
} else {
appendChar(char)
}
lastChar = char
i++
}
'\t' -> {
appendText("\u2002")
i++
}
else -> {
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
appendChar(char)
if (accentNext) {
builder.append('\u0301')
@@ -210,7 +270,6 @@ object DslAnnotatedParser {
accentNext = false
}
lastChar = char
i++
}
}
@@ -352,6 +411,35 @@ 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,5 +1,7 @@
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
@@ -13,6 +15,7 @@ import com.example.research.ui.article.ArticleContent
import com.example.research.ui.article.ArticleTitleBar
import com.example.research.ui.article.ArticleState
import com.example.research.ui.main.components.MainTopBar
import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
@@ -84,20 +87,27 @@ fun AdaptiveMainScreen(
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
)
.padding(start = 32.dp, end = listEndPadding),
.padding(start = Spacing.contentStart, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
)
},
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,
showBackButton = isShowingDetail && articleState != null,
onNavigateBack = onNavigateBackFromArticle,
compact = true,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
@@ -112,8 +122,13 @@ fun AdaptiveMainScreen(
)
}
LaunchedEffect(articleState, isQueryEmpty) {
LaunchedEffect(articleState, isQueryEmpty, isLandscape) {
val targetValue = when {
isLandscape -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
articleState == null -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded,
@@ -14,6 +14,7 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -169,7 +170,7 @@ internal fun MainScreen(
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = 32.dp, end = 16.dp),
.padding(start = Spacing.contentStart, end = 16.dp),
)
}
}
@@ -8,6 +8,7 @@ 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
@@ -50,6 +51,7 @@ 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()
}
@@ -72,9 +74,22 @@ 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
@@ -125,6 +140,7 @@ internal fun SearchResultsPane(
listState = listState,
showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding),
staleEntries = shownStale,
)
}
}
@@ -1,5 +1,6 @@
package com.example.research.ui.main.components
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -29,8 +30,11 @@ fun SearchResultsList(
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
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(
modifier = modifier
.testTag("search_results")
@@ -40,17 +44,32 @@ fun SearchResultsList(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
count = results.itemCount,
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
contentType = results.itemContentType { "search_result" }
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" }
) { index ->
results[index]?.let { entry ->
SearchResultItem(
entry = entry,
resultIndex = index,
onArticleClick = onArticleClick,
showDictionaryName = showDictionaryName
)
val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
if (entry != null) {
Box(
modifier = Modifier.animateItem(
fadeInSpec = if (index < 8) tween(150) else null,
placementSpec = null,
fadeOutSpec = null
)
) {
SearchResultItem(
entry = entry,
resultIndex = index,
onArticleClick = onArticleClick,
showDictionaryName = showDictionaryName
)
}
}
}
@@ -1,6 +1,13 @@
package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.*
@@ -72,6 +79,12 @@ fun AppNavigation(
.windowSizeClass
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
LaunchedEffect(isWideScreen) {
if (isWideScreen) {
screenStack.removeAll { it == Screen.Article }
}
}
BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) {
if (currentScreen == Screen.Article) {
if (searchViewModel.navigateBackFromArticle()) {
@@ -92,7 +105,21 @@ fun AppNavigation(
shouldShowSettings -> Screen.Settings
else -> currentScreen
}
when (effectiveScreen) {
AnimatedContent(
targetState = effectiveScreen,
transitionSpec = {
val forward = initialState.ordinal < targetState.ordinal
(
fadeIn(tween(180)) +
slideInHorizontally(tween(180)) { if (forward) it / 3 else -it / 3 }
).togetherWith(
fadeOut(tween(180)) +
slideOutHorizontally(tween(180)) { if (forward) -it / 3 else it / 3 }
)
},
label = "screenTransition",
) { screen ->
when (screen) {
Screen.Home -> {
MainRoute(
searchViewModel = searchViewModel,
@@ -142,5 +169,6 @@ fun AppNavigation(
}
)
}
}
}
}
@@ -3,6 +3,11 @@ package com.example.research.ui.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -266,7 +271,11 @@ private fun CollapsibleSettingsSection(
testTag = testTag
)
if (isExpanded) {
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.example.research.DictionaryStatus
import com.example.research.R
import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.DateUtils
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
@@ -16,6 +17,7 @@ import com.example.research.core.domain.usecase.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState
@@ -61,6 +63,7 @@ class SettingsViewModel(
private val dictionaryImportManager: DictionaryImportManager,
progressStateHolder: DictionaryProgressStateHolder,
appVersion: String,
initialSectionExpansion: SectionExpansionStates,
) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -68,7 +71,14 @@ class SettingsViewModel(
validator = DictionarySourceValidator(),
)
private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion))
private val _uiState = MutableStateFlow(
SettingsUiState(
appVersion = appVersion,
isThemeExpanded = initialSectionExpansion.isThemeExpanded,
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
)
)
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
val progressSnapshot = progressStateHolder.progressSnapshot
@@ -91,9 +101,26 @@ class SettingsViewModel(
init {
setupStateObservation()
observeDictionariesForSeedFlag()
persistDictionaryStatus()
initialize()
}
private fun persistDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.collect { status ->
val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate"
DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty"
else -> null
}
if (persistedName != null) {
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
}
}
}
}
private fun observeDictionariesForSeedFlag() {
viewModelScope.launch {
localDictionaryRepository.dictionaries.collect { dictionaries ->
@@ -152,7 +179,9 @@ class SettingsViewModel(
if (wasIndexing && !isIndexing) {
if (isDownloadInProgress) {
isDownloadInProgress = false
dictionaryStatus.value = DictionaryStatus.UpToDate
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
}
wasIndexing = isIndexing
@@ -552,9 +581,21 @@ class SettingsViewModel(
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
val savedStatus = preferencesManager.dictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = when (savedStatusName) {
"UpToDate" -> DictionaryStatus.UpToDate
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
else -> null
}
if (restoredStatus != null && savedStatusDate == DateUtils.getCurrentDateString()) {
dictionaryStatus.value = restoredStatus
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
}
if (result.data > 0) {
@@ -1,6 +1,14 @@
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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -15,8 +23,10 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -110,7 +120,11 @@ fun DictionaryManagement(
testTag = "dictionary_header"
)
if (isExpanded) {
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(animationSpec = tween(250)) + fadeIn(tween(250)),
exit = shrinkVertically(animationSpec = tween(200)) + fadeOut(tween(200)),
) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -260,18 +274,34 @@ private fun DictionaryListSection(
modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false }
) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) {
val visiblePaths = dictionaries.mapTo(mutableSetOf(), Dictionary::path)
appearedPaths = appearedPaths intersect visiblePaths
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
dictionaries.forEach { dictionary ->
key(dictionary.path) {
DictionaryListItem(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) },
isDeleteBlocked = isDeleteBlocked
val hasAppeared = dictionary.path in appearedPaths
val rowAlpha by animateFloatAsState(
targetValue = if (hasAppeared) 1f else 0f,
animationSpec = tween(150),
label = "dictionaryRowAppear",
)
LaunchedEffect(dictionary.path) {
appearedPaths = appearedPaths + dictionary.path
}
Box(modifier = Modifier.alpha(rowAlpha)) {
DictionaryListItem(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) },
isDeleteBlocked = isDeleteBlocked
)
}
}
}
}
@@ -9,4 +9,6 @@ object Spacing {
val iconStart = 32.dp
val searchTextStart = 68.dp
val listItemVertical = 8.dp
val contentStart = 32.dp
}
+2 -2
View File
@@ -13,7 +13,6 @@
<string name="language_russian">Русский</string>
<string name="language_system">Системный</string>
<string name="clear_search">Очистить поиск</string>
<string name="article_no_selected">Статья не выбрана</string>
<string name="article_return_to_search">Вернуться к поиску</string>
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
<string name="error_loading_article">Ошибка загрузки: %1$s</string>
@@ -48,6 +47,7 @@
<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 +77,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 или .dz</string>
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .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>
+2 -2
View File
@@ -13,7 +13,6 @@
<string name="language_russian">Russian</string>
<string name="language_system">System</string>
<string name="clear_search">Clear search</string>
<string name="article_no_selected">No article selected</string>
<string name="article_return_to_search">Return to search</string>
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
<string name="error_loading_article">Loading error: %1$s</string>
@@ -48,6 +47,7 @@
<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 +75,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 .dz</string>
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.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>
@@ -0,0 +1,9 @@
# Strip android.util.Log warnings/errors from release builds.
# Debug builds keep full logging for diagnostics.
-assumenosideeffects class android.util.Log {
public static int w(java.lang.String, java.lang.String);
public static int w(java.lang.String, java.lang.String, java.lang.Throwable);
public static int w(java.lang.String, java.lang.Throwable);
public static int e(java.lang.String, java.lang.String);
public static int e(java.lang.String, java.lang.String, java.lang.Throwable);
}
@@ -0,0 +1,16 @@
- 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
@@ -0,0 +1,16 @@
- Приложение запускается быстрее
- Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
- Адреса источников принимают формат host:port
- Загрузка словарей автоматически повторяется при переключении сети
- Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически
- Повышена стабильность приложения
- Ускорена индексация словарей
- Обновлены тулинг сборки и документация
- Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме
+4 -4
View File
@@ -1,12 +1,12 @@
[versions]
aboutlibraries = "15.2.0"
activity_compose = "1.13.0"
agp = "9.3.1"
compose_bom = "2026.08.00"
core = "1.19.0"
agp = "9.4.1"
compose_bom = "2026.09.00"
core = "1.19.1"
datastore_preferences = "1.2.1"
documentfile = "1.1.0"
kotlin = "2.4.10"
kotlin = "2.4.20"
lifecycle_runtime_ktx = "2.11.0"
okhttp = "5.5.0"
paging = "3.5.1"