22 Commits
Author SHA1 Message Date
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
OneWay 4e75c7d865 Release 1.7.0
- Added search history
- The search bar can be placed at the top or bottom of the screen
- Fixed headwords with stress marks displaying truncated in search results
- Improved download, indexing and navigation stability
- The About screen opens faster
- Updated build tooling and code cleanup
2026-10-07 01:33:27 +08:00
OneWay 5a0426c864 fix(main): populate the search field and results on history click in wide mode
HistoryEntryClicked in wide mode only called SearchAndLoadArticle,
which never updates searchQuery, so the field stayed empty and the
results pager took the query.isBlank() branch with PagingData.empty().
Now dispatch onSearchQueryChanged first, then SearchAndLoadArticle.
2026-10-07 00:43:15 +08:00
OneWay 5b6bb08248 fix(article): align the back arrow with the title in compact mode
In the two-pane compact title bar the arrow sat slightly above
the first title line; offset it down to optically center it.
2026-10-07 00:43:15 +08:00
58 changed files with 745 additions and 625 deletions

No files matched your search

+12 -7
View File
@@ -1,6 +1,6 @@
<h1 align="center">ReSearch</h1> <h1 align="center">ReSearch</h1>
<h4 align="center">A fast Android dictionary reader for DSL-format dictionaries.</h4> <h4 align="center">An Android dictionary reader for DSL-format dictionaries.</h4>
<p align="center"> <p align="center">
<a href="#screenshots">Screenshots</a> &bull; <a href="#screenshots">Screenshots</a> &bull;
@@ -15,21 +15,26 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
## Screenshots ## Screenshots
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.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 article screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.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 ## Features
- Fast headword search across multiple DSL dictionaries. - Fast headword search across multiple DSL dictionaries.
- Search history in the empty search state: recent queries with per-entry removal and clear-all. - Search history in a bottom sheet: recent queries with per-entry removal and clear-all.
- Look up selected text from other apps via the selection menu.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files. - Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases. - Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
- Multiple dictionary source URLs can be added in one list.
- Automatic extraction, DictZip compression, and indexing. - Automatic extraction, DictZip compression, and indexing.
- Faithful article rendering with DSL indentation, margins, and named colors. - Faithful article rendering with DSL indentation, margins, and named colors.
- Offline search after dictionaries are indexed. - Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries. - Random-access article loading from DictZip dictionaries.
- Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification. - Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification.
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe. - Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
- Search bar placement at the top or bottom of the screen.
- Adaptive two-pane layout for tablets and large screens.
- Material 3 interface with dynamic color, light, dark, and system themes. - Material 3 interface with dynamic color, light, dark, and system themes.
- English and Russian localization. - English and Russian localization.
- About screen with open source license information. - About screen with open source license information.
@@ -80,12 +85,12 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack ## Tech Stack
- Kotlin 2.4.20 - Kotlin 2.4.21
- Jetpack Compose (BOM 2026.09.00) - Jetpack Compose (BOM 2026.09.00)
- Material 3 - Material 3 1.5.0-beta01
- Coroutines and Flow 1.11.0 - Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1 - DataStore Preferences 1.2.1
- Paging 3.5.1 - Paging 3.5.2
- OkHttp 5.5.0 - OkHttp 5.5.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.11.0
- AboutLibraries 15.2.0 metadata generation - AboutLibraries 15.2.0 metadata generation
+2 -11
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research" applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt() minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 9 versionCode = 12
versionName = "1.6.0" versionName = "1.8.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -164,26 +164,17 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.documentfile) implementation(libs.androidx.documentfile)
val composeBom = platform(libs.androidx.compose.bom) val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom) implementation(composeBom)
implementation(libs.bundles.compose) implementation(libs.bundles.compose)
implementation(libs.androidx.compose.material3.adaptive.layout) implementation(libs.androidx.compose.material3.adaptive.layout)
debugImplementation(libs.androidx.compose.runtime.tracing) debugImplementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp) implementation(libs.okhttp)
// Coroutines with Android-optimized dispatchers
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
} }
@@ -13,9 +13,6 @@ object ReSearchTrace {
const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract" const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract"
const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index" const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index"
const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import" 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() 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. HSPLcom/example/research/ReSearchApplication**->**(**)**
# Keep this profile checked in: release builders, including F-Droid, only compile it. HSPLcom/example/research/MainActivity**->**(**)**
# Updated 2026-06-25: Added optimized cache paths from performance optimization stages 1-4 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/ui/main/**->**(**)**
HSPLcom/example/research/MainActivity;->**(**)** HSPLandroidx/paging/**->**(**)**
HSPLcom/example/research/feature/search/**->**(**)**
HSPLcom/example/research/common/ui/AppLocaleProviderKt**->**(**)** HSPLcom/example/research/data/paging/**->**(**)**
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/data/parser/IndexSearcher**->**(**)** 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/**->**(**)**
HSPLcom/example/research/data/repository/KotlinDictionaryEngine**->**(**)** 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/data/parser/DslArticleReader**->**(**)**
HSPLcom/example/research/ui/article/DslAnnotatedParser**->**(**)** HSPLcom/example/research/data/parser/LruCharsetCache**->**(**)**
HSPLcom/example/research/ui/article/ArticleScreenKt**->**(**)** HSPLcom/example/research/ui/article/**->**(**)**
# Hot paths for optimized article rendering (Stage 1) HSPLcom/example/research/data/parser/Dsl**->**(**)**
HSPLcom/example/research/ui/article/ArticleLinkText**->**(**)** HSPLcom/example/research/data/parser/LineKind**->**(**)**
HSPLcom/example/research/ui/article/ArticleState**->**(**)** 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**->**(**)** 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/** Lcom/example/research/**
# Compose framework: the alpha AARs ship near-empty baseline profiles, so first
# compositions run interpreted without these rules.
HSPLandroidx/compose/runtime/**->**(**)** HSPLandroidx/compose/runtime/**->**(**)**
Landroidx/compose/runtime/** Landroidx/compose/runtime/**
HSPLandroidx/compose/ui/**->**(**)** HSPLandroidx/compose/ui/**->**(**)**
@@ -61,12 +55,10 @@ HSPLandroidx/compose/animation/**->**(**)**
Landroidx/compose/animation/** Landroidx/compose/animation/**
HSPLandroidx/compose/material/**->**(**)** HSPLandroidx/compose/material/**->**(**)**
Landroidx/compose/material/** Landroidx/compose/material/**
# Support libraries on the same cold path
HSPLandroidx/lifecycle/**->**(**)** HSPLandroidx/lifecycle/**->**(**)**
Landroidx/lifecycle/** Landroidx/lifecycle/**
HSPLandroidx/activity/compose/**->**(**)** HSPLandroidx/activity/compose/**->**(**)**
Landroidx/activity/compose/** Landroidx/activity/compose/**
HSPLkotlinx/coroutines/**->**(**)** HSPLkotlinx/coroutines/**->**(**)**
Lkotlinx/coroutines/** Lkotlinx/coroutines/**
Landroidx/paging/**
@@ -7,7 +7,7 @@ sealed interface DictionaryStatus {
data object Unknown : DictionaryStatus data object Unknown : DictionaryStatus
data object Checking : DictionaryStatus data object Checking : DictionaryStatus
data object UpToDate : DictionaryStatus data object UpToDate : DictionaryStatus
data object NeedsUpdate : DictionaryStatus data class NeedsUpdate(val sourceIds: Set<String>) : DictionaryStatus
data object Empty : DictionaryStatus data object Empty : DictionaryStatus
data class Error(val message: String) : DictionaryStatus data class Error(val message: String) : DictionaryStatus
} }
@@ -21,14 +21,16 @@ object DictionaryProgressModel {
val progress: Float, val progress: Float,
val percent: Int, val percent: Int,
@param:StringRes val titleRes: Int, @param:StringRes val titleRes: Int,
val indeterminate: Boolean = false,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is Snapshot) return false 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 { override fun hashCode(): Int {
return 31 * percent + titleRes return 31 * (31 * percent + titleRes) + indeterminate.hashCode()
} }
} }
fun computeProgressSnapshot( fun computeProgressSnapshot(
@@ -92,6 +94,7 @@ object DictionaryProgressModel {
progress = p, progress = p,
percent = pct, percent = pct,
titleRes = R.string.notification_download_title, titleRes = R.string.notification_download_title,
indeterminate = dl <= 0f,
) )
} }
@@ -1,37 +1,86 @@
package com.example.research.common.progress package com.example.research.common.progress
import android.os.SystemClock
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
class DictionaryProgressPresenter( class DictionaryProgressPresenter(
scope: CoroutineScope, private val scope: CoroutineScope,
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>, private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
private val notifications: NotificationPort, private val notifications: NotificationPort,
mainDispatcher: CoroutineDispatcher = Dispatchers.Main, private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : PipelineTerminalUi { ) : PipelineTerminalUi {
private companion object {
val MIN_NOTIFICATION_INTERVAL = 500.milliseconds
}
@Volatile @Volatile
private var terminalActive = false 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 { init {
scope.launch(mainDispatcher) { scope.launch(mainDispatcher) {
progressSnapshot.collect { snapshot -> progressSnapshot.collect { snapshot ->
when { when {
snapshot == null -> { snapshot == null -> {
resetThrottle()
if (!terminalActive && notifications.hasActiveProgressNotification()) { if (!terminalActive && notifications.hasActiveProgressNotification()) {
notifications.cancelProgressNotification() 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() { fun beginFlow() {
terminalActive = false terminalActive = false
resetThrottle()
notifications.cancelTerminalNotification() notifications.cancelTerminalNotification()
} }
@@ -52,6 +101,7 @@ class DictionaryProgressPresenter(
override fun cancelProgress() { override fun cancelProgress() {
terminalActive = false terminalActive = false
resetThrottle()
notifications.cancelProgressNotification() 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.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -29,7 +28,8 @@ fun CollapsibleHeader(
onToggle: () -> Unit, onToggle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
testTag: String? = null testTag: String? = null,
badgeDescription: String? = null,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -43,11 +43,11 @@ fun CollapsibleHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( BadgedIcon(
painter = painterResource(iconRes), painter = painterResource(iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp) size = 24.dp,
badgeDescription = badgeDescription,
) )
Column( 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)
}
@@ -15,49 +15,17 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import java.io.File 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( class IndexEntryPagingSource(
private val indexSearcher: IndexSearcher, private val indexSearcher: IndexSearcher,
private val dictionaries: List<Dictionary>, dictionaries: List<Dictionary>,
private val query: String private val query: String
) : PagingSource<IndexEntryPagingSource.TailKey, IndexEntry>() { ) : PagingSource<Int, IndexEntry>() {
data class StreamCursor( private val activeDictionaries = dictionaries.filter { it.isActive }
val dictPosition: Int,
val cursor: IndexSearcher.RangeCursor
)
data class TailKey(val streams: List<StreamCursor>) override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IndexEntry> {
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> {
return try { return try {
val key = params.key loadAll()
if (key == null) loadHead() else loadTail(key, params.loadSize)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@@ -65,21 +33,22 @@ class IndexEntryPagingSource(
} }
} }
private suspend fun loadHead(): LoadResult<TailKey, IndexEntry> = private suspend fun loadAll(): LoadResult<Int, IndexEntry> =
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) { ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) {
coroutineScope { coroutineScope {
val perDictionary = activeDictionaries val perDictionary = activeDictionaries
.map { (position, dict) -> .map { dict ->
async { async {
val indexFile = File(dict.indexPath) val indexFile = File(dict.indexPath)
if (!indexFile.exists()) return@async null if (!indexFile.exists()) return@async null
try { try {
val result = indexSearcher.searchWithCursors( val entries = indexSearcher.search(
pathOrUri = indexFile.absolutePath, pathOrUri = indexFile.absolutePath,
query = query query = query,
includeSubstringMatches = false
) )
Triple(position, dict, result) dict to entries
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: Exception) { } catch (_: Exception) {
@@ -90,9 +59,9 @@ class IndexEntryPagingSource(
.awaitAll() .awaitAll()
.filterNotNull() .filterNotNull()
val rawResults = perDictionary.flatMap { (position, dict, result) -> val rawResults = perDictionary.flatMap { (dict, entries) ->
result.entries.map { entry -> entries.map { entry ->
position to entry.withDictionary( entry.withDictionary(
dict.metadata.name.ifBlank { dict.name }, dict.metadata.name.ifBlank { dict.name },
dict.path dict.path
) )
@@ -100,95 +69,19 @@ class IndexEntryPagingSource(
} }
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val ranked = rawResults.map { it.second } val ranked = rawResults
.rankBySearchRelevance(SearchRankingContext(normalizedQuery)) .rankBySearchRelevance(SearchRankingContext(normalizedQuery))
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) } .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( LoadResult.Page(
data = ranked, data = ranked,
prevKey = null, prevKey = null,
nextKey = if (streams.isEmpty()) null else TailKey(streams) nextKey = null
) )
} }
} }
private suspend fun loadTail(key: TailKey, loadSize: Int): LoadResult<TailKey, IndexEntry> { override fun getRefreshKey(state: PagingState<Int, IndexEntry>): Int? {
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? {
return null return null
} }
} }
private const val MIN_CHUNK = 48
@@ -49,6 +49,7 @@ class DslIndexer(context: Context? = null) {
suspend fun createIndex( suspend fun createIndex(
dslInputStream: java.io.InputStream, dslInputStream: java.io.InputStream,
indexFile: File, indexFile: File,
totalInputBytes: Long = -1L,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
onOperationChange: (String) -> Unit = {}, onOperationChange: (String) -> Unit = {},
onFileProgress: (Float) -> Unit = {}, onFileProgress: (Float) -> Unit = {},
@@ -65,7 +66,7 @@ class DslIndexer(context: Context? = null) {
try { foldedIndexFile(indexFile).delete() } catch (_: Throwable) { /* Legacy v5 leftover */ } try { foldedIndexFile(indexFile).delete() } catch (_: Throwable) { /* Legacy v5 leftover */ }
onOperationChange("Parsing file") onOperationChange("Parsing file")
onFileProgress(0.0f) onFileProgress(0.0f)
counts = parseDslToTempFile(bufferedStream, tempFile, counts = parseDslToTempFile(bufferedStream, tempFile, totalInputBytes,
onProgress, onFileProgress, onCharsetDetected) onProgress, onFileProgress, onCharsetDetected)
onFileProgress(0.5f) onFileProgress(0.5f)
onOperationChange("Sorting index") onOperationChange("Sorting index")
@@ -88,6 +89,7 @@ class DslIndexer(context: Context? = null) {
private suspend fun parseDslToTempFile( private suspend fun parseDslToTempFile(
inputStream: java.io.InputStream, inputStream: java.io.InputStream,
tempFile: File, tempFile: File,
totalInputBytes: Long,
onProgress: (Int) -> Unit, onProgress: (Int) -> Unit,
onFileProgress: (Float) -> Unit = {}, onFileProgress: (Float) -> Unit = {},
onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {} onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {}
@@ -230,8 +232,12 @@ class DslIndexer(context: Context? = null) {
val yieldThreshold = adaptiveConfig.yieldInterval val yieldThreshold = adaptiveConfig.yieldInterval
if (yieldCounter >= yieldThreshold) { if (yieldCounter >= yieldThreshold) {
onProgress(entryCount) onProgress(entryCount)
val estimatedProgress = (lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000) val parseProgress = if (totalInputBytes > 0L) {
onFileProgress(estimatedProgress.coerceIn(0f, 0.4f)) (reader.position.toFloat() / totalInputBytes.toFloat())
} else {
(lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000)
}
onFileProgress(parseProgress.coerceIn(0f, 0.4f))
if (resourceManager != null) { if (resourceManager != null) {
if (resourceManager.shouldYield(adaptiveConfig)) { if (resourceManager.shouldYield(adaptiveConfig)) {
@@ -18,6 +18,7 @@ import kotlinx.coroutines.yield
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.Closeable import java.io.Closeable
import java.io.DataInputStream import java.io.DataInputStream
import java.io.EOFException
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.RandomAccessFile import java.io.RandomAccessFile
@@ -25,7 +26,6 @@ import java.io.RandomAccessFile
class IndexSearcher(private val context: Context) { class IndexSearcher(private val context: Context) {
private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L) private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L)
private val resultsCache = ResultsLruCache<List<IndexEntry>>(maxEntries = 20) private val resultsCache = ResultsLruCache<List<IndexEntry>>(maxEntries = 20)
private val cursorResultsCache = ResultsLruCache<CursorSearchResult>(maxEntries = 20)
/** /**
* Pre-allocated reusable buffer for readEntry() to reduce allocations. * Pre-allocated reusable buffer for readEntry() to reduce allocations.
@@ -45,27 +45,6 @@ class IndexSearcher(private val context: Context) {
val dataStartOffset: Long 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) { suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
try { try {
@@ -109,7 +88,7 @@ class IndexSearcher(private val context: Context) {
} }
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val foldedQuery = normalizedQuery.foldLatinDiacritics() val foldedQuery = normalizedQuery.foldLatinDiacritics()
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}" val cacheKey = "$pathOrUri:$includeSubstringMatches:${normalizedQuery.sanitizeCacheKey()}"
resultsCache.get(cacheKey)?.let { resultsCache.get(cacheKey)?.let {
return@withContext it return@withContext it
} }
@@ -118,9 +97,9 @@ class IndexSearcher(private val context: Context) {
val count = metadata.totalCount val count = metadata.totalCount
if (count == 0) return@withContext emptyList() if (count == 0) return@withContext emptyList()
val results = openSource(pathOrUri).use { source -> val results = openSource(pathOrUri).use { source ->
val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).entries.toMutableList() val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).toMutableList()
if (foldedQuery != normalizedQuery) { if (foldedQuery != normalizedQuery) {
prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery).entries) prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery))
} }
val substringMatches = mutableListOf<IndexEntry>() val substringMatches = mutableListOf<IndexEntry>()
@@ -165,81 +144,9 @@ class IndexSearcher(private val context: Context) {
metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) } 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) { fun trimMemory(level: Int) {
metadataCache.trim(level) metadataCache.trim(level)
resultsCache.trim(level) resultsCache.trim(level)
cursorResultsCache.trim(level)
} }
suspend fun isMetadataLoaded(pathOrUri: String): Boolean { suspend fun isMetadataLoaded(pathOrUri: String): Boolean {
@@ -349,9 +256,9 @@ class IndexSearcher(private val context: Context) {
source: RandomAccessSource, source: RandomAccessSource,
metadata: IndexMetadata, metadata: IndexMetadata,
query: String query: String
): RangeScanResult { ): List<IndexEntry> {
val firstMatch = findFirstMatch(source, metadata, query) val firstMatch = findFirstMatch(source, metadata, query)
?: return RangeScanResult(emptyList(), null) ?: return emptyList()
return readMatchingEntries(source, metadata, firstMatch, query) return readMatchingEntries(source, metadata, firstMatch, query)
} }
@@ -415,26 +322,21 @@ class IndexSearcher(private val context: Context) {
metadata: IndexMetadata, metadata: IndexMetadata,
firstMatch: Pair<Int, IndexEntry>, firstMatch: Pair<Int, IndexEntry>,
query: String query: String
): RangeScanResult { ): List<IndexEntry> {
val results = mutableListOf<IndexEntry>() val results = mutableListOf<IndexEntry>()
results.add(firstMatch.second) results.add(firstMatch.second)
val uniqueArticles = hashSetOf(firstMatch.second.articleKey())
var currentIdx = firstMatch.first + 1 var currentIdx = firstMatch.first + 1
var iterationCount = 0 var iterationCount = 0
while (currentIdx < metadata.totalCount) { while (currentIdx < metadata.totalCount) {
if (uniqueArticles.size >= MAX_PREFIX_RESULTS) {
return RangeScanResult(results, RangeCursor(query, currentIdx, source.position()))
}
if (iterationCount % 1000 == 0) yield() if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source, metadata) val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase() val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(query)) break if (!entryWordLower.startsWith(query)) break
uniqueArticles.add(entry.articleKey())
results.add(entry.copy(word = entryWordLower)) results.add(entry.copy(word = entryWordLower))
currentIdx++ currentIdx++
iterationCount++ iterationCount++
} }
return RangeScanResult(results, null) return results
} }
private fun readPooledString(source: RandomAccessSource, length: Int): String { private fun readPooledString(source: RandomAccessSource, length: Int): String {
@@ -483,7 +385,6 @@ class IndexSearcher(private val context: Context) {
private const val TAG = "IndexSearcher" private const val TAG = "IndexSearcher"
private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000 private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000
private const val SPARSE_INTERVAL_V1 = 128 private const val SPARSE_INTERVAL_V1 = 128
private const val MAX_PREFIX_RESULTS = 200
private const val MAX_SUBSTRING_RESULTS = 100 private const val MAX_SUBSTRING_RESULTS = 100
} }
} }
@@ -588,30 +489,89 @@ interface RandomAccessSource : Closeable {
} }
class FileSource(file: File) : RandomAccessSource { class FileSource(file: File) : RandomAccessSource {
private val raf = RandomAccessFile(file, "r") 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) { 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) { override fun readFully(b: ByteArray) {
raf.readFully(b) readFully(b, 0, b.size)
} }
override fun readFully(b: ByteArray, off: Int, len: Int) { override fun readFully(b: ByteArray, off: Int, len: Int) {
raf.readFully(b, off, len) 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() { override fun close() {
raf.close() raf.close()
} }
private companion object {
private const val BUFFER_SIZE = 64 * 1024
}
} }
@@ -1,6 +1,7 @@
package com.example.research.data.parser package com.example.research.data.parser
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.NaturalStringComparator
import com.example.research.core.util.foldLatinDiacritics import com.example.research.core.util.foldLatinDiacritics
internal data class SearchRankingContext( internal data class SearchRankingContext(
@@ -40,6 +41,11 @@ internal fun List<IndexEntry>.rankBySearchRelevance(
entry = entry, entry = entry,
) )
} }
.sortedWith(compareBy({ it.matchTier }, { it.exactMatch }, { it.charCount }, { it.word })) .sortedWith(
compareBy<SortKey> { it.matchTier }
.thenBy { it.exactMatch }
.thenBy { it.charCount }
.thenBy(NaturalStringComparator) { it.word }
)
.map { it.entry } .map { it.entry }
} }
@@ -22,6 +22,7 @@ import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.io.RandomAccessFile
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
@@ -51,12 +52,14 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
val indexFile = File(indexPath) val indexFile = File(indexPath)
val metadata = readMetadata(dictPath) val metadata = readMetadata(dictPath)
val totalInputBytes = uncompressedInputLength(dictFile)
val articleCount = withContext(Dispatchers.IO) { val articleCount = withContext(Dispatchers.IO) {
openDslInputStream(dictPath).use { inputStream -> openDslInputStream(dictPath).use { inputStream ->
dslIndexer.createIndex( dslIndexer.createIndex(
dslInputStream = inputStream, dslInputStream = inputStream,
indexFile = indexFile, indexFile = indexFile,
totalInputBytes = totalInputBytes,
onProgress = { count -> onProgress = { count ->
onArticlesIndexed(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 { private fun resolveIndexPath(indexArg: String): String {
val candidate = File(indexArg) val candidate = File(indexArg)
if (candidate.exists()) return candidate.absolutePath if (candidate.exists()) return candidate.absolutePath
@@ -57,6 +57,7 @@ class DownloadManager(
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke() onFlowStarted?.invoke()
cancelCleanupJob = null cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
@@ -81,8 +82,6 @@ class DownloadManager(
return@withLock return@withLock
} }
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -149,6 +148,7 @@ class DownloadManager(
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke() onFlowStarted?.invoke()
cancelCleanupJob = null cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
@@ -177,8 +177,6 @@ class DownloadManager(
return@withLock return@withLock
} }
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -50,63 +50,36 @@ class DictionaryChecker(
} }
} }
suspend fun areDictionariesUpToDate( suspend fun sourcesWithUpdates(
folderUri: Uri, folderUri: Uri,
sources: List<DictionarySource>, sources: List<DictionarySource>,
files: List<DocumentFileInfo>? = null files: List<DocumentFileInfo>? = null
): Boolean = withContext(Dispatchers.IO) { ): Set<String> = withContext(Dispatchers.IO) {
try { try {
val enabledSources = sources.filter { it.isEnabled }
if (enabledSources.isEmpty()) {
return@withContext true
}
val currentDate = DateUtils.getCurrentDateString() val currentDate = DateUtils.getCurrentDateString()
val previousDate = DateUtils.getPreviousDateString() val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files) val dictFiles = getDictFiles(folderUri, files)
sources
val datedSources = enabledSources.filter { source -> .filter { it.isEnabled && DictionarySource.hasDatePlaceholder(it.urlTemplate) }
DictionarySource.hasDatePlaceholder(source.urlTemplate) .filter { source ->
} when {
if (datedSources.isEmpty()) { dictFiles.hasFileForSource(source, currentDate) -> false
return@withContext true dictFiles.hasFileForSource(source, previousDate) ->
} isSourceAvailableOnServer(source, currentDate)
else -> when (probeSource(source, currentDate)) {
val allHaveToday = datedSources.all { source -> SourceAvailability.AVAILABLE -> true
dictFiles.hasFileForSource(source, currentDate) SourceAvailability.NOT_FOUND ->
} probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
if (allHaveToday) { }
return@withContext true }
}
val allHaveYesterday = datedSources.all { source ->
dictFiles.hasFileForSource(source, previousDate)
}
if (allHaveYesterday) {
val serverHasToday = datedSources.any { source ->
isSourceAvailableOnServer(source, currentDate)
} }
.mapTo(mutableSetOf(), DictionarySource::id)
return@withContext !serverHasToday
}
val serverCanOfferNewer = datedSources.any { source ->
when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
}
}
return@withContext !serverCanOfferNewer
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e) 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 extractor = DictionaryExtractor(context, fileStorageManager)
private val cleaner = DictionaryCleaner(context, fileStorageManager) private val cleaner = DictionaryCleaner(context, fileStorageManager)
private var filesBeforeDownload = mutableSetOf<String>() private class DownloadSession(val filesBefore: Set<String>, val prefixes: Set<String>)
private var downloadedPrefixes = mutableSetOf<String>()
@Volatile
private var session: DownloadSession? = null
companion object { companion object {
private const val TAG = "DictionaryRepository" private const val TAG = "DictionaryRepository"
} }
suspend fun areDictionariesUpToDate( suspend fun sourcesWithUpdates(
sources: List<DictionarySource>? = null sources: List<DictionarySource>
): Boolean = withContext(Dispatchers.IO) { ): Set<String> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath val uri = Uri.fromFile(File(preferencesManager.dictionaryPath))
val uri = Uri.fromFile(File(folderUri)) checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri))
val files = fileStorageManager.listFilesInFolder(uri)
val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first()
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
} }
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> = suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
@@ -81,15 +80,10 @@ class DictionaryRepository(
val files = fileStorageManager.listFilesInFolder(uri) val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files) cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) { session = DownloadSession(
filesBeforeDownload = files.map { it.name }.toMutableSet() filesBefore = files.mapTo(mutableSetOf()) { it.name },
} prefixes = sources.mapNotNullTo(mutableSetOf()) { DictionarySource.extractPrefix(it.urlTemplate) },
)
synchronized(downloadedPrefixes) {
downloadedPrefixes = sources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries( downloader.downloadDictionaries(
folderUri = uri, folderUri = uri,
@@ -114,63 +108,22 @@ class DictionaryRepository(
cleaner.deleteUnprocessedFiles(uri, files) cleaner.deleteUnprocessedFiles(uri, files)
val filesAfterStandardCleanup = fileStorageManager.listFilesInFolder(uri) val current = session ?: return@withContext Result.success(Unit)
session = null
val existingFiles = synchronized(filesBeforeDownload) { fileStorageManager.listFilesInFolder(uri)
filesBeforeDownload.toSet() .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 { .forEach { (fileName, fileUri) ->
filesAfterStandardCleanup.filter { file -> val docFile = fileStorageManager.getDocumentFile(fileUri)
file.name !in existingFiles && if (docFile != null && docFile.exists() && !docFile.delete() &&
finalPrefixes.any { prefix -> file.name.startsWith("${prefix}_") } !fileStorageManager.forceDeleteFile(docFile, fileName)
} ) {
} Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName")
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) {
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) Result.success(Unit)
} }
@@ -183,15 +136,7 @@ class DictionaryRepository(
val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet() val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
val result = cleaner.performAllCleanup(uri, prefixes, files) val result = cleaner.performAllCleanup(uri, prefixes, files)
session = null
synchronized(filesBeforeDownload) {
filesBeforeDownload.clear()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
result result
} }
} }
@@ -14,8 +14,9 @@ import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.ui.article.ArticleState import com.example.research.ui.article.ArticleState
import com.example.research.ui.article.ArticleTextWarmUp 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.DslAnnotatedParser
import com.example.research.ui.article.HEAVY_ARTICLE_BLOCK_COUNT
import com.example.research.ui.article.mergeDslBlocks
import com.example.research.R import com.example.research.R
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -63,7 +64,7 @@ class SearchViewModel(
private var indexWarmupJob: Job? = null private var indexWarmupJob: Job? = null
private var debounceSaveQueryJob: 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 articleHistory = ArrayDeque<ArticleState.Success>()
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> = private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
@@ -211,12 +212,7 @@ class SearchViewModel(
val cached = synchronized(parseCache) { parseCache[cacheKey] } val cached = synchronized(parseCache) { parseCache[cacheKey] }
if (cached != null) { if (cached != null) {
mutableSelectedArticle.update { mutableSelectedArticle.update {
ArticleState.Success( cached.copy(title = entry.originalWord, dictionaryName = entry.dictionaryName)
title = entry.originalWord,
dslContent = cached,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
} }
return@launch return@launch
} }
@@ -228,6 +224,13 @@ class SearchViewModel(
colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME, 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) { synchronized(parseCache) {
if (parseCache.size >= PARSE_CACHE_SIZE) { if (parseCache.size >= PARSE_CACHE_SIZE) {
val iterator = parseCache.entries.iterator() val iterator = parseCache.entries.iterator()
@@ -235,19 +238,12 @@ class SearchViewModel(
iterator.next().run { iterator.remove() } iterator.next().run { iterator.remove() }
} }
} }
parseCache[cacheKey] = annotatedStringList parseCache[cacheKey] = article
} }
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
ArticleTextWarmUp.warmUp(getApplication(), annotatedStringList) ArticleTextWarmUp.warmUp(getApplication(), article.dslContent)
}
mutableSelectedArticle.update {
ArticleState.Success(
title = entry.originalWord,
dslContent = annotatedStringList,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
} }
mutableSelectedArticle.update { article }
} }
.onError { message, exception -> .onError { message, exception ->
Log.e(TAG, "loadArticle() - error: $message", exception) Log.e(TAG, "loadArticle() - error: $message", exception)
@@ -324,10 +320,6 @@ class SearchViewModel(
companion object { companion object {
private const val TAG = "SearchViewModel" 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 PARSE_CACHE_SIZE = 50
private const val KEY_SEARCH_QUERY = "search_query" 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.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -56,7 +55,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AboutScreen( fun AboutScreen(
onNavigateBack: () -> Unit onNavigateBack: () -> Unit
@@ -310,7 +308,6 @@ private fun AboutLibraryRow(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun LicenseDialog( private fun LicenseDialog(
license: AboutLicense, license: AboutLicense,
@@ -44,7 +44,6 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@@ -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.AppWindowInsets
import com.example.research.ui.theme.Spacing 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 @Composable
fun ArticleContent( fun ArticleContent(
articleState: ArticleState?, articleState: ArticleState?,
@@ -183,8 +134,7 @@ fun ArticleContent(
} }
} }
val isHeavyArticle = val isHeavyArticle = articleState.isHeavy
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember( val displayBlocks = remember(
@@ -192,14 +142,12 @@ fun ArticleContent(
baseTextStyle, baseTextStyle,
darkTheme darkTheme
) { ) {
val merged =
mergeDslBlocks(articleState.dslContent)
if (darkTheme) { if (darkTheme) {
merged.map { articleState.dslContent.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text)) it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
} }
} else { } else {
merged articleState.dslContent
} }
} }
val articleList: @Composable (Modifier) -> Unit = { listModifier -> val articleList: @Composable (Modifier) -> Unit = { listModifier ->
@@ -296,7 +244,7 @@ fun ArticleTitleBar(
val titleLineHeight = with(LocalDensity.current) { val titleLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp() MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp()
} }
Modifier.offset(y = (titleLineHeight - 48.dp) / 2) Modifier.offset(y = (titleLineHeight - 48.dp) / 2 + 3.dp)
} else { } else {
Modifier Modifier
} }
@@ -37,6 +37,7 @@ sealed class ArticleState {
val dictionaryPath: String = "", val dictionaryPath: String = "",
val scrollIndex: Int = 0, val scrollIndex: Int = 0,
val scrollOffset: Int = 0, val scrollOffset: Int = 0,
val isHeavy: Boolean = false,
) : ArticleState() ) : ArticleState()
@Immutable @Immutable
data class Error( data class Error(
@@ -13,11 +13,14 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100 internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100
internal const val HEAVY_ARTICLE_TOTAL_CHARS = 1_500
object ArticleTextWarmUp { object ArticleTextWarmUp {
suspend fun warmUp(context: Context, blocks: List<DslBlock>) { 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 density = Density(context)
val measurer = TextMeasurer( val measurer = TextMeasurer(
defaultFontFamilyResolver = createFontFamilyResolver(context), 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), .padding(start = Spacing.contentStart, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars), listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
alignFirstResultWithTitleBar = isSearchBarAtBottom,
) )
}, },
detailPane = { detailPane = {
@@ -124,7 +125,7 @@ fun AdaptiveMainScreen(
articleState = articleState, articleState = articleState,
showBackButton = isShowingDetail && articleState != null, showBackButton = isShowingDetail && articleState != null,
onNavigateBack = onNavigateBackFromArticle, onNavigateBack = onNavigateBackFromArticle,
compact = true, compact = !isSearchBarAtBottom,
selectionReset = titleSelectionReset.intValue, selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding( modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top) WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
@@ -93,6 +93,7 @@ fun MainRoute(
searchViewModel.saveScrollPosition(action.index, action.offset) searchViewModel.saveScrollPosition(action.index, action.offset)
is MainAction.HistoryEntryClicked -> is MainAction.HistoryEntryClicked ->
if (isWide) { if (isWide) {
searchViewModel.onSearchQueryChanged(action.query)
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(action.query)) searchViewModel.onAction(SearchAction.SearchAndLoadArticle(action.query))
} else { } else {
searchViewModel.onSearchQueryChanged(action.query) searchViewModel.onSearchQueryChanged(action.query)
@@ -215,6 +216,7 @@ internal fun MainScreen(
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing) .windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp), .padding(start = Spacing.contentStart, end = 16.dp),
alignFirstResultWithTitleBar = true,
) )
searchBar() searchBar()
} }
@@ -16,7 +16,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
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.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
@@ -43,6 +48,7 @@ internal fun SearchResultsPane(
onArticleClick: (IndexEntry) -> Unit, onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listModifier: Modifier = Modifier, listModifier: Modifier = Modifier,
alignFirstResultWithTitleBar: Boolean = false,
) { ) {
val listState = rememberLazyListState( val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition.first, initialFirstVisibleItemIndex = scrollPosition.first,
@@ -55,6 +61,7 @@ internal fun SearchResultsPane(
val imeBottomPadding = with(LocalDensity.current) { val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp() WindowInsets.ime.getBottom(this).toDp()
} }
val topPadding = if (alignFirstResultWithTitleBar) titleBarAlignedTopPadding() else 0.dp
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { snapshotFlow {
@@ -139,8 +146,21 @@ internal fun SearchResultsPane(
modifier = listModifier.weight(1f), modifier = listModifier.weight(1f),
listState = listState, listState = listState,
showDictionaryName = showDictionaryName, showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding), contentPadding = PaddingValues(top = topPadding, bottom = imeBottomPadding),
staleEntries = shownStale, 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.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -28,7 +29,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.graphics.painter.Painter
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned 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.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -72,6 +74,7 @@ private val TopBarHeight = Spacing.topBarHeight
private val TopBarShape = RoundedCornerShape(28.dp) private val TopBarShape = RoundedCornerShape(28.dp)
private val HistorySheetChrome = 64.dp private val HistorySheetChrome = 64.dp
private val HistorySheetMinHeight = 300.dp private val HistorySheetMinHeight = 300.dp
private val HistorySheetBottomPadding = 16.dp
@Composable @Composable
fun MainTopBar( fun MainTopBar(
@@ -289,12 +292,16 @@ private fun SearchHistorySheet(
onEntryRemove: (String) -> Unit, onEntryRemove: (String) -> Unit,
onClearAll: () -> 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 density = LocalDensity.current
val screenHeight = LocalConfiguration.current.screenHeightDp.dp val windowHeightPx = LocalWindowInfo.current.containerSize.height
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density) val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
val maxContentHeight = remember { val maxContentHeight = remember {
with(density) { with(density) {
val screenHeight = windowHeightPx.toDp()
if (isSearchBarAtBottom) { if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp() screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
} else { } else {
@@ -314,6 +321,7 @@ private fun SearchHistorySheet(
onEntryClick = onEntryClick, onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove, onEntryRemove = onEntryRemove,
onClearAll = onClearAll, onClearAll = onClearAll,
contentPadding = PaddingValues(bottom = HistorySheetBottomPadding),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight) .heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
@@ -6,17 +6,13 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.* import androidx.paging.compose.*
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes import com.example.research.core.util.removeDictionarySuffixes
@@ -53,7 +49,7 @@ fun SearchResultsList(
contentType = { "search_result" } contentType = { "search_result" }
) { index -> ) { index ->
val entry = if (showStale) staleEntries.getOrNull(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) { if (entry != null) {
SearchResultItem( SearchResultItem(
entry = entry, 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -104,7 +103,6 @@ fun SettingsRoute(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
uiState: SettingsUiState, uiState: SettingsUiState,
@@ -117,7 +117,7 @@ class SettingsViewModel(
dictionaryStatus.collect { status -> dictionaryStatus.collect { status ->
val persistedName = when (status) { val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate" DictionaryStatus.UpToDate -> "UpToDate"
DictionaryStatus.NeedsUpdate -> "NeedsUpdate" is DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty" DictionaryStatus.Empty -> "Empty"
else -> null else -> null
} }
@@ -365,8 +365,8 @@ class SettingsViewModel(
return DictionaryStatus.UpToDate return DictionaryStatus.UpToDate
} }
val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources) val updatable = dictionaryRepository.sourcesWithUpdates(enabledSources)
return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate return if (updatable.isEmpty()) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate(updatable)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e) android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) { if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
@@ -625,11 +625,7 @@ class SettingsViewModel(
val savedStatus = preferencesManager.dictionaryStatus() val savedStatus = preferencesManager.dictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|') val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "") val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = when (savedStatusName) { val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate else null
"UpToDate" -> DictionaryStatus.UpToDate
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
else -> null
}
if (restoredStatus != null && if (restoredStatus != null &&
savedStatusDate == DateUtils.getCurrentDateString() && savedStatusDate == DateUtils.getCurrentDateString() &&
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() } withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
@@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.components.BadgedIcon
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -84,7 +85,8 @@ fun DictionaryListItem(
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false } isDeleteBlocked: () -> Boolean = { false },
hasUpdate: Boolean = false,
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -195,11 +197,15 @@ fun DictionaryListItem(
modifier = Modifier.size(IconButtonSize), modifier = Modifier.size(IconButtonSize),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( BadgedIcon(
painter = painterResource(R.drawable.ic_book), painter = painterResource(R.drawable.ic_book),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary, 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.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf 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.EmptyState
import com.example.research.common.ui.components.SectionCard import com.example.research.common.ui.components.SectionCard
import com.example.research.core.domain.model.Dictionary 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.core.domain.model.DictionarySource
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState import com.example.research.ui.settings.ImportState
@@ -75,7 +77,10 @@ fun DictionaryManagement(
) { ) {
val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) } 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 haptic = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@@ -90,6 +95,21 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting } derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
} }
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } } 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, // 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 // snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
@@ -118,7 +138,12 @@ fun DictionaryManagement(
isExpanded = isExpanded, isExpanded = isExpanded,
onToggle = onToggleExpanded, onToggle = onToggleExpanded,
subtitle = headerSubtitle, subtitle = headerSubtitle,
testTag = "dictionary_header" testTag = "dictionary_header",
badgeDescription = if (isUpdateAvailable && !isExpanded) {
stringResource(R.string.dictionaries_update_available)
} else {
null
},
) )
AnimatedVisibility( AnimatedVisibility(
@@ -178,7 +203,7 @@ fun DictionaryManagement(
) )
} }
} else { } else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) { if (isUpdateAvailable) {
DictionaryUpdateCard( DictionaryUpdateCard(
onStartDownload = onStartDownload, onStartDownload = onStartDownload,
modifier = backgroundModifier modifier = backgroundModifier
@@ -192,7 +217,8 @@ fun DictionaryManagement(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(backgroundModifier), .then(backgroundModifier),
isDeleteBlocked = isDeleteBlocked isDeleteBlocked = isDeleteBlocked,
updatablePaths = updatablePaths,
) )
if (!isInProgress) { if (!isInProgress) {
@@ -264,6 +290,7 @@ private fun DictionaryProgressSectionHost(
label = label, label = label,
onCancel = onCancel, onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0, percent = progressSnapshot?.percent ?: 0,
indeterminate = progressSnapshot?.indeterminate ?: false,
testTag = progressTestTag, testTag = progressTestTag,
) )
} }
@@ -274,7 +301,8 @@ private fun DictionaryListSection(
onToggleDictionary: (String) -> Unit, onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit, onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false } isDeleteBlocked: () -> Boolean = { false },
updatablePaths: Set<String> = emptySet(),
) { ) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) } var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) { LaunchedEffect(dictionaries) {
@@ -301,7 +329,8 @@ private fun DictionaryListSection(
dictionary = dictionary, dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) }, onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) }, 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.ui.res.painterResource
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -31,6 +32,7 @@ fun DictionaryProgressSection(
onCancel: () -> Unit, onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
percent: Int, percent: Int,
indeterminate: Boolean,
testTag: String, testTag: String,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@@ -44,6 +46,7 @@ fun DictionaryProgressSection(
var displayedLabel by remember { mutableStateOf(label) } var displayedLabel by remember { mutableStateOf(label) }
var displayedPercent by remember { mutableIntStateOf(percent) } var displayedPercent by remember { mutableIntStateOf(percent) }
var displayedTestTag by remember { mutableStateOf(testTag) } var displayedTestTag by remember { mutableStateOf(testTag) }
var displayedIndeterminate by remember { mutableStateOf(indeterminate) }
LaunchedEffect(visible) { LaunchedEffect(visible) {
if (visible) { if (visible) {
@@ -60,6 +63,7 @@ fun DictionaryProgressSection(
displayedLabel = label displayedLabel = label
displayedPercent = percent displayedPercent = percent
displayedTestTag = testTag displayedTestTag = testTag
displayedIndeterminate = indeterminate
} }
} }
val animatedState = remember { Animatable(0f) } val animatedState = remember { Animatable(0f) }
@@ -88,21 +92,33 @@ fun DictionaryProgressSection(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Text( if (!displayedIndeterminate) {
text = "$displayedPercent%", Text(
style = MaterialTheme.typography.bodySmall, text = "$displayedPercent%",
color = MaterialTheme.colorScheme.primary style = MaterialTheme.typography.bodySmall,
) color = MaterialTheme.colorScheme.primary
)
}
} }
Spacer(Modifier.height(8.dp)) Box(
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(4.dp) .height(WavyProgressIndicatorDefaults.LinearContainerHeight),
.clip(MaterialTheme.shapes.small), contentAlignment = Alignment.Center,
) ) {
Spacer(Modifier.height(8.dp)) if (displayedIndeterminate) {
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(MaterialTheme.shapes.small),
)
}
}
Spacer(Modifier.height(4.dp))
OutlinedButton( OutlinedButton(
onClick = onCancel, onClick = onCancel,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -6,6 +6,7 @@ object Spacing {
// Semantic spacing constants for specific UI elements // Semantic spacing constants for specific UI elements
val topBarPadding = 8.dp val topBarPadding = 8.dp
val topBarHeight = 56.dp val topBarHeight = 56.dp
val topBarCenter = topBarPadding + topBarHeight / 2
val listItemVertical = 8.dp val listItemVertical = 8.dp
val contentStart = 32.dp val contentStart = 32.dp
+1 -1
View File
@@ -62,7 +62,6 @@
<string name="error_scan_directory_failed">Не удалось просканировать папку со словарями</string> <string name="error_scan_directory_failed">Не удалось просканировать папку со словарями</string>
<string name="error_search_failed">Ошибка поиска запроса \'%1$s\' в %2$s</string> <string name="error_search_failed">Ошибка поиска запроса \'%1$s\' в %2$s</string>
<string name="error_failed_load_index_metadata">Не удалось загрузить метаданные индекса: %1$s</string> <string name="error_failed_load_index_metadata">Не удалось загрузить метаданные индекса: %1$s</string>
<string name="error_loading_more">Ошибка загрузки</string>
<plurals name="active_dictionaries_count"> <plurals name="active_dictionaries_count">
<item quantity="one">%1$d активный словарь</item> <item quantity="one">%1$d активный словарь</item>
<item quantity="few">%1$d активных словаря</item> <item quantity="few">%1$d активных словаря</item>
@@ -88,6 +87,7 @@
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string> <string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
<string name="dictionaries_not_downloaded">Словари еще не загружены</string> <string name="dictionaries_not_downloaded">Словари еще не загружены</string>
<string name="dictionaries_update_available">Доступны обновления словарей</string> <string name="dictionaries_update_available">Доступны обновления словарей</string>
<string name="dictionary_update_available">Доступно обновление</string>
<string name="action_cancel">Отмена</string> <string name="action_cancel">Отмена</string>
<string name="action_add">Добавить</string> <string name="action_add">Добавить</string>
<string name="action_add_another_url">Добавить ещё URL</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_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_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_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"> <plurals name="active_dictionaries_count">
<item quantity="one">%1$d active dictionary</item> <item quantity="one">%1$d active dictionary</item>
<item quantity="other">%1$d active dictionaries</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="dictionary_source_suspicious_characters">URL contains invalid characters</string>
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string> <string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
<string name="dictionaries_update_available">Dictionary updates available</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_cancel">Cancel</string>
<string name="action_add">Add</string> <string name="action_add">Add</string>
<string name="action_add_another_url">Add another URL</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.aboutlibraries) apply false
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) 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") { tasks.register("assembleDebug") {
dependsOn(":app:assembleDebug") dependsOn(":app:assembleDebug")
@@ -1 +1 @@
- Initial F-Droid release - Initial release
@@ -0,0 +1,6 @@
- Added search history
- The search bar can be placed at the top or bottom of the screen
- Fixed headwords with stress marks displaying truncated in search results
- Improved download, indexing and navigation stability
- The About screen opens faster
- Updated build tooling and code cleanup
@@ -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
@@ -1,4 +1,6 @@
- The app starts faster - The app starts faster
- Faster dictionary indexing
- Improved app stability
- Smooth screen transition animations - Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly - Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout - Tabs and extra spaces inside articles no longer break the layout
@@ -8,9 +10,7 @@
- Download progress notifications are more reliable - Download progress notifications are more reliable
- Search results clear instantly when the query is erased - Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically - Leftover files from cancelled downloads are now removed automatically
- Improved app stability
- Faster dictionary indexing
- Updated build tooling and documentation
- Long link-heavy articles no longer freeze while scrolling - Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster - Large search result lists render faster
- Dark article colors are readable in the dark theme - Dark article colors are readable in the dark theme
- Updated build tooling and documentation
@@ -1,4 +1,4 @@
ReSearch is a fast Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression. ReSearch is an Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression.
Features: Features:
@@ -8,7 +8,7 @@ Features:
- Import .dsl and .dsl.dz files from device storage - Import .dsl and .dsl.dz files from device storage
- Automatic indexing with binary index files for instant search - Automatic indexing with binary index files for instant search
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection - Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
- Adaptive three-pane layout for tablets and large screens - Adaptive two-pane layout for tablets and large screens
- Material 3 UI with dynamic color, plus light, dark, and system-follow themes - Material 3 UI with dynamic color, plus light, dark, and system-follow themes
- English and Russian localization - English and Russian localization
- No tracking, no ads, fully open source - No tracking, no ads, fully open source
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

@@ -1 +1 @@
- Первый релиз для F-Droid - Первый релиз
@@ -0,0 +1,6 @@
- Добавлена история поиска
- Поисковую строку можно расположить сверху или снизу экрана
- Исправлено усечение заголовков со знаками ударения в результатах поиска
- Повышена стабильность загрузки, индексации и навигации
- Экран «О приложении» открывается быстрее
- Обновлён инструментарий сборки, проведена чистка кода
@@ -0,0 +1,6 @@
- Поиск загружает все совпадения по префиксу и стал значительно быстрее
- Исправлено зависание прогресса индексации больших словарей
- Большие статьи открываются быстрее
- Нумерованные результаты идут по порядку номеров
- Улучшения интерфейса
- Обновлены библиотеки и инструментарий сборки
@@ -1,4 +1,6 @@
- Приложение запускается быстрее - Приложение запускается быстрее
- Ускорена индексация словарей
- Повышена стабильность приложения
- Плавные анимации переходов между экранами - Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки - Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку - Табуляции и лишние пробелы в статьях больше не ломают вёрстку
@@ -8,9 +10,7 @@
- Уведомления о прогрессе загрузки стали надёжнее - Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса - Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически - Остаточные файлы после отменённых загрузок удаляются автоматически
- Повышена стабильность приложения
- Ускорена индексация словарей
- Обновлены тулинг сборки и документация
- Длинные статьи со ссылками больше не подвисают при прокрутке - Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее - Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме - Тёмные цвета статей читаемы в тёмной теме
- Обновлены инструменты сборки и документация
@@ -1,4 +1,4 @@
ReSearch — быстрое приложение для чтения словарей в формате DSL. Скачивайте, импортируйте и ищите в нескольких словарях с мгновенным получением статей благодаря сжатию DictZip. ReSearch — приложение для чтения словарей в формате DSL. Скачивайте, импортируйте и ищите в нескольких словарях с мгновенным получением статей благодаря сжатию DictZip.
Возможности: Возможности:
@@ -8,7 +8,7 @@ ReSearch — быстрое приложение для чтения слова
- Импорт файлов .dsl и .dsl.dz из хранилища устройства - Импорт файлов .dsl и .dsl.dz из хранилища устройства
- Автоматическая индексация с бинарными индексными файлами для мгновенного поиска - Автоматическая индексация с бинарными индексными файлами для мгновенного поиска
- Поддержка нескольких кодировок — автоопределение UTF-8, UTF-16 LE/BE - Поддержка нескольких кодировок — автоопределение UTF-8, UTF-16 LE/BE
- Адаптивный трёхпанельный интерфейс для планшетов и больших экранов - Адаптивный двухпанельный интерфейс для планшетов и больших экранов
- Material 3 с динамическими цветами, светлая, тёмная и системная темы - Material 3 с динамическими цветами, светлая, тёмная и системная темы
- Локализация на английский и русский языки - Локализация на английский и русский языки
- Без трекеров, без рекламы, полностью открытый исходный код - Без трекеров, без рекламы, полностью открытый исходный код
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

+4 -3
View File
@@ -6,10 +6,11 @@ compose_bom = "2026.09.00"
core = "1.19.1" core = "1.19.1"
datastore_preferences = "1.2.1" datastore_preferences = "1.2.1"
documentfile = "1.1.0" documentfile = "1.1.0"
kotlin = "2.4.20" kotlin = "2.4.21"
lifecycle_runtime_ktx = "2.11.0" lifecycle_runtime_ktx = "2.11.0"
material3 = "1.5.0-beta01"
okhttp = "5.5.0" okhttp = "5.5.0"
paging = "3.5.1" paging = "3.5.2"
profileinstaller = "1.4.1" profileinstaller = "1.4.1"
kotlinx_coroutines = "1.11.0" kotlinx_coroutines = "1.11.0"
kotlinx_serialization = "1.11.0" kotlinx_serialization = "1.11.0"
@@ -17,7 +18,7 @@ kotlinx_serialization = "1.11.0"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" } 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-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-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" }
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing" } androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500
Vendored
+41 -11
View File
@@ -26,6 +26,33 @@
@rem Set local scope for the variables, and ensure extensions are enabled @rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions 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 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused @rem This is normally unused
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2 1>&2 echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo. 1>&2 1>&2 echo.
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 1>&2 echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. 1>&2 1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1 "%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. 1>&2 1>&2 echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo. 1>&2 1>&2 echo.
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 1>&2 echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. 1>&2 1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1 "%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute :execute
@rem Setup the command line @rem Setup the command line
@@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2
@rem Execute gradlew @rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded @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 @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 :exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL% "%COMSPEC%" /c exit %ERRORLEVEL%