30 Commits
Author SHA1 Message Date
OneWay 03b706c9f7 refactor(prefs): create the settings store through DataStore.Builder
The store is now built through DataStore.Builder instead of the
preferencesDataStore delegate.
2026-10-22 21:48:01 +08:00
OneWay 4bdd185434 fix(about): survive a licenses parse failure 2026-10-22 21:43:15 +08:00
OneWay 79a7ce3059 chore(deps): bump Gradle wrapper to 9.8.2 2026-10-21 04:34:31 +08:00
OneWay 91627d181e chore(deps): bump kotlinx.serialization to 1.12.0 2026-10-21 04:33:24 +08:00
OneWay 692fb2117e fix(download): unblock search after a failed update
A failed update left DownloadState.Error in the state flow forever:
settings replayed it as a snackbar on every recomposition and search
stayed locked until the next app start.
2026-10-21 04:00:00 +08:00
OneWay 3bb4e09b04 chore(deps): bump aboutlibraries to 15.3.0 2026-10-15 13:52:00 +08:00
OneWay ff4ba62bbd chore(deps): bump paging to 3.5.3 2026-10-15 13:41:07 +08:00
OneWay 123615e1f8 fix(download): expire the sticky error state
A download error stayed in the state flow forever: the dictionaries
section replayed it as a snackbar on every recomposition and screens
created later picked it up long after the failure, even while a newer
download was already running. Error states now decay to idle after the
same grace period as success, unless a newer state has replaced them.
2026-10-15 13:38:00 +08:00
OneWay 6889dd2c45 fix(settings): keep the update badge after a failed download
Errors of downloads this screen did not start no longer touch the
status; when the prior status is unknown or still unresolved, today's
persisted status is restored or a fresh evaluation runs instead of
fabricating UpToDate.
2026-10-15 13:34:09 +08:00
OneWay f22028d126 Search results: one ordering pass instead of double ranking
The search pipeline ranked and deduplicated matches twice: inside the
searcher per dictionary and again after merging across dictionaries.
The extra pass ran before the required ordering and duplicated the
dedup work. The searcher now returns article-deduplicated matches and
the paging source applies a single ordering pass: display length
ascending, then natural numeric-aware order. Paging configuration
knobs that a full-page source cannot use are dropped.
2026-10-15 13:06:05 +08:00
OneWay e20af9c1ff fix(settings): keep the update badge across PROCESS_TEXT relaunches
Persist NeedsUpdate with its source ids and restore today's persisted
status — UpToDate and NeedsUpdate alike — through one shared helper used
by both startup paths, falling back to a fresh evaluation when the
persisted status can no longer be trusted (stale date, removed sources,
or the update already installed).
2026-10-14 23:33:51 +08:00
OneWay 731340fd13 Release 1.8.0
- Search loads all prefix matches; broad prefix searches are much faster
- Fixed indexing progress freezing on large dictionaries
- Large articles open faster
- Numbered results sort in numeric order
- UI improvements
- Updated libraries and build tooling
2026-10-14 01:53:38 +08:00
OneWay 3930b7bee9 docs(store): refresh screenshots 2026-10-12 15:41:29 +08:00
OneWay 911e8b61f6 feat(settings): badge dictionaries that have updates
Report which sources have a newer file instead of a single flag, and
show a Material badge at the top-end corner of each such dictionary's
icon. The section icon carries the badge while the section is
collapsed.
2026-10-12 15:38:55 +08:00
OneWay c9d1a3439c feat(search): line up the first result with the article title bar
With the search bar at the bottom, offset the results list so the
first result sits on the article title row and the back button
lands under the finger, in single- and two-pane layouts alike.
2026-10-12 15:36:18 +08:00
OneWay 41bc116c56 feat(download): show progress before a link download starts
The source check, folder access and request ran before the first
byte with no visible feedback. Enter the loading state on tap and
show an indeterminate wavy indicator until download progress is
known. File import is unchanged. An early cancel deletes only files
created by the current download, so installed dictionaries survive.
2026-10-12 15:33:42 +08:00
OneWay 7af9930667 chore(deps): bump material3 to 1.5.0-beta01
Needed for the expressive wavy progress indicator. Move the sheet
states to rememberBottomSheetState, drop opt-ins for APIs that are
now stable, and read the window height from LocalWindowInfo.
2026-10-12 15:31:07 +08:00
OneWay a4197d2970 fix(search): load all prefix matches in one ranked pass
Rows were read via peek(), which gives Paging no access hints, so
prefix matches beyond the first page never loaded. Drop the head/tail
paging: a query reads the whole prefix range of every active
dictionary in one scan, merges and ranks it, and hands Paging a single
page. The append error row and its strings go away; the prefix scan
reuses search() with substring matches disabled. FileSource reads
through a buffer, cutting per-entry syscall overhead.
2026-10-12 02:34:02 +08:00
OneWay 4fc765382e refactor(trace): remove unused flow section constants 2026-10-11 22:53:55 +08:00
OneWay c6ec14da3e fix(indexing): keep parsing progress moving
The parse phase estimated progress from a line-count formula that hit
its ceiling early, so the percentage froze on large dictionaries.
Estimate it from the position in the decompressed stream against the
uncompressed size instead.
2026-10-11 22:25:23 +08:00
OneWay 2fdcae6ae3 fix(notifications): throttle dictionary progress reposts
A fast download reposted the ongoing notification several times per
second. Publish stage changes immediately and rate-limit intermediate
updates to the latest snapshot.
2026-10-11 22:22:08 +08:00
OneWay ae4bffe516 chore(deps): bump Gradle wrapper to 9.8.1
Patch release with Windows wrapper script fixes.
2026-10-10 14:09:00 +08:00
OneWay ff54287916 chore(deps): bump the dependency-versions plugin to 0.65.0 2026-10-10 01:58:43 +08:00
OneWay 318d05350e chore(deps): bump paging to 3.5.2
Patch release fixing an append() call made before the initial refresh.
2026-10-10 01:58:19 +08:00
OneWay 4aeaca4a98 chore(deps): bump Kotlin to 2.4.21 2026-10-10 01:57:43 +08:00
OneWay 01ef1dee2c perf(profile): cover startup, search and article-open paths
Cover the startup path, per-keystroke search with index reads and
ranking, the dictzip article read, block merge and text warm-up,
and dictionary indexing; they previously ran as JIT-warmed or
interpreted code.
2026-10-10 01:13:18 +08:00
OneWay 5ad184ba58 refactor(article): warm up the blocks that are actually rendered
The screen merges small parser blocks into larger render blocks,
while the warm-up measured the unmerged parser blocks. Merge once in
the view model and keep the merged blocks in the article state, so
the screen and the warm-up share exactly what will be drawn.
2026-10-10 01:13:03 +08:00
OneWay 381ce33017 fix(article): warm up large single-block articles
The warm-up ran only above a block-count threshold, so an article
that parses into one large block never qualified. Trigger on the
total character count as well.
2026-10-10 01:12:48 +08:00
OneWay f3ecc3086c fix(search): order numbered headwords by numeric value
The final ranking tie-break compared headwords lexicographically,
so entries numbered with digits or CJK numerals sorted by text
instead of by number. Compare runs of digits or numeral characters
by their numeric value, saturating on very long runs.
2026-10-10 01:12:33 +08:00
OneWay ad3d4f83fb fix(search): pad the bottom of the search history sheet 2026-10-09 13:03:59 +08:00
51 changed files with 864 additions and 675 deletions

No files matched your search

+9 -9
View File
@@ -15,9 +15,9 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
## Screenshots
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch settings screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch search results screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="160" alt="ReSearch about screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/3.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search results screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch search history sheet">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="160" alt="ReSearch settings screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/3.png)
## Features
@@ -85,15 +85,15 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack
- Kotlin 2.4.20
- Kotlin 2.4.21
- Jetpack Compose (BOM 2026.09.00)
- Material 3
- Material 3 1.5.0-beta01
- Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1
- Paging 3.5.1
- DataStore Preferences 1.3.0
- Paging 3.5.3
- OkHttp 5.5.0
- kotlinx.serialization 1.11.0
- AboutLibraries 15.2.0 metadata generation
- kotlinx.serialization 1.12.0
- AboutLibraries 15.3.0 metadata generation
- Android Gradle Plugin 9.4.1
## Build
+2 -11
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 10
versionName = "1.7.0"
versionCode = 12
versionName = "1.8.0"
vectorDrawables {
useSupportLibrary = true
@@ -164,26 +164,17 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.documentfile)
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(libs.bundles.compose)
implementation(libs.androidx.compose.material3.adaptive.layout)
debugImplementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp)
// Coroutines with Android-optimized dispatchers
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
}
@@ -13,9 +13,6 @@ object ReSearchTrace {
const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract"
const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index"
const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import"
const val FLOW_UPDATE_CARD_APPEAR = "ReSearch/Flow/UpdateCardAppear"
const val FLOW_UPDATING = "ReSearch/Flow/Updating"
const val FLOW_PROGRESS_DISAPPEAR = "ReSearch/Flow/ProgressDisappear"
private val nextCookie = AtomicInteger()
+36 -44
View File
@@ -1,54 +1,48 @@
# Seeded from the measured ReSearch startup, search, scrolling, and article-opening hot paths.
# Keep this profile checked in: release builders, including F-Droid, only compile it.
# Updated 2026-06-25: Added optimized cache paths from performance optimization stages 1-4
HSPLcom/example/research/ReSearchApplication**->**(**)**
HSPLcom/example/research/MainActivity**->**(**)**
HSPLcom/example/research/InitialLaunch**->**(**)**
HSPLcom/example/research/DictionaryStatus**->**(**)**
HSPLcom/example/research/core/**->**(**)**
HSPLcom/example/research/common/ui/**->**(**)**
HSPLcom/example/research/ui/theme/**->**(**)**
HSPLcom/example/research/ui/navigation/**->**(**)**
HSPLcom/example/research/data/local/preferences/**->**(**)**
HSPLcom/example/research/data/parser/ResourceManager**->**(**)**
HSPLcom/example/research/common/util/FileStorageManager**->**(**)**
HSPLcom/example/research/common/util/DateUtils**->**(**)**
HSPLcom/example/research/ReSearchApplication;->**(**)**
HSPLcom/example/research/MainActivity;->**(**)**
HSPLcom/example/research/common/ui/AppLocaleProviderKt**->**(**)**
HSPLcom/example/research/common/ui/theme/ThemeKt**->**(**)**
HSPLcom/example/research/ui/navigation/AppNavigationKt**->**(**)**
HSPLcom/example/research/ui/main/MainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/AdaptiveMainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/components/MainTopBarKt**->**(**)**
HSPLcom/example/research/ui/main/components/SearchResultsListKt**->**(**)**
HSPLcom/example/research/feature/search/SearchViewModel**->**(**)**
HSPLcom/example/research/data/paging/IndexEntryPagingSource**->**(**)**
HSPLcom/example/research/ui/main/**->**(**)**
HSPLandroidx/paging/**->**(**)**
HSPLcom/example/research/feature/search/**->**(**)**
HSPLcom/example/research/data/paging/**->**(**)**
HSPLcom/example/research/data/parser/IndexSearcher**->**(**)**
HSPLcom/example/research/data/parser/SearchRankingKt**->**(**)**
HSPLcom/example/research/data/parser/FileSource**->**(**)**
HSPLcom/example/research/data/parser/ResultsLruCache**->**(**)**
HSPLcom/example/research/data/parser/MetadataLruCache**->**(**)**
HSPLcom/example/research/data/parser/SearchRanking**->**(**)**
HSPLcom/example/research/data/parser/SortKey**->**(**)**
HSPLcom/example/research/data/repository/LocalDictionaryRepository**->**(**)**
HSPLcom/example/research/data/repository/KotlinDictionaryEngine**->**(**)**
HSPLcom/example/research/data/repository/**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipInputStream**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipRandomAccessFile**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipHeader**->**(**)**
HSPLcom/example/research/data/parser/DslArticleReader**->**(**)**
HSPLcom/example/research/ui/article/DslAnnotatedParser**->**(**)**
HSPLcom/example/research/ui/article/ArticleScreenKt**->**(**)**
HSPLcom/example/research/data/parser/LruCharsetCache**->**(**)**
HSPLcom/example/research/ui/article/**->**(**)**
# Hot paths for optimized article rendering (Stage 1)
HSPLcom/example/research/ui/article/ArticleLinkText**->**(**)**
HSPLcom/example/research/ui/article/ArticleState**->**(**)**
HSPLcom/example/research/data/parser/Dsl**->**(**)**
HSPLcom/example/research/data/parser/LineKind**->**(**)**
HSPLcom/example/research/data/parser/ByteTrackingLineReader**->**(**)**
HSPLcom/example/research/data/parser/Utf8Writer**->**(**)**
HSPLcom/example/research/ui/settings/**->**(**)**
HSPLcom/example/research/common/progress/**->**(**)**
HSPLcom/example/research/feature/download/**->**(**)**
# About screen (bonus optimization)
HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)**
# Updated 2026-07-11: cover the settings/dictionary flow and the Compose framework.
# Settings + dictionary management flow
HSPLcom/example/research/ui/settings/**->**(**)**
Lcom/example/research/ui/settings/**
HSPLcom/example/research/common/ui/components/**->**(**)**
Lcom/example/research/common/ui/components/**
HSPLcom/example/research/common/progress/**->**(**)**
Lcom/example/research/common/progress/**
HSPLcom/example/research/feature/download/**->**(**)**
Lcom/example/research/feature/download/**
# Pre-verify the app classes already covered by method rules above
Lcom/example/research/**
# Compose framework: the alpha AARs ship near-empty baseline profiles, so first
# compositions run interpreted without these rules.
HSPLandroidx/compose/runtime/**->**(**)**
Landroidx/compose/runtime/**
HSPLandroidx/compose/ui/**->**(**)**
@@ -61,12 +55,10 @@ HSPLandroidx/compose/animation/**->**(**)**
Landroidx/compose/animation/**
HSPLandroidx/compose/material/**->**(**)**
Landroidx/compose/material/**
# Support libraries on the same cold path
HSPLandroidx/lifecycle/**->**(**)**
Landroidx/lifecycle/**
HSPLandroidx/activity/compose/**->**(**)**
Landroidx/activity/compose/**
HSPLkotlinx/coroutines/**->**(**)**
Lkotlinx/coroutines/**
Landroidx/paging/**
@@ -7,7 +7,7 @@ sealed interface DictionaryStatus {
data object Unknown : DictionaryStatus
data object Checking : DictionaryStatus
data object UpToDate : DictionaryStatus
data object NeedsUpdate : DictionaryStatus
data class NeedsUpdate(val sourceIds: Set<String>) : DictionaryStatus
data object Empty : DictionaryStatus
data class Error(val message: String) : DictionaryStatus
}
@@ -21,14 +21,16 @@ object DictionaryProgressModel {
val progress: Float,
val percent: Int,
@param:StringRes val titleRes: Int,
val indeterminate: Boolean = false,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Snapshot) return false
return percent == other.percent && titleRes == other.titleRes
return percent == other.percent && titleRes == other.titleRes &&
indeterminate == other.indeterminate
}
override fun hashCode(): Int {
return 31 * percent + titleRes
return 31 * (31 * percent + titleRes) + indeterminate.hashCode()
}
}
fun computeProgressSnapshot(
@@ -92,6 +94,7 @@ object DictionaryProgressModel {
progress = p,
percent = pct,
titleRes = R.string.notification_download_title,
indeterminate = dl <= 0f,
)
}
@@ -1,37 +1,86 @@
package com.example.research.common.progress
import android.os.SystemClock
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
class DictionaryProgressPresenter(
scope: CoroutineScope,
private val scope: CoroutineScope,
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
private val notifications: NotificationPort,
mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : PipelineTerminalUi {
private companion object {
val MIN_NOTIFICATION_INTERVAL = 500.milliseconds
}
@Volatile
private var terminalActive = false
private var lastShownTitleRes: Int = 0
private var lastEmitMs: Long = 0L
private var pendingSnapshot: DictionaryProgressModel.Snapshot? = null
private var flushJob: Job? = null
init {
scope.launch(mainDispatcher) {
progressSnapshot.collect { snapshot ->
when {
snapshot == null -> {
resetThrottle()
if (!terminalActive && notifications.hasActiveProgressNotification()) {
notifications.cancelProgressNotification()
}
}
!terminalActive -> notifications.showProgress(snapshot.percent, snapshot.titleRes)
!terminalActive -> emitThrottled(snapshot)
}
}
}
}
private fun emitThrottled(snapshot: DictionaryProgressModel.Snapshot) {
val now = SystemClock.elapsedRealtime()
val stageChanged = snapshot.titleRes != lastShownTitleRes
if (stageChanged || (now - lastEmitMs).milliseconds >= MIN_NOTIFICATION_INTERVAL) {
flushJob?.cancel()
pendingSnapshot = null
publish(snapshot, now)
} else {
pendingSnapshot = snapshot
if (flushJob?.isActive != true) {
flushJob = scope.launch(mainDispatcher) {
delay(MIN_NOTIFICATION_INTERVAL - (now - lastEmitMs).milliseconds)
pendingSnapshot?.let { publish(it, SystemClock.elapsedRealtime()) }
pendingSnapshot = null
}
}
}
}
private fun publish(snapshot: DictionaryProgressModel.Snapshot, now: Long) {
if (terminalActive) return
lastShownTitleRes = snapshot.titleRes
lastEmitMs = now
notifications.showProgress(snapshot.percent, snapshot.titleRes)
}
private fun resetThrottle() {
flushJob?.cancel()
pendingSnapshot = null
lastShownTitleRes = 0
lastEmitMs = 0L
}
fun beginFlow() {
terminalActive = false
resetThrottle()
notifications.cancelTerminalNotification()
}
@@ -52,6 +101,7 @@ class DictionaryProgressPresenter(
override fun cancelProgress() {
terminalActive = false
resetThrottle()
notifications.cancelProgressNotification()
}
}
@@ -0,0 +1,46 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Badge
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
private val BadgeOutset = 4.dp
@Composable
fun BadgedIcon(
painter: Painter,
tint: Color,
size: Dp,
badgeDescription: String?,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.size(size)) {
Icon(
painter = painter,
contentDescription = null,
tint = tint,
modifier = Modifier.size(size)
)
if (badgeDescription != null) {
Badge(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = BadgeOutset, y = -BadgeOutset)
.testTag("badge")
.semantics { contentDescription = badgeDescription }
)
}
}
}
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -29,7 +28,8 @@ fun CollapsibleHeader(
onToggle: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
testTag: String? = null
testTag: String? = null,
badgeDescription: String? = null,
) {
Row(
modifier = modifier
@@ -43,11 +43,11 @@ fun CollapsibleHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
BadgedIcon(
painter = painterResource(iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
size = 24.dp,
badgeDescription = badgeDescription,
)
Column(
@@ -0,0 +1,128 @@
package com.example.research.core.util
val NaturalStringComparator: Comparator<String> = Comparator { a, b -> compareNatural(a, b) }
private const val NUMERAL_CAP = 1_000_000_000_000_000_000L
private fun digitOf(c: Char): Int = when (c) {
'〇', '零' -> 0
'一', '壹', '壱', '弌' -> 1
'二', '貳', '贰', '弐', '弍', '兩', '两' -> 2
'三', '叁', '參', '参', '弎' -> 3
'四', '肆' -> 4
'五', '伍' -> 5
'六', '陸', '陆' -> 6
'七', '柒' -> 7
'八', '捌' -> 8
'九', '玖' -> 9
else -> Character.digit(c, 10)
}
private fun smallUnitOf(c: Char): Long = when (c) {
'十', '拾' -> 10L
'百', '佰' -> 100L
'千', '仟' -> 1000L
else -> 0L
}
private fun fixedTensOf(c: Char): Long = when (c) {
'廿' -> 20L
'卅' -> 30L
'卌' -> 40L
else -> 0L
}
private fun bigUnitOf(c: Char): Long = when (c) {
'万', '萬' -> 10_000L
'億', '亿' -> 100_000_000L
'兆' -> 1_000_000_000_000L
'京' -> 10_000_000_000_000_000L
else -> 0L
}
private fun isNumeralChar(c: Char): Boolean =
digitOf(c) >= 0 || smallUnitOf(c) > 0L || fixedTensOf(c) > 0L || bigUnitOf(c) > 0L
private fun satMul(a: Long, b: Long): Long =
if (a != 0L && b > NUMERAL_CAP / a) NUMERAL_CAP else minOf(a * b, NUMERAL_CAP)
private fun satAdd(a: Long, b: Long): Long = minOf(a + b, NUMERAL_CAP)
private fun numeralValue(s: String, start: Int, end: Int): Long {
var total = 0L
var section = 0L
var pending = -1L
for (k in start until end) {
val c = s[k]
val digit = digitOf(c)
if (digit >= 0) {
pending = if (pending < 0) digit.toLong() else satAdd(satMul(pending, 10L), digit.toLong())
continue
}
val small = smallUnitOf(c)
if (small > 0L) {
section = satAdd(section, satMul(if (pending < 0) 1L else pending, small))
pending = -1
continue
}
val fixed = fixedTensOf(c)
if (fixed > 0L) {
section = satAdd(section, fixed)
pending = -1
continue
}
val base = satAdd(section, maxOf(pending, 0L))
total = satAdd(total, satMul(if (base == 0L) 1L else base, bigUnitOf(c)))
section = 0L
pending = -1
}
return satAdd(total, satAdd(section, maxOf(pending, 0L)))
}
private fun compareNatural(a: String, b: String): Int {
var i = 0
var j = 0
while (i < a.length && j < b.length) {
val ca = a[i]
val cb = b[j]
val asciiA = ca in '0'..'9'
val asciiB = cb in '0'..'9'
when {
!asciiA && !asciiB && isNumeralChar(ca) && isNumeralChar(cb) -> {
var endA = i
while (endA < a.length && isNumeralChar(a[endA])) endA++
var endB = j
while (endB < b.length && isNumeralChar(b[endB])) endB++
val diff = numeralValue(a, i, endA).compareTo(numeralValue(b, j, endB))
if (diff != 0) return diff
i = endA
j = endB
}
asciiA && asciiB -> {
var endA = i
while (endA < a.length && a[endA] in '0'..'9') endA++
var endB = j
while (endB < b.length && b[endB] in '0'..'9') endB++
var startA = i
while (startA < endA - 1 && a[startA] == '0') startA++
var startB = j
while (startB < endB - 1 && b[startB] == '0') startB++
val lengthA = endA - startA
val lengthB = endB - startB
if (lengthA != lengthB) return lengthA.compareTo(lengthB)
for (k in 0 until lengthA) {
val diff = a[startA + k].compareTo(b[startB + k])
if (diff != 0) return diff
}
i = endA
j = endB
}
else -> {
if (ca != cb) return ca.compareTo(cb)
i++
j++
}
}
}
return (a.length - i).compareTo(b.length - j)
}
@@ -2,14 +2,20 @@ package com.example.research.data.local.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.FileStorage
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferencesFileSerializer
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.preferences.preferencesDataStoreFile
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.SearchBarPosition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -17,7 +23,23 @@ import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
private const val SETTINGS_STORE_NAME = "settings"
private val storeLock = Any()
@Volatile
private var settingsStore: DataStore<Preferences>? = null
private val Context.dataStore: DataStore<Preferences>
get() = settingsStore ?: synchronized(storeLock) {
settingsStore ?: DataStore.Builder(
storage = FileStorage(PreferencesFileSerializer) {
applicationContext.preferencesDataStoreFile(SETTINGS_STORE_NAME)
},
context = Dispatchers.IO + SupervisorJob(),
).setCorruptionHandler(ReplaceFileCorruptionHandler { emptyPreferences() }).build()
.also { settingsStore = it }
}
data class SectionExpansionStates(
val isThemeExpanded: Boolean = false,
@@ -5,59 +5,26 @@ import androidx.paging.PagingState
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.performance.ReSearchTrace
import com.example.research.core.util.sanitizeQuery
import com.example.research.data.parser.IndexSearcher
import com.example.research.data.parser.SearchRankingContext
import com.example.research.data.parser.rankBySearchRelevance
import com.example.research.data.parser.sortByCharCountThenNatural
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.CancellationException
import java.io.File
/**
* Two-phase search paging.
*
* Page one (key = null) is the ranked head: the capped, relevance-ordered result the
* search screen has always shown, merged across active dictionaries. Every range scan
* that hit its cap hands back a [IndexSearcher.RangeCursor]; those become the tail key.
*
* Tail pages continue the unfinished range scans directly from the index files and
* merge them alphabetically (k-way, by index word), so scrolling eventually surfaces
* every prefix match in every active dictionary. Articles already shown -- by the head
* or by an earlier tail page, including diacritic alias duplicates -- are filtered
* through [emittedArticles], which grows only as far as the user actually scrolls.
*
* A new query builds a new PagingSource (see SearchViewModel's flatMapLatest), so an
* in-flight head or tail load is cancelled by Paging when the user keeps typing.
*/
class IndexEntryPagingSource(
private val indexSearcher: IndexSearcher,
private val dictionaries: List<Dictionary>,
dictionaries: List<Dictionary>,
private val query: String
) : PagingSource<IndexEntryPagingSource.TailKey, IndexEntry>() {
) : PagingSource<Int, IndexEntry>() {
data class StreamCursor(
val dictPosition: Int,
val cursor: IndexSearcher.RangeCursor
)
private val activeDictionaries = dictionaries.filter { it.isActive }
data class TailKey(val streams: List<StreamCursor>)
private data class EmittedKey(
val dictPosition: Int,
val word: String,
val offset: Long
)
private val emittedArticles = HashSet<EmittedKey>()
private val activeDictionaries = dictionaries.withIndex().filter { it.value.isActive }
override suspend fun load(params: LoadParams<TailKey>): LoadResult<TailKey, IndexEntry> {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IndexEntry> {
return try {
val key = params.key
if (key == null) loadHead() else loadTail(key, params.loadSize)
loadAll()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -65,21 +32,22 @@ class IndexEntryPagingSource(
}
}
private suspend fun loadHead(): LoadResult<TailKey, IndexEntry> =
private suspend fun loadAll(): LoadResult<Int, IndexEntry> =
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) {
coroutineScope {
val perDictionary = activeDictionaries
.map { (position, dict) ->
.map { dict ->
async {
val indexFile = File(dict.indexPath)
if (!indexFile.exists()) return@async null
try {
val result = indexSearcher.searchWithCursors(
val entries = indexSearcher.search(
pathOrUri = indexFile.absolutePath,
query = query
query = query,
includeSubstringMatches = false
)
Triple(position, dict, result)
dict to entries
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
@@ -90,105 +58,28 @@ class IndexEntryPagingSource(
.awaitAll()
.filterNotNull()
val rawResults = perDictionary.flatMap { (position, dict, result) ->
result.entries.map { entry ->
position to entry.withDictionary(
val rawResults = perDictionary.flatMap { (dict, entries) ->
entries.map { entry ->
entry.withDictionary(
dict.metadata.name.ifBlank { dict.name },
dict.path
)
}
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val ranked = rawResults.map { it.second }
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
val ranked = rawResults
.sortByCharCountThenNatural()
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) }
val streams = perDictionary.flatMap { (position, _, result) ->
result.cursors.map { StreamCursor(position, it) }
}
LoadResult.Page(
data = ranked,
prevKey = null,
nextKey = if (streams.isEmpty()) null else TailKey(streams)
nextKey = null
)
}
}
private suspend fun loadTail(key: TailKey, loadSize: Int): LoadResult<TailKey, IndexEntry> {
val streams = key.streams.map { TailStream(it.dictPosition, it.cursor) }
val page = ArrayList<IndexEntry>(loadSize)
val pageKeys = HashSet<EmittedKey>()
val chunkSize = maxOf(loadSize, MIN_CHUNK)
while (page.size < loadSize) {
streams.forEach { it.ensureBuffered(chunkSize) }
val next = streams
.filter { it.hasBuffered }
.minByOrNull { it.peekWord }
?: break
val scanned = next.consume()
val dict = dictionaries[next.dictPosition]
val entry = scanned.entry.withDictionary(
dict.metadata.name.ifBlank { dict.name },
dict.path
)
val emittedKey = entry.emittedKey(next.dictPosition)
if (emittedKey !in emittedArticles && pageKeys.add(emittedKey)) {
page.add(entry)
}
}
emittedArticles.addAll(pageKeys)
val remaining = streams.mapNotNull { stream ->
stream.nextCursor?.let { StreamCursor(stream.dictPosition, it) }
}
return LoadResult.Page(
data = page,
prevKey = null,
nextKey = if (remaining.isEmpty()) null else TailKey(remaining)
)
}
private inner class TailStream(
val dictPosition: Int,
initialCursor: IndexSearcher.RangeCursor
) {
var nextCursor: IndexSearcher.RangeCursor? = initialCursor
private set
private var buffer: List<IndexSearcher.ScannedEntry> = emptyList()
private var position = 0
val hasBuffered: Boolean get() = position < buffer.size
val peekWord: String get() = buffer[position].entry.word
suspend fun ensureBuffered(chunkSize: Int) {
if (hasBuffered) return
val cursor = nextCursor ?: return
val indexPath = File(dictionaries[dictPosition].indexPath).absolutePath
buffer = indexSearcher.scanTailChunk(indexPath, cursor, chunkSize)
position = 0
if (buffer.isEmpty()) nextCursor = null
}
fun consume(): IndexSearcher.ScannedEntry {
val scanned = buffer[position]
position++
nextCursor = scanned.cursorAfter
return scanned
}
}
private fun IndexEntry.emittedKey(dictPosition: Int) =
EmittedKey(dictPosition, word, offset.value)
override fun getRefreshKey(state: PagingState<TailKey, IndexEntry>): TailKey? {
override fun getRefreshKey(state: PagingState<Int, IndexEntry>): Int? {
return null
}
}
private const val MIN_CHUNK = 48
@@ -49,6 +49,7 @@ class DslIndexer(context: Context? = null) {
suspend fun createIndex(
dslInputStream: java.io.InputStream,
indexFile: File,
totalInputBytes: Long = -1L,
onProgress: (Int) -> Unit = {},
onOperationChange: (String) -> Unit = {},
onFileProgress: (Float) -> Unit = {},
@@ -65,7 +66,7 @@ class DslIndexer(context: Context? = null) {
try { foldedIndexFile(indexFile).delete() } catch (_: Throwable) { /* Legacy v5 leftover */ }
onOperationChange("Parsing file")
onFileProgress(0.0f)
counts = parseDslToTempFile(bufferedStream, tempFile,
counts = parseDslToTempFile(bufferedStream, tempFile, totalInputBytes,
onProgress, onFileProgress, onCharsetDetected)
onFileProgress(0.5f)
onOperationChange("Sorting index")
@@ -88,6 +89,7 @@ class DslIndexer(context: Context? = null) {
private suspend fun parseDslToTempFile(
inputStream: java.io.InputStream,
tempFile: File,
totalInputBytes: Long,
onProgress: (Int) -> Unit,
onFileProgress: (Float) -> Unit = {},
onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {}
@@ -230,8 +232,12 @@ class DslIndexer(context: Context? = null) {
val yieldThreshold = adaptiveConfig.yieldInterval
if (yieldCounter >= yieldThreshold) {
onProgress(entryCount)
val estimatedProgress = (lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000)
onFileProgress(estimatedProgress.coerceIn(0f, 0.4f))
val parseProgress = if (totalInputBytes > 0L) {
(reader.position.toFloat() / totalInputBytes.toFloat())
} else {
(lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000)
}
onFileProgress(parseProgress.coerceIn(0f, 0.4f))
if (resourceManager != null) {
if (resourceManager.shouldYield(adaptiveConfig)) {
@@ -18,6 +18,7 @@ import kotlinx.coroutines.yield
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.DataInputStream
import java.io.EOFException
import java.io.File
import java.io.FileInputStream
import java.io.RandomAccessFile
@@ -25,7 +26,6 @@ import java.io.RandomAccessFile
class IndexSearcher(private val context: Context) {
private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L)
private val resultsCache = ResultsLruCache<List<IndexEntry>>(maxEntries = 20)
private val cursorResultsCache = ResultsLruCache<CursorSearchResult>(maxEntries = 20)
/**
* Pre-allocated reusable buffer for readEntry() to reduce allocations.
@@ -45,27 +45,6 @@ class IndexSearcher(private val context: Context) {
val dataStartOffset: Long
)
data class RangeCursor(
val rangeQuery: String,
val absoluteIndex: Int,
val fileOffset: Long
)
internal class RangeScanResult(
val entries: List<IndexEntry>,
val nextCursor: RangeCursor?
)
class CursorSearchResult(
val entries: List<IndexEntry>,
val cursors: List<RangeCursor>
)
class ScannedEntry(
val entry: IndexEntry,
val cursorAfter: RangeCursor?
)
suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
try {
@@ -109,7 +88,7 @@ class IndexSearcher(private val context: Context) {
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val foldedQuery = normalizedQuery.foldLatinDiacritics()
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
val cacheKey = "$pathOrUri:$includeSubstringMatches:${normalizedQuery.sanitizeCacheKey()}"
resultsCache.get(cacheKey)?.let {
return@withContext it
}
@@ -118,9 +97,9 @@ class IndexSearcher(private val context: Context) {
val count = metadata.totalCount
if (count == 0) return@withContext emptyList()
val results = openSource(pathOrUri).use { source ->
val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).entries.toMutableList()
val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).toMutableList()
if (foldedQuery != normalizedQuery) {
prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery).entries)
prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery))
}
val substringMatches = mutableListOf<IndexEntry>()
@@ -143,9 +122,7 @@ class IndexSearcher(private val context: Context) {
}
}
}
(prefixMatches + substringMatches)
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
.distinctBy { it.articleKey() }
(prefixMatches + substringMatches).distinctBy { it.articleKey() }
}
resultsCache.put(cacheKey, results)
results
@@ -165,81 +142,9 @@ class IndexSearcher(private val context: Context) {
metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
}
suspend fun searchWithCursors(
pathOrUri: String,
query: String
): CursorSearchResult = withContext(Dispatchers.Default) {
if (query.isBlank()) {
return@withContext CursorSearchResult(emptyList(), emptyList())
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val foldedQuery = normalizedQuery.foldLatinDiacritics()
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
cursorResultsCache.get(cacheKey)?.let {
return@withContext it
}
try {
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
if (metadata.totalCount == 0) {
return@withContext CursorSearchResult(emptyList(), emptyList())
}
val result = openSource(pathOrUri).use { source ->
val scans = buildList {
add(readPrefixMatches(source, metadata, normalizedQuery))
if (foldedQuery != normalizedQuery) {
add(readPrefixMatches(source, metadata, foldedQuery))
}
}
val entries = scans.flatMap { it.entries }
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
.distinctBy { it.articleKey() }
CursorSearchResult(entries, scans.mapNotNull { it.nextCursor })
}
cursorResultsCache.put(cacheKey, result)
result
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "searchWithCursors() - error: ${e.message}", e)
if (e is DictionaryError) throw e
throw DictionaryError.SearchError.QueryFailed(context.getString(R.string.error_search_failed, query.take(20), pathOrUri.take(20)), e)
}
}
suspend fun scanTailChunk(
pathOrUri: String,
cursor: RangeCursor,
maxEntries: Int
): List<ScannedEntry> = withContext(Dispatchers.Default) {
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
if (cursor.absoluteIndex >= metadata.totalCount) return@withContext emptyList()
openSource(pathOrUri).use { source ->
source.seek(cursor.fileOffset)
val out = ArrayList<ScannedEntry>(maxEntries)
var currentIdx = cursor.absoluteIndex
var iterationCount = 0
while (currentIdx < metadata.totalCount && out.size < maxEntries) {
if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(cursor.rangeQuery)) break
currentIdx++
iterationCount++
val cursorAfter = if (currentIdx < metadata.totalCount) {
RangeCursor(cursor.rangeQuery, currentIdx, source.position())
} else {
null
}
out.add(ScannedEntry(entry.copy(word = entryWordLower), cursorAfter))
}
out
}
}
fun trimMemory(level: Int) {
metadataCache.trim(level)
resultsCache.trim(level)
cursorResultsCache.trim(level)
}
suspend fun isMetadataLoaded(pathOrUri: String): Boolean {
@@ -349,9 +254,9 @@ class IndexSearcher(private val context: Context) {
source: RandomAccessSource,
metadata: IndexMetadata,
query: String
): RangeScanResult {
): List<IndexEntry> {
val firstMatch = findFirstMatch(source, metadata, query)
?: return RangeScanResult(emptyList(), null)
?: return emptyList()
return readMatchingEntries(source, metadata, firstMatch, query)
}
@@ -415,26 +320,21 @@ class IndexSearcher(private val context: Context) {
metadata: IndexMetadata,
firstMatch: Pair<Int, IndexEntry>,
query: String
): RangeScanResult {
): List<IndexEntry> {
val results = mutableListOf<IndexEntry>()
results.add(firstMatch.second)
val uniqueArticles = hashSetOf(firstMatch.second.articleKey())
var currentIdx = firstMatch.first + 1
var iterationCount = 0
while (currentIdx < metadata.totalCount) {
if (uniqueArticles.size >= MAX_PREFIX_RESULTS) {
return RangeScanResult(results, RangeCursor(query, currentIdx, source.position()))
}
if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(query)) break
uniqueArticles.add(entry.articleKey())
results.add(entry.copy(word = entryWordLower))
currentIdx++
iterationCount++
}
return RangeScanResult(results, null)
return results
}
private fun readPooledString(source: RandomAccessSource, length: Int): String {
@@ -483,7 +383,6 @@ class IndexSearcher(private val context: Context) {
private const val TAG = "IndexSearcher"
private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000
private const val SPARSE_INTERVAL_V1 = 128
private const val MAX_PREFIX_RESULTS = 200
private const val MAX_SUBSTRING_RESULTS = 100
}
}
@@ -588,30 +487,89 @@ interface RandomAccessSource : Closeable {
}
class FileSource(file: File) : RandomAccessSource {
private val raf = RandomAccessFile(file, "r")
private val buffer = ByteArray(BUFFER_SIZE)
private var bufferStart = 0L
private var bufferEnd = 0L
private var pos = 0L
override fun seek(pos: Long) {
raf.seek(pos)
this.pos = pos
}
override fun position(): Long = raf.filePointer
override fun position(): Long = pos
override fun readUnsignedByte(): Int = raf.readUnsignedByte()
private fun refill(minNeeded: Int): Boolean {
if (bufferEnd - pos >= minNeeded) return true
bufferStart = pos
raf.seek(pos)
val read = raf.read(buffer)
bufferEnd = bufferStart + maxOf(read, 0)
return bufferEnd - pos >= minNeeded
}
override fun readUnsignedShort(): Int = raf.readUnsignedShort()
override fun readUnsignedByte(): Int {
if (!refill(1)) throw EOFException()
return buffer[(pos++ - bufferStart).toInt()].toInt() and 0xFF
}
override fun readInt(): Int = raf.readInt()
override fun readUnsignedShort(): Int {
if (!refill(2)) throw EOFException()
val i = (pos - bufferStart).toInt()
pos += 2
return (buffer[i].toInt() and 0xFF shl 8) or (buffer[i + 1].toInt() and 0xFF)
}
override fun readLong(): Long = raf.readLong()
override fun readInt(): Int {
if (!refill(4)) throw EOFException()
val i = (pos - bufferStart).toInt()
pos += 4
return (buffer[i].toInt() shl 24) or
(buffer[i + 1].toInt() and 0xFF shl 16) or
(buffer[i + 2].toInt() and 0xFF shl 8) or
(buffer[i + 3].toInt() and 0xFF)
}
override fun readLong(): Long {
if (!refill(8)) throw EOFException()
val i = (pos - bufferStart).toInt()
pos += 8
var value = 0L
for (k in 0 until 8) {
value = (value shl 8) or (buffer[i + k].toLong() and 0xFF)
}
return value
}
override fun readFully(b: ByteArray) {
raf.readFully(b)
readFully(b, 0, b.size)
}
override fun readFully(b: ByteArray, off: Int, len: Int) {
if (len > BUFFER_SIZE / 2) {
raf.seek(pos)
raf.readFully(b, off, len)
pos += len
bufferEnd = bufferStart
return
}
var remaining = len
var dst = off
while (remaining > 0) {
if (!refill(1)) throw EOFException()
val i = (pos - bufferStart).toInt()
val chunk = minOf(remaining, (bufferEnd - pos).toInt())
System.arraycopy(buffer, i, b, dst, chunk)
pos += chunk
dst += chunk
remaining -= chunk
}
}
override fun close() {
raf.close()
}
private companion object {
private const val BUFFER_SIZE = 64 * 1024
}
}
@@ -1,45 +1,38 @@
package com.example.research.data.parser
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.foldLatinDiacritics
import com.example.research.core.util.NaturalStringComparator
internal data class SearchRankingContext(
val normalizedQuery: String,
val foldedQuery: String = normalizedQuery.foldLatinDiacritics(),
)
private data class SortKey(
val matchTier: Int,
val exactMatch: Int,
val charCount: Int,
val word: String,
val entry: IndexEntry,
)
internal fun List<IndexEntry>.rankBySearchRelevance(
ranking: SearchRankingContext
): List<IndexEntry> {
internal fun List<IndexEntry>.sortByCharCountThenNatural(): List<IndexEntry> {
if (size < 2) return this
return map { entry ->
val word = entry.word
val charCounts = IntArray(size)
var minCount = Int.MAX_VALUE
var maxCount = Int.MIN_VALUE
for (i in indices) {
val entry = this[i]
val displayKey = entry.originalWord
.trim()
.ifEmpty { entry.word.trim() }
val count = displayKey.codePointCount(0, displayKey.length)
charCounts[i] = count
if (count < minCount) minCount = count
if (count > maxCount) maxCount = count
}
SortKey(
matchTier = when {
!entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0
entry.isAlias && word.startsWith(ranking.foldedQuery) -> 1
!entry.isAlias && word.foldLatinDiacritics().startsWith(ranking.foldedQuery) -> 1
else -> 2
},
exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
charCount = displayKey.codePointCount(0, displayKey.length),
word = word,
entry = entry,
)
val buckets = arrayOfNulls<ArrayList<IndexEntry>>(maxCount - minCount + 1)
for (i in indices) {
val bucketIndex = charCounts[i] - minCount
(buckets[bucketIndex] ?: ArrayList<IndexEntry>().also { buckets[bucketIndex] = it })
.add(this[i])
}
.sortedWith(compareBy({ it.matchTier }, { it.exactMatch }, { it.charCount }, { it.word }))
.map { it.entry }
val naturalOrder = compareBy<IndexEntry, String>(NaturalStringComparator) { it.word }
val result = ArrayList<IndexEntry>(size)
for (bucket in buckets) {
if (bucket == null) continue
if (bucket.size > 1) bucket.sortWith(naturalOrder)
result.addAll(bucket)
}
return result
}
@@ -22,6 +22,7 @@ import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.RandomAccessFile
import java.util.concurrent.CancellationException
import java.util.zip.GZIPInputStream
@@ -51,12 +52,14 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
val indexFile = File(indexPath)
val metadata = readMetadata(dictPath)
val totalInputBytes = uncompressedInputLength(dictFile)
val articleCount = withContext(Dispatchers.IO) {
openDslInputStream(dictPath).use { inputStream ->
dslIndexer.createIndex(
dslInputStream = inputStream,
indexFile = indexFile,
totalInputBytes = totalInputBytes,
onProgress = { count ->
onArticlesIndexed(count)
},
@@ -207,6 +210,30 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
}
}
private fun uncompressedInputLength(file: File): Long {
val name = file.name.lowercase()
if (!name.endsWith(".dz") && !name.endsWith(".gz")) return file.length()
return readGzipUncompressedLength(file).takeIf { it > 0L } ?: file.length()
}
private fun readGzipUncompressedLength(file: File): Long = try {
RandomAccessFile(file, "r").use { raf ->
val fileLength = raf.length()
if (fileLength < 18L) {
-1L
} else {
raf.seek(fileLength - 4L)
val b0 = raf.readUnsignedByte()
val b1 = raf.readUnsignedByte()
val b2 = raf.readUnsignedByte()
val b3 = raf.readUnsignedByte()
(b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)).toLong() and 0xFFFFFFFFL
}
}
} catch (_: Exception) {
-1L
}
private fun resolveIndexPath(indexArg: String): String {
val candidate = File(indexArg)
if (candidate.exists()) return candidate.absolutePath
@@ -52,11 +52,19 @@ class DownloadManager(
var onDownloadSuccess: (() -> Unit)? = null
var onTerminal: ((DownloadState) -> Unit)? = null
private suspend fun expireErrorStateToIdle() {
delay(TERMINAL_STATE_DURATION)
if (downloadState.value is DownloadState.Error) {
updateDownloadProgressState(DownloadState.Idle, 0f)
}
}
fun startDownload() {
if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return
onFlowStarted?.invoke()
cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch {
mutex.withLock {
try {
@@ -78,11 +86,10 @@ class DownloadManager(
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
expireErrorStateToIdle()
return@withLock
}
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -136,6 +143,7 @@ class DownloadManager(
}
}
expireErrorStateToIdle()
}
} finally {
downloadJob = null
@@ -149,6 +157,7 @@ class DownloadManager(
if (!downloadScope.isActive) return
onFlowStarted?.invoke()
cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch {
mutex.withLock {
try {
@@ -174,11 +183,10 @@ class DownloadManager(
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
expireErrorStateToIdle()
return@withLock
}
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -232,6 +240,7 @@ class DownloadManager(
}
}
expireErrorStateToIdle()
}
} finally {
downloadJob = null
@@ -50,63 +50,36 @@ class DictionaryChecker(
}
}
suspend fun areDictionariesUpToDate(
suspend fun sourcesWithUpdates(
folderUri: Uri,
sources: List<DictionarySource>,
files: List<DocumentFileInfo>? = null
): Boolean = withContext(Dispatchers.IO) {
): Set<String> = withContext(Dispatchers.IO) {
try {
val enabledSources = sources.filter { it.isEnabled }
if (enabledSources.isEmpty()) {
return@withContext true
}
val currentDate = DateUtils.getCurrentDateString()
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 ->
dictFiles.hasFileForSource(source, currentDate)
}
if (allHaveToday) {
return@withContext true
}
val allHaveYesterday = datedSources.all { source ->
dictFiles.hasFileForSource(source, previousDate)
}
if (allHaveYesterday) {
val serverHasToday = datedSources.any { source ->
sources
.filter { it.isEnabled && DictionarySource.hasDatePlaceholder(it.urlTemplate) }
.filter { source ->
when {
dictFiles.hasFileForSource(source, currentDate) -> false
dictFiles.hasFileForSource(source, previousDate) ->
isSourceAvailableOnServer(source, currentDate)
}
return@withContext !serverHasToday
}
val serverCanOfferNewer = datedSources.any { source ->
when (probeSource(source, currentDate)) {
else -> when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
}
}
return@withContext !serverCanOfferNewer
}
.mapTo(mutableSetOf(), DictionarySource::id)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e)
true
emptySet()
}
}
@@ -25,21 +25,20 @@ class DictionaryRepository(
private val extractor = DictionaryExtractor(context, fileStorageManager)
private val cleaner = DictionaryCleaner(context, fileStorageManager)
private var filesBeforeDownload = mutableSetOf<String>()
private var downloadedPrefixes = mutableSetOf<String>()
private class DownloadSession(val filesBefore: Set<String>, val prefixes: Set<String>)
@Volatile
private var session: DownloadSession? = null
companion object {
private const val TAG = "DictionaryRepository"
}
suspend fun areDictionariesUpToDate(
sources: List<DictionarySource>? = null
): Boolean = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first()
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
suspend fun sourcesWithUpdates(
sources: List<DictionarySource>
): Set<String> = withContext(Dispatchers.IO) {
val uri = Uri.fromFile(File(preferencesManager.dictionaryPath))
checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri))
}
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
@@ -81,15 +80,10 @@ class DictionaryRepository(
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload = files.map { it.name }.toMutableSet()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes = sources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
session = DownloadSession(
filesBefore = files.mapTo(mutableSetOf()) { it.name },
prefixes = sources.mapNotNullTo(mutableSetOf()) { DictionarySource.extractPrefix(it.urlTemplate) },
)
downloader.downloadDictionaries(
folderUri = uri,
@@ -114,63 +108,22 @@ class DictionaryRepository(
cleaner.deleteUnprocessedFiles(uri, files)
val filesAfterStandardCleanup = fileStorageManager.listFilesInFolder(uri)
val current = session ?: return@withContext Result.success(Unit)
session = null
val existingFiles = synchronized(filesBeforeDownload) {
filesBeforeDownload.toSet()
fileStorageManager.listFilesInFolder(uri)
.filter { file ->
file.name !in current.filesBefore &&
current.prefixes.any { prefix -> file.name.startsWith("${prefix}_") }
}
val prefixes = synchronized(downloadedPrefixes) {
downloadedPrefixes.toSet()
}
val finalPrefixes = prefixes.ifEmpty {
val sources = preferencesManager.dictionarySources.first()
sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
}
val filesToDelete = if (existingFiles.isEmpty()) {
filesAfterStandardCleanup.filter { file ->
val fileName = file.name
finalPrefixes.any { prefix -> fileName.startsWith("${prefix}_") }
}
} else {
filesAfterStandardCleanup.filter { file ->
file.name !in existingFiles &&
finalPrefixes.any { prefix -> file.name.startsWith("${prefix}_") }
}
}
if (filesToDelete.isNotEmpty()) {
var deletedCount = 0
for ((fileName, uri) in filesToDelete) {
val docFile = fileStorageManager.getDocumentFile(uri)
if (docFile != null && docFile.exists()) {
val deleted = docFile.delete()
if (deleted) {
deletedCount++
} else {
val forceDeleted = fileStorageManager.forceDeleteFile(docFile, fileName)
if (!forceDeleted) {
.forEach { (fileName, fileUri) ->
val docFile = fileStorageManager.getDocumentFile(fileUri)
if (docFile != null && docFile.exists() && !docFile.delete() &&
!fileStorageManager.forceDeleteFile(docFile, fileName)
) {
Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName")
} else {
deletedCount++
}
}
}
}
}
synchronized(filesBeforeDownload) {
if (filesBeforeDownload.isNotEmpty()) {
filesBeforeDownload.clear()
}
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
Result.success(Unit)
}
@@ -183,15 +136,7 @@ class DictionaryRepository(
val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
val result = cleaner.performAllCleanup(uri, prefixes, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload.clear()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
session = null
result
}
}
@@ -14,8 +14,9 @@ import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.ui.article.ArticleState
import com.example.research.ui.article.ArticleTextWarmUp
import com.example.research.ui.article.DslBlock
import com.example.research.ui.article.DslAnnotatedParser
import com.example.research.ui.article.HEAVY_ARTICLE_BLOCK_COUNT
import com.example.research.ui.article.mergeDslBlocks
import com.example.research.R
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@@ -63,7 +64,7 @@ class SearchViewModel(
private var indexWarmupJob: Job? = null
private var debounceSaveQueryJob: Job? = null
private val parseCache = java.util.LinkedHashMap<String, List<DslBlock>>(PARSE_CACHE_SIZE, 0.75f, true)
private val parseCache = java.util.LinkedHashMap<String, ArticleState.Success>(PARSE_CACHE_SIZE, 0.75f, true)
private val articleHistory = ArrayDeque<ArticleState.Success>()
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
@@ -111,9 +112,7 @@ class SearchViewModel(
Pager(
config = PagingConfig(
pageSize = 12,
prefetchDistance = 1,
enablePlaceholders = false,
initialLoadSize = 12
enablePlaceholders = false
),
pagingSourceFactory = {
IndexEntryPagingSource(
@@ -211,12 +210,7 @@ class SearchViewModel(
val cached = synchronized(parseCache) { parseCache[cacheKey] }
if (cached != null) {
mutableSelectedArticle.update {
ArticleState.Success(
title = entry.originalWord,
dslContent = cached,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
cached.copy(title = entry.originalWord, dictionaryName = entry.dictionaryName)
}
return@launch
}
@@ -228,6 +222,13 @@ class SearchViewModel(
colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME,
)
}
val article = ArticleState.Success(
title = entry.originalWord,
dslContent = mergeDslBlocks(annotatedStringList),
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
isHeavy = annotatedStringList.size > HEAVY_ARTICLE_BLOCK_COUNT,
)
synchronized(parseCache) {
if (parseCache.size >= PARSE_CACHE_SIZE) {
val iterator = parseCache.entries.iterator()
@@ -235,19 +236,12 @@ class SearchViewModel(
iterator.next().run { iterator.remove() }
}
}
parseCache[cacheKey] = annotatedStringList
parseCache[cacheKey] = article
}
viewModelScope.launch(Dispatchers.Default) {
ArticleTextWarmUp.warmUp(getApplication(), annotatedStringList)
}
mutableSelectedArticle.update {
ArticleState.Success(
title = entry.originalWord,
dslContent = annotatedStringList,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
ArticleTextWarmUp.warmUp(getApplication(), article.dslContent)
}
mutableSelectedArticle.update { article }
}
.onError { message, exception ->
Log.e(TAG, "loadArticle() - error: $message", exception)
@@ -324,10 +318,6 @@ class SearchViewModel(
companion object {
private const val TAG = "SearchViewModel"
// Parse cache: stores parsed DslBlock lists to avoid re-parsing articles
// Size: 50 articles - typical user navigation depth in a session
// Memory estimate: ~50KB per article average (text + formatting)
// Total: ~2.5MB worst case
private const val PARSE_CACHE_SIZE = 50
private const val KEY_SEARCH_QUERY = "search_query"
@@ -19,7 +19,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -56,7 +55,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
onNavigateBack: () -> Unit
@@ -71,7 +69,7 @@ fun AboutScreen(
val uriHandler = LocalUriHandler.current
val librariesData by produceState<AboutLibrariesData?>(initialValue = null, context) {
value = withContext(Dispatchers.IO) {
AboutLibrariesParser.read(context, R.raw.aboutlibraries)
runCatching { AboutLibrariesParser.read(context, R.raw.aboutlibraries) }.getOrNull()
}
}
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
@@ -310,7 +308,6 @@ private fun AboutLibraryRow(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LicenseDialog(
license: AboutLicense,
@@ -44,7 +44,6 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
@@ -55,54 +54,6 @@ import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
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?,
@@ -183,8 +134,7 @@ fun ArticleContent(
}
}
val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val isHeavyArticle = articleState.isHeavy
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
@@ -192,14 +142,12 @@ fun ArticleContent(
baseTextStyle,
darkTheme
) {
val merged =
mergeDslBlocks(articleState.dslContent)
if (darkTheme) {
merged.map {
articleState.dslContent.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
}
} else {
merged
articleState.dslContent
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
@@ -37,6 +37,7 @@ sealed class ArticleState {
val dictionaryPath: String = "",
val scrollIndex: Int = 0,
val scrollOffset: Int = 0,
val isHeavy: Boolean = false,
) : ArticleState()
@Immutable
data class Error(
@@ -13,11 +13,14 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100
internal const val HEAVY_ARTICLE_TOTAL_CHARS = 1_500
object ArticleTextWarmUp {
suspend fun warmUp(context: Context, blocks: List<DslBlock>) {
if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT) return
if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT &&
blocks.sumOf { it.text.length } <= HEAVY_ARTICLE_TOTAL_CHARS
) return
val density = Density(context)
val measurer = TextMeasurer(
defaultFontFamilyResolver = createFontFamilyResolver(context),
@@ -0,0 +1,51 @@
package com.example.research.ui.article
import androidx.compose.ui.text.AnnotatedString
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
internal 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
}
@@ -107,6 +107,7 @@ fun AdaptiveMainScreen(
)
.padding(start = Spacing.contentStart, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
alignFirstResultWithTitleBar = isSearchBarAtBottom,
)
},
detailPane = {
@@ -124,7 +125,7 @@ fun AdaptiveMainScreen(
articleState = articleState,
showBackButton = isShowingDetail && articleState != null,
onNavigateBack = onNavigateBackFromArticle,
compact = true,
compact = !isSearchBarAtBottom,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
@@ -64,7 +64,11 @@ fun MainRoute(
dictionary.isActive && dictionary.articleCount > 0
}
settingsState.downloadState is DownloadState.Idle &&
val downloadSettled = settingsState.downloadState.let { state ->
state is DownloadState.Idle || state is DownloadState.Error
}
downloadSettled &&
!settingsState.indexingProgress.isIndexing &&
settingsState.importState !is ImportState.Importing &&
settingsState.importState !is ImportState.Extracting &&
@@ -216,6 +220,7 @@ internal fun MainScreen(
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp),
alignFirstResultWithTitleBar = true,
)
searchBar()
}
@@ -16,7 +16,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.text.rememberTextMeasurer
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.Spacing
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.example.research.core.domain.model.IndexEntry
@@ -43,6 +48,7 @@ internal fun SearchResultsPane(
onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier,
listModifier: Modifier = Modifier,
alignFirstResultWithTitleBar: Boolean = false,
) {
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition.first,
@@ -55,6 +61,7 @@ internal fun SearchResultsPane(
val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp()
}
val topPadding = if (alignFirstResultWithTitleBar) titleBarAlignedTopPadding() else 0.dp
LaunchedEffect(listState) {
snapshotFlow {
@@ -139,8 +146,21 @@ internal fun SearchResultsPane(
modifier = listModifier.weight(1f),
listState = listState,
showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding),
contentPadding = PaddingValues(top = topPadding, bottom = imeBottomPadding),
staleEntries = shownStale,
)
}
}
@Composable
private fun titleBarAlignedTopPadding(): Dp {
val textMeasurer = rememberTextMeasurer(cacheSize = 0)
val style = MaterialTheme.typography.dictionaryTitleLarge
val density = LocalDensity.current
return remember(textMeasurer, style, density) {
val titleHeight = with(density) {
textMeasurer.measure(text = "A", style = style, maxLines = 1).size.height.toDp()
}
(Spacing.topBarCenter - Spacing.listItemVertical - titleHeight / 2).coerceAtLeast(0.dp)
}
}
@@ -2,6 +2,7 @@ package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
@@ -28,7 +29,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -45,7 +47,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
@@ -72,6 +74,7 @@ private val TopBarHeight = Spacing.topBarHeight
private val TopBarShape = RoundedCornerShape(28.dp)
private val HistorySheetChrome = 64.dp
private val HistorySheetMinHeight = 300.dp
private val HistorySheetBottomPadding = 16.dp
@Composable
fun MainTopBar(
@@ -289,12 +292,16 @@ private fun SearchHistorySheet(
onEntryRemove: (String) -> Unit,
onClearAll: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val density = LocalDensity.current
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val windowHeightPx = LocalWindowInfo.current.containerSize.height
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
val maxContentHeight = remember {
with(density) {
val screenHeight = windowHeightPx.toDp()
if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
} else {
@@ -314,6 +321,7 @@ private fun SearchHistorySheet(
onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove,
onClearAll = onClearAll,
contentPadding = PaddingValues(bottom = HistorySheetBottomPadding),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
@@ -6,17 +6,13 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.*
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes
@@ -53,7 +49,7 @@ fun SearchResultsList(
contentType = { "search_result" }
) { index ->
val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
else if (index in 0 until results.itemCount) results[index] else null
if (entry != null) {
SearchResultItem(
entry = entry,
@@ -63,26 +59,6 @@ fun SearchResultsList(
)
}
}
when (results.loadState.append) {
is LoadState.Error -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.error_loading_more),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
else -> {}
}
}
}
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -104,7 +103,6 @@ fun SettingsRoute(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
uiState: SettingsUiState,
@@ -37,6 +37,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
private const val STATUS_UP_TO_DATE = "UpToDate"
private const val STATUS_NEEDS_UPDATE = "NeedsUpdate"
private const val STATUS_EMPTY = "Empty"
private data class PersistedDictionaryStatus(
val name: String,
val date: String,
val sourceIds: Set<String>,
)
private data class DictionaryStateInputs(
val theme: AppTheme,
val dictionarySources: List<DictionarySource>,
@@ -115,14 +125,18 @@ class SettingsViewModel(
private fun persistDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.collect { status ->
val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate"
DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty"
val persisted = when (status) {
DictionaryStatus.UpToDate ->
"$STATUS_UP_TO_DATE|${DateUtils.getCurrentDateString()}"
is DictionaryStatus.NeedsUpdate ->
"$STATUS_NEEDS_UPDATE|${DateUtils.getCurrentDateString()}|" +
status.sourceIds.joinToString(",")
DictionaryStatus.Empty ->
"$STATUS_EMPTY|${DateUtils.getCurrentDateString()}"
else -> null
}
if (persistedName != null) {
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
if (persisted != null) {
preferencesManager.saveDictionaryStatus(persisted)
}
}
}
@@ -202,14 +216,21 @@ class SettingsViewModel(
}
}
is DownloadState.Error -> {
val startedHere = statusBeforeDownload != DictionaryStatus.Unknown ||
isDownloadInProgress
isDownloadInProgress = false
if (!errorStateHandled) {
errorStateHandled = true
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else when (statusBeforeDownload) {
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
else -> statusBeforeDownload
if (dictionaryState.dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
} else if (startedHere) {
when (statusBeforeDownload) {
DictionaryStatus.Unknown, DictionaryStatus.Checking ->
viewModelScope.launch {
restoreOrEvaluateDictionaryStatus()
}
else -> dictionaryStatus.value = statusBeforeDownload
}
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
@@ -283,7 +304,7 @@ class SettingsViewModel(
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
} else {
dictionaryStatus.value = DictionaryStatus.UpToDate
restoreOrEvaluateDictionaryStatus()
false
}
} catch (e: Exception) {
@@ -365,8 +386,8 @@ class SettingsViewModel(
return DictionaryStatus.UpToDate
}
val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources)
return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate
val updatable = dictionaryRepository.sourcesWithUpdates(enabledSources)
return if (updatable.isEmpty()) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate(updatable)
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
@@ -551,6 +572,71 @@ class SettingsViewModel(
): List<DictionarySource> =
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
private suspend fun restoreOrEvaluateDictionaryStatus() {
val restored = restorePersistedDictionaryStatus()
if (restored != null) {
dictionaryStatus.value = restored
return
}
dictionaryStatus.value = DictionaryStatus.Checking
dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
}
private suspend fun restorePersistedDictionaryStatus(): DictionaryStatus? {
val persisted = readPersistedDictionaryStatus() ?: return null
if (persisted.date != DateUtils.getCurrentDateString()) return null
return when (persisted.name) {
STATUS_UP_TO_DATE ->
if (withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }) {
DictionaryStatus.UpToDate
} else {
null
}
STATUS_NEEDS_UPDATE -> {
val updatableIds = withContext(Dispatchers.IO) { sourcesMissingTodaysFile() }
val restorableIds = persisted.sourceIds intersect updatableIds
if (restorableIds.isNotEmpty()) DictionaryStatus.NeedsUpdate(restorableIds) else null
}
else -> null
}
}
private suspend fun readPersistedDictionaryStatus(): PersistedDictionaryStatus? {
val raw = preferencesManager.dictionaryStatus() ?: return null
val parts = raw.split('|')
if (parts.isEmpty() || parts.first().isEmpty()) return null
return PersistedDictionaryStatus(
name = parts.first(),
date = parts.getOrElse(1) { "" },
sourceIds = parts.getOrElse(2) { "" }
.split(',')
.map(String::trim)
.filter(String::isNotEmpty)
.toSet(),
)
}
private suspend fun sourcesMissingTodaysFile(): Set<String> {
val sources = preferencesManager.dictionarySources.first()
val dictionaries = localDictionaryRepository.dictionaries.first()
val datedEnabledSources = installedEnabledSources(sources, dictionaries)
.filter { DictionarySource.hasDatePlaceholder(it.urlTemplate) }
if (datedEnabledSources.isEmpty()) return emptySet()
val fileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
) ?: return emptySet()
val today = DateUtils.getCurrentDateString()
return datedEnabledSources
.filterNot { source -> fileNames.any { it.startsWith(fileBaseNameFor(source, today)) } }
.mapTo(mutableSetOf(), DictionarySource::id)
}
private fun fileBaseNameFor(source: DictionarySource, date: String): String =
DictionarySource.extractFileName(source.urlTemplate, date)
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
private suspend fun installedDictionariesAreCurrent(): Boolean {
val sources = preferencesManager.dictionarySources.first()
val dictionaries = localDictionaryRepository.dictionaries.first()
@@ -562,11 +648,7 @@ class SettingsViewModel(
) ?: return true
val today = DateUtils.getCurrentDateString()
return datedEnabledSources.all { source ->
val baseName = DictionarySource.extractFileName(source.urlTemplate, today)
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
fileNames.any { it.startsWith(baseName) }
fileNames.any { it.startsWith(fileBaseNameFor(source, today)) }
}
}
@@ -622,24 +704,7 @@ class SettingsViewModel(
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
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() &&
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
) {
dictionaryStatus.value = restoredStatus
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
restoreOrEvaluateDictionaryStatus()
}
if (result.data > 0) {
@@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.BadgedIcon
import com.example.research.core.domain.model.Dictionary
import kotlinx.coroutines.launch
@@ -84,7 +85,8 @@ fun DictionaryListItem(
onToggle: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false }
isDeleteBlocked: () -> Boolean = { false },
hasUpdate: Boolean = false,
) {
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
@@ -195,11 +197,15 @@ fun DictionaryListItem(
modifier = Modifier.size(IconButtonSize),
contentAlignment = Alignment.Center
) {
Icon(
BadgedIcon(
painter = painterResource(R.drawable.ic_book),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(IconSize)
size = IconSize,
badgeDescription = if (hasUpdate) {
stringResource(R.string.dictionary_update_available)
} else {
null
},
)
}
@@ -16,7 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -47,6 +48,7 @@ import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.EmptyState
import com.example.research.common.ui.components.SectionCard
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState
@@ -75,7 +77,10 @@ fun DictionaryManagement(
) {
val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val haptic = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current
@@ -90,6 +95,21 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
}
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
val isUpdateAvailable = dictionaryStatus is DictionaryStatus.NeedsUpdate &&
!isInProgress && hasEnabledSources && dictionaries.isNotEmpty()
val updatablePaths = remember(dictionaryStatus, sources, dictionaries, isUpdateAvailable) {
val ids = (dictionaryStatus as? DictionaryStatus.NeedsUpdate)?.sourceIds
if (!isUpdateAvailable || ids.isNullOrEmpty()) {
emptySet()
} else {
val updatableSources = sources.filter { it.id in ids }
dictionaries
.filter { dictionary ->
updatableSources.any { DictionarySourceFileMatcher.matches(it, dictionary) }
}
.mapTo(mutableSetOf(), Dictionary::path)
}
}
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
@@ -118,7 +138,12 @@ fun DictionaryManagement(
isExpanded = isExpanded,
onToggle = onToggleExpanded,
subtitle = headerSubtitle,
testTag = "dictionary_header"
testTag = "dictionary_header",
badgeDescription = if (isUpdateAvailable && !isExpanded) {
stringResource(R.string.dictionaries_update_available)
} else {
null
},
)
AnimatedVisibility(
@@ -178,7 +203,7 @@ fun DictionaryManagement(
)
}
} else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) {
if (isUpdateAvailable) {
DictionaryUpdateCard(
onStartDownload = onStartDownload,
modifier = backgroundModifier
@@ -192,7 +217,8 @@ fun DictionaryManagement(
modifier = Modifier
.fillMaxWidth()
.then(backgroundModifier),
isDeleteBlocked = isDeleteBlocked
isDeleteBlocked = isDeleteBlocked,
updatablePaths = updatablePaths,
)
if (!isInProgress) {
@@ -264,6 +290,7 @@ private fun DictionaryProgressSectionHost(
label = label,
onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0,
indeterminate = progressSnapshot?.indeterminate ?: false,
testTag = progressTestTag,
)
}
@@ -274,7 +301,8 @@ private fun DictionaryListSection(
onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false }
isDeleteBlocked: () -> Boolean = { false },
updatablePaths: Set<String> = emptySet(),
) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) {
@@ -301,7 +329,8 @@ private fun DictionaryListSection(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) },
isDeleteBlocked = isDeleteBlocked
isDeleteBlocked = isDeleteBlocked,
hasUpdate = dictionary.path in updatablePaths,
)
}
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
@@ -31,6 +32,7 @@ fun DictionaryProgressSection(
onCancel: () -> Unit,
modifier: Modifier = Modifier,
percent: Int,
indeterminate: Boolean,
testTag: String,
) {
AnimatedVisibility(
@@ -44,6 +46,7 @@ fun DictionaryProgressSection(
var displayedLabel by remember { mutableStateOf(label) }
var displayedPercent by remember { mutableIntStateOf(percent) }
var displayedTestTag by remember { mutableStateOf(testTag) }
var displayedIndeterminate by remember { mutableStateOf(indeterminate) }
LaunchedEffect(visible) {
if (visible) {
@@ -60,6 +63,7 @@ fun DictionaryProgressSection(
displayedLabel = label
displayedPercent = percent
displayedTestTag = testTag
displayedIndeterminate = indeterminate
}
}
val animatedState = remember { Animatable(0f) }
@@ -88,13 +92,23 @@ fun DictionaryProgressSection(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
if (!displayedIndeterminate) {
Text(
text = "$displayedPercent%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(8.dp))
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(WavyProgressIndicatorDefaults.LinearContainerHeight),
contentAlignment = Alignment.Center,
) {
if (displayedIndeterminate) {
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
@@ -102,7 +116,9 @@ fun DictionaryProgressSection(
.height(4.dp)
.clip(MaterialTheme.shapes.small),
)
Spacer(Modifier.height(8.dp))
}
}
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
@@ -6,6 +6,7 @@ object Spacing {
// Semantic spacing constants for specific UI elements
val topBarPadding = 8.dp
val topBarHeight = 56.dp
val topBarCenter = topBarPadding + topBarHeight / 2
val listItemVertical = 8.dp
val contentStart = 32.dp
+1 -1
View File
@@ -62,7 +62,6 @@
<string name="error_scan_directory_failed">Не удалось просканировать папку со словарями</string>
<string name="error_search_failed">Ошибка поиска запроса \'%1$s\' в %2$s</string>
<string name="error_failed_load_index_metadata">Не удалось загрузить метаданные индекса: %1$s</string>
<string name="error_loading_more">Ошибка загрузки</string>
<plurals name="active_dictionaries_count">
<item quantity="one">%1$d активный словарь</item>
<item quantity="few">%1$d активных словаря</item>
@@ -88,6 +87,7 @@
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
<string name="dictionaries_not_downloaded">Словари еще не загружены</string>
<string name="dictionaries_update_available">Доступны обновления словарей</string>
<string name="dictionary_update_available">Доступно обновление</string>
<string name="action_cancel">Отмена</string>
<string name="action_add">Добавить</string>
<string name="action_add_another_url">Добавить ещё URL</string>
+1 -1
View File
@@ -62,7 +62,6 @@
<string name="error_scan_directory_failed">Failed to scan dictionary folder</string>
<string name="error_search_failed">Search failed for query \'%1$s\' in %2$s</string>
<string name="error_failed_load_index_metadata">Failed to load index metadata: %1$s</string>
<string name="error_loading_more">Loading error</string>
<plurals name="active_dictionaries_count">
<item quantity="one">%1$d active dictionary</item>
<item quantity="other">%1$d active dictionaries</item>
@@ -86,6 +85,7 @@
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
<string name="dictionaries_update_available">Dictionary updates available</string>
<string name="dictionary_update_available">Update available</string>
<string name="action_cancel">Cancel</string>
<string name="action_add">Add</string>
<string name="action_add_another_url">Add another URL</string>
+1 -1
View File
@@ -2,7 +2,7 @@ plugins {
alias(libs.plugins.aboutlibraries) apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
id("com.github.ben-manes.versions") version "0.54.0"
id("com.github.ben-manes.versions") version "0.65.0"
}
tasks.register("assembleDebug") {
dependsOn(":app:assembleDebug")
@@ -0,0 +1,6 @@
- Search loads all prefix matches; broad prefix searches are much faster
- Fixed indexing progress freezing on large dictionaries
- Large articles open faster
- Numbered results sort in numeric order
- UI improvements
- Updated libraries and build tooling
Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 229 KiB

@@ -0,0 +1,6 @@
- Поиск загружает все совпадения по префиксу и стал значительно быстрее
- Исправлено зависание прогресса индексации больших словарей
- Большие статьи открываются быстрее
- Нумерованные результаты идут по порядку номеров
- Улучшения интерфейса
- Обновлены библиотеки и инструментарий сборки
Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 229 KiB

+7 -6
View File
@@ -1,23 +1,24 @@
[versions]
aboutlibraries = "15.2.0"
aboutlibraries = "15.3.0"
activity_compose = "1.13.0"
agp = "9.4.1"
compose_bom = "2026.09.00"
core = "1.19.1"
datastore_preferences = "1.2.1"
datastore_preferences = "1.3.0"
documentfile = "1.1.0"
kotlin = "2.4.20"
kotlin = "2.4.21"
lifecycle_runtime_ktx = "2.11.0"
material3 = "1.5.0-beta01"
okhttp = "5.5.0"
paging = "3.5.1"
paging = "3.5.3"
profileinstaller = "1.4.1"
kotlinx_coroutines = "1.11.0"
kotlinx_serialization = "1.11.0"
kotlinx_serialization = "1.12.0"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" }
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
Vendored
+41 -11
View File
@@ -26,6 +26,33 @@
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%