21 Commits
Author SHA1 Message Date
OneWay 92fc5562db style: address IDE warnings
Remove unused imports and an unused spacing constant, drop redundant if
checks and redundant opt-in markers, replace loop variables with
destructuring, convert a string concatenation to a template, use
Configuration.screenHeightDp instead of LocalWindowInfo.containerSize, and
opt in to ExperimentalSerializationApi for decodeFromStream.
2026-10-06 09:42:54 +08:00
OneWay e9706ab12a fix(search): rebuild the results pager only when the dictionary set changes
Background metadata loading rewrote the dictionaries flow several times
after startup, and every emission recreated the Pager: the results list
visibly collapsed and refilled. Distinct on the index paths that paging
actually uses, and seed the state synchronously instead of an empty list.
2026-10-06 09:42:54 +08:00
OneWay 9dac7507ed fix(settings): skip the startup scan when dictionaries are already loaded
A second activity instance (PROCESS_TEXT) ran the full directory scan
again, and the main screen stayed blank until hasLoadedPreferences flipped
in the scan's finally block. The repository is a process-wide singleton:
when it already holds dictionaries, mark them up to date and load
preferences immediately.
2026-10-06 09:42:54 +08:00
OneWay 7deabeb02a fix(search): keep PROCESS_TEXT on the results screen
handleExternalSearch auto-opened the article on an exact match, so the main
screen with the search bar flashed before the article covered it. Only set
the query; the article now opens from the results list.
2026-10-06 09:42:54 +08:00
OneWay c051ba9585 fix(article): stabilize baseTextStyle across recompositions
TextStyle.copy() returned a new instance on every recomposition, so the
remember() cache for the rendered blocks never hit. A text selection re-ran
block merging and recreated all article text nodes, rebuilding their
StaticLayouts repeatedly. Hoist the theme values and remember the style.
2026-10-06 09:42:54 +08:00
OneWay 97eeae5876 perf(about): parse the license catalog with decodeFromStream
Stream the bundled license catalog straight into the JSON decoder
instead of buffering the whole resource as a string first, halving
parse time and peak allocation when the About screen loads.
2026-10-05 05:18:57 +08:00
OneWay a2079a37a1 fix(settings): write the no-dictionaries seed only after a completed scan
A racing SettingsViewModel whose scan short-circuits with an in-progress
error persisted hadNoDictionaries from a still-empty list, forcing the
Settings screen on the next cold start despite existing dictionaries.
2026-10-05 05:16:21 +08:00
OneWay bd253e3e37 fix(settings): pin Add Source container to static insets
With many fields, the Add Source sheet could keep re-triggering its
settle animation: animated window insets reached the container while
its height followed the content. Keep only the static status bar
inset on the sheet container; the content already pads itself for
the navigation bar and IME.
2026-10-04 12:36:00 +00:00
OneWay 4b8fce960e fix(search): remove result row fade-in when the query changes
Remove the fade-in animation from result rows so that items render
directly when the query changes.
2026-10-04 12:33:00 +00:00
OneWay 05f62a42c6 feat(search): search history bottom sheet
Record queries on explicit search and keep up to 100 entries.
Show them in a bottom sheet opened from the search bar, with
per-entry removal and clear-all.
2026-10-04 12:30:00 +00:00
OneWay 232c1955ec fix(main): compose the main route only after preferences load
Show a blank frame until the first preferences snapshot arrives,
so the saved bar position does not flash or jump on cold start.
2026-10-04 12:27:00 +00:00
OneWay a62e3a1e8f feat(settings): place the search bar at the top or the bottom
Add a search bar position setting and render the bar above the
results or below them, with window insets applied to match.
2026-10-04 12:24:00 +00:00
OneWay 36d58085d7 feat(article): adaptive compact top bar
Top-align the title row in compact layouts, align the back button
with the first title line using the shared back button, make the
title start padding depend on whether the back button is shown,
and drop the spacing constant the old arrow buttons used.
2026-10-04 12:21:00 +00:00
OneWay 7dedfad68d fix(ui): align back arrows with the search bar leading icon
Use one shared back button with a 28dp icon in a 48dp touch target
and matching start padding on the settings and about screens.
2026-10-04 12:18:00 +00:00
OneWay af147ada50 refactor(main): expose cold-start preferences as an InitialLaunch flow
Read the theme, locale, empty-library seed and section expansion
once in the application and collect them as state, instead of
blocking the UI thread with synchronous reads before setContent.
2026-10-04 12:15:00 +00:00
OneWay 32349e2358 feat(settings): enter multiple dictionary source URLs in one sheet
Allow adding and removing URL fields in the Add Source sheet,
validate the whole form at once, and sanitize entered URLs.
2026-10-04 12:12:00 +00:00
OneWay c56bb4e2a6 fix(download): verify payload freshness before restoring the update state
On restart the update-available state was restored purely from the
saved date. Also compare the installed payload file names against
dated sources, and re-check when they no longer match.
2026-10-04 12:09:00 +00:00
OneWay 6514e81804 fix(article): cancel link press only after touch slop
An already-consumed down change is part of another gesture, not a
reason to cancel the link press. Only treat movement past touch
slop as cancellation.
2026-10-04 12:06:00 +00:00
OneWay 7c87f20ace fix(storage): retry index file creation while the target is busy
A just-deleted index file can still be held by the system, so
createNewFile() may fail right after deletion. Retry a few times
with a short delay, waiting for the old file to disappear first.
2026-10-04 12:03:00 +00:00
OneWay b5bb605665 fix(settings): accept dictionary source URLs over https only
The platform blocks cleartext traffic by default, so an http:// source
passed validation but could never download and failed with a misleading
network error. The validator now rejects http:// upfront.
2026-09-27 12:30:00 +08:00
OneWay 5c275e16d6 fix(download): don't show the update card for current dictionaries
areDictionariesUpToDate() mapped every exception to NeedsUpdate, and
the post-update recheck ran after the first indexed source of a batch,
before later sources finished downloading; the interim NeedsUpdate
persisted for the day. Treat unexpected errors as up to date, rethrow
cancellation, and recheck once when the download batch completes.
2026-09-27 11:00:00 +08:00
71 changed files with 901 additions and 1083 deletions

No files matched your search

+8 -13
View File
@@ -1,6 +1,6 @@
<h1 align="center">ReSearch</h1>
<h4 align="center">An Android dictionary reader for DSL-format dictionaries.</h4>
<h4 align="center">A fast Android dictionary reader for DSL-format dictionaries.</h4>
<p align="center">
<a href="#screenshots">Screenshots</a> &bull;
@@ -15,26 +15,21 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
## Screenshots
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search results screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch search history sheet">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="160" alt="ReSearch settings screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/3.png)
[<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/2.png" width="160" alt="ReSearch article screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
## Features
- Fast headword search across multiple DSL dictionaries.
- 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.
- Search history in the empty search state: recent queries with per-entry removal and clear-all.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- 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.
- Faithful article rendering with DSL indentation, margins, and named colors.
- Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries.
- Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification.
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
- 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.
- English and Russian localization.
- About screen with open source license information.
@@ -85,15 +80,15 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack
- Kotlin 2.4.21
- Kotlin 2.4.20
- Jetpack Compose (BOM 2026.09.00)
- Material 3 1.5.0-beta01
- Material 3
- Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1
- Paging 3.5.3
- Paging 3.5.1
- OkHttp 5.5.0
- kotlinx.serialization 1.11.0
- AboutLibraries 15.3.0 metadata generation
- AboutLibraries 15.2.0 metadata generation
- Android Gradle Plugin 9.4.1
## Build
+11 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 12
versionName = "1.8.0"
versionCode = 9
versionName = "1.6.0"
vectorDrawables {
useSupportLibrary = true
@@ -164,17 +164,26 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.documentfile)
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(libs.bundles.compose)
implementation(libs.androidx.compose.material3.adaptive.layout)
debugImplementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp)
// Coroutines with Android-optimized dispatchers
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
}
@@ -13,6 +13,9 @@ object ReSearchTrace {
const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract"
const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index"
const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import"
const val FLOW_UPDATE_CARD_APPEAR = "ReSearch/Flow/UpdateCardAppear"
const val FLOW_UPDATING = "ReSearch/Flow/Updating"
const val FLOW_PROGRESS_DISAPPEAR = "ReSearch/Flow/ProgressDisappear"
private val nextCookie = AtomicInteger()
+44 -36
View File
@@ -1,48 +1,54 @@
HSPLcom/example/research/ReSearchApplication**->**(**)**
HSPLcom/example/research/MainActivity**->**(**)**
HSPLcom/example/research/InitialLaunch**->**(**)**
HSPLcom/example/research/DictionaryStatus**->**(**)**
HSPLcom/example/research/core/**->**(**)**
HSPLcom/example/research/common/ui/**->**(**)**
HSPLcom/example/research/ui/theme/**->**(**)**
HSPLcom/example/research/ui/navigation/**->**(**)**
HSPLcom/example/research/data/local/preferences/**->**(**)**
HSPLcom/example/research/data/parser/ResourceManager**->**(**)**
HSPLcom/example/research/common/util/FileStorageManager**->**(**)**
HSPLcom/example/research/common/util/DateUtils**->**(**)**
# Seeded from the measured ReSearch startup, search, scrolling, and article-opening hot paths.
# Keep this profile checked in: release builders, including F-Droid, only compile it.
# Updated 2026-06-25: Added optimized cache paths from performance optimization stages 1-4
HSPLcom/example/research/ui/main/**->**(**)**
HSPLandroidx/paging/**->**(**)**
HSPLcom/example/research/feature/search/**->**(**)**
HSPLcom/example/research/data/paging/**->**(**)**
HSPLcom/example/research/ReSearchApplication;->**(**)**
HSPLcom/example/research/MainActivity;->**(**)**
HSPLcom/example/research/common/ui/AppLocaleProviderKt**->**(**)**
HSPLcom/example/research/common/ui/theme/ThemeKt**->**(**)**
HSPLcom/example/research/ui/navigation/AppNavigationKt**->**(**)**
HSPLcom/example/research/ui/main/MainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/AdaptiveMainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/components/MainTopBarKt**->**(**)**
HSPLcom/example/research/ui/main/components/SearchResultsListKt**->**(**)**
HSPLcom/example/research/feature/search/SearchViewModel**->**(**)**
HSPLcom/example/research/data/paging/IndexEntryPagingSource**->**(**)**
HSPLcom/example/research/data/parser/IndexSearcher**->**(**)**
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/parser/SearchRankingKt**->**(**)**
HSPLcom/example/research/data/repository/**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipInputStream**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipRandomAccessFile**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipHeader**->**(**)**
HSPLcom/example/research/data/repository/LocalDictionaryRepository**->**(**)**
HSPLcom/example/research/data/repository/KotlinDictionaryEngine**->**(**)**
HSPLcom/example/research/data/parser/DslArticleReader**->**(**)**
HSPLcom/example/research/data/parser/LruCharsetCache**->**(**)**
HSPLcom/example/research/ui/article/**->**(**)**
HSPLcom/example/research/ui/article/DslAnnotatedParser**->**(**)**
HSPLcom/example/research/ui/article/ArticleScreenKt**->**(**)**
HSPLcom/example/research/data/parser/Dsl**->**(**)**
HSPLcom/example/research/data/parser/LineKind**->**(**)**
HSPLcom/example/research/data/parser/ByteTrackingLineReader**->**(**)**
HSPLcom/example/research/data/parser/Utf8Writer**->**(**)**
HSPLcom/example/research/ui/settings/**->**(**)**
HSPLcom/example/research/common/progress/**->**(**)**
HSPLcom/example/research/feature/download/**->**(**)**
# Hot paths for optimized article rendering (Stage 1)
HSPLcom/example/research/ui/article/ArticleLinkText**->**(**)**
HSPLcom/example/research/ui/article/ArticleState**->**(**)**
# About screen (bonus optimization)
HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)**
# Updated 2026-07-11: cover the settings/dictionary flow and the Compose framework.
# Settings + dictionary management flow
HSPLcom/example/research/ui/settings/**->**(**)**
Lcom/example/research/ui/settings/**
HSPLcom/example/research/common/ui/components/**->**(**)**
Lcom/example/research/common/ui/components/**
HSPLcom/example/research/common/progress/**->**(**)**
Lcom/example/research/common/progress/**
HSPLcom/example/research/feature/download/**->**(**)**
Lcom/example/research/feature/download/**
# Pre-verify the app classes already covered by method rules above
Lcom/example/research/**
# Compose framework: the alpha AARs ship near-empty baseline profiles, so first
# compositions run interpreted without these rules.
HSPLandroidx/compose/runtime/**->**(**)**
Landroidx/compose/runtime/**
HSPLandroidx/compose/ui/**->**(**)**
@@ -55,10 +61,12 @@ HSPLandroidx/compose/animation/**->**(**)**
Landroidx/compose/animation/**
HSPLandroidx/compose/material/**->**(**)**
Landroidx/compose/material/**
# Support libraries on the same cold path
HSPLandroidx/lifecycle/**->**(**)**
Landroidx/lifecycle/**
HSPLandroidx/activity/compose/**->**(**)**
Landroidx/activity/compose/**
HSPLkotlinx/coroutines/**->**(**)**
Lkotlinx/coroutines/**
Landroidx/paging/**
@@ -7,7 +7,7 @@ sealed interface DictionaryStatus {
data object Unknown : DictionaryStatus
data object Checking : DictionaryStatus
data object UpToDate : DictionaryStatus
data class NeedsUpdate(val sourceIds: Set<String>) : DictionaryStatus
data object NeedsUpdate : DictionaryStatus
data object Empty : DictionaryStatus
data class Error(val message: String) : DictionaryStatus
}
@@ -26,7 +26,6 @@ import com.example.research.common.ui.AppLocaleProvider
import com.example.research.common.ui.BackgroundTextMeasurementProvider
import com.example.research.common.ui.theme.ReSearchTheme
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
@@ -69,8 +68,12 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager,
app.dictionaryProgressStateHolder,
versionName,
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isSearchBarExpanded = false,
isDictionariesExpanded = true,
),
)
}
}
@@ -144,7 +147,7 @@ class MainActivity : ComponentActivity() {
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
text?.toString()?.let {
searchViewModel.onAction(SearchAction.QueryChanged(it))
searchViewModel.onAction(SearchAction.HandleExternalSearch(it))
}
}
}
@@ -4,7 +4,6 @@ import android.app.Application
import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.NotificationHelper
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.data.repository.LocalDictionaryRepository
@@ -30,7 +29,6 @@ import okhttp3.Protocol
data class InitialLaunch(
val theme: AppTheme,
val language: String,
val searchBarPosition: SearchBarPosition,
val seedNoDictionaries: Boolean,
val sectionExpansion: SectionExpansionStates,
)
@@ -147,16 +145,20 @@ class ReSearchApplication : Application() {
}
private suspend fun loadInitialLaunch(): InitialLaunch {
val defaultExpansions = SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isSearchBarExpanded = false,
isDictionariesExpanded = true,
)
return InitialLaunch(
theme = runCatching { AppTheme.valueOf(preferencesManager.themeMode.first()) }
.getOrDefault(AppTheme.SYSTEM),
language = runCatching { preferencesManager.language.first() }.getOrDefault("system"),
searchBarPosition = runCatching { preferencesManager.searchBarPosition.first() }
.getOrDefault(SearchBarPosition.TOP),
seedNoDictionaries = runCatching { preferencesManager.hadNoDictionaries.first() }
.getOrDefault(true),
sectionExpansion = runCatching { preferencesManager.sectionExpansionStates() }
.getOrDefault(SectionExpansionStates()),
.getOrDefault(defaultExpansions),
)
}
@@ -21,16 +21,14 @@ object DictionaryProgressModel {
val progress: Float,
val percent: Int,
@param:StringRes val titleRes: Int,
val indeterminate: Boolean = false,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Snapshot) return false
return percent == other.percent && titleRes == other.titleRes &&
indeterminate == other.indeterminate
return percent == other.percent && titleRes == other.titleRes
}
override fun hashCode(): Int {
return 31 * (31 * percent + titleRes) + indeterminate.hashCode()
return 31 * percent + titleRes
}
}
fun computeProgressSnapshot(
@@ -94,7 +92,6 @@ object DictionaryProgressModel {
progress = p,
percent = pct,
titleRes = R.string.notification_download_title,
indeterminate = dl <= 0f,
)
}
@@ -1,86 +1,37 @@
package com.example.research.common.progress
import android.os.SystemClock
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
class DictionaryProgressPresenter(
private val scope: CoroutineScope,
scope: CoroutineScope,
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
private val notifications: NotificationPort,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : PipelineTerminalUi {
private companion object {
val MIN_NOTIFICATION_INTERVAL = 500.milliseconds
}
@Volatile
private var terminalActive = false
private var lastShownTitleRes: Int = 0
private var lastEmitMs: Long = 0L
private var pendingSnapshot: DictionaryProgressModel.Snapshot? = null
private var flushJob: Job? = null
init {
scope.launch(mainDispatcher) {
progressSnapshot.collect { snapshot ->
when {
snapshot == null -> {
resetThrottle()
if (!terminalActive && notifications.hasActiveProgressNotification()) {
notifications.cancelProgressNotification()
}
}
!terminalActive -> emitThrottled(snapshot)
!terminalActive -> notifications.showProgress(snapshot.percent, snapshot.titleRes)
}
}
}
}
private fun emitThrottled(snapshot: DictionaryProgressModel.Snapshot) {
val now = SystemClock.elapsedRealtime()
val stageChanged = snapshot.titleRes != lastShownTitleRes
if (stageChanged || (now - lastEmitMs).milliseconds >= MIN_NOTIFICATION_INTERVAL) {
flushJob?.cancel()
pendingSnapshot = null
publish(snapshot, now)
} else {
pendingSnapshot = snapshot
if (flushJob?.isActive != true) {
flushJob = scope.launch(mainDispatcher) {
delay(MIN_NOTIFICATION_INTERVAL - (now - lastEmitMs).milliseconds)
pendingSnapshot?.let { publish(it, SystemClock.elapsedRealtime()) }
pendingSnapshot = null
}
}
}
}
private fun publish(snapshot: DictionaryProgressModel.Snapshot, now: Long) {
if (terminalActive) return
lastShownTitleRes = snapshot.titleRes
lastEmitMs = now
notifications.showProgress(snapshot.percent, snapshot.titleRes)
}
private fun resetThrottle() {
flushJob?.cancel()
pendingSnapshot = null
lastShownTitleRes = 0
lastEmitMs = 0L
}
fun beginFlow() {
terminalActive = false
resetThrottle()
notifications.cancelTerminalNotification()
}
@@ -101,7 +52,6 @@ class DictionaryProgressPresenter(
override fun cancelProgress() {
terminalActive = false
resetThrottle()
notifications.cancelProgressNotification()
}
}
@@ -1,17 +1,13 @@
package com.example.research.common.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
fun Modifier.appTestTag(tag: String): Modifier = this
.testTag(tag)
@@ -28,14 +24,3 @@ fun Modifier.clickableWithHaptics(
onClick()
}
}
@Composable
fun Modifier.iconClickable(
enabled: Boolean = true,
onClick: () -> Unit
): Modifier = this.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
enabled = enabled,
onClick = onClick
)
@@ -1,46 +0,0 @@
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,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -28,8 +29,7 @@ fun CollapsibleHeader(
onToggle: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
testTag: String? = null,
badgeDescription: String? = null,
testTag: String? = null
) {
Row(
modifier = modifier
@@ -43,11 +43,11 @@ fun CollapsibleHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
BadgedIcon(
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
size = 24.dp,
badgeDescription = badgeDescription,
modifier = Modifier.size(24.dp)
)
Column(
@@ -89,7 +89,7 @@ class FileStorageManager {
Log.e(TAG, "Could not delete existing file: $fileName")
return@withContext null
}
awaitGone(targetFile::exists)
waitForDeletion(targetFile)
} else {
return@withContext Uri.fromFile(targetFile)
}
@@ -104,7 +104,7 @@ class FileStorageManager {
delay(CREATE_RETRY_DELAY.milliseconds)
if (targetFile.exists()) {
targetFile.delete()
awaitGone(targetFile::exists)
waitForDeletion(targetFile)
}
created = targetFile.createNewFile()
createAttempt++
@@ -130,7 +130,7 @@ class FileStorageManager {
for (attempt in 1..maxRetries) {
try {
if (file.delete()) {
awaitGone(file::exists)
waitForFileDeletion(file)
if (!file.exists()) {
return@withContext true
}
@@ -156,9 +156,17 @@ class FileStorageManager {
}
// FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window
private suspend fun awaitGone(exists: () -> Boolean) {
private suspend fun waitForDeletion(file: File) {
var waitCount = 0
while (exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++
}
}
private suspend fun waitForFileDeletion(file: DocumentFile) {
var waitCount = 0
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++
}
@@ -1,11 +0,0 @@
package com.example.research.core.domain.model
enum class SearchBarPosition {
TOP,
BOTTOM;
companion object {
fun fromStorage(value: String?): SearchBarPosition =
entries.firstOrNull { it.name == value } ?: TOP
}
}
@@ -1,128 +0,0 @@
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)
}
@@ -60,10 +60,7 @@ fun String.extractDictionaryPrefix(): String {
}
fun String.removeAccentTagsForIndexing(): String =
rewriteAccentTags { builder, from, to -> builder.append(this, from, to) }
internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, Int) -> Unit): String {
fun String.removeAccentTagsForIndexing(): String {
val firstOpen = indexOf("[']")
if (firstOpen < 0) return this
@@ -83,7 +80,7 @@ internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, I
builder = StringBuilder(length)
}
builder.append(this, appendStart, index)
appendInner(builder, index + 3, scan)
builder.append(this, index + 3, scan)
index = scan + 4
appendStart = index
continue
@@ -97,7 +94,7 @@ internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, I
}?.toString() ?: this
}
internal fun String.startsWithAt(start: Int, literal: String): Boolean {
private fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) {
if (this[start + i] != literal[i]) return false
@@ -9,9 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.SearchBarPosition
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
@@ -20,10 +18,10 @@ import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
data class SectionExpansionStates(
val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false,
val isSearchBarExpanded: Boolean = false,
val isDictionariesExpanded: Boolean = true,
val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean,
val isSearchBarExpanded: Boolean,
val isDictionariesExpanded: Boolean,
)
class PreferencesManager(private val context: Context) {
@@ -67,14 +65,14 @@ class PreferencesManager(private val context: Context) {
}
}
val searchBarPosition: Flow<SearchBarPosition> = context.dataStore.data
val searchBarPosition: Flow<String> = context.dataStore.data
.map { preferences ->
SearchBarPosition.fromStorage(preferences[PreferencesKeys.SEARCH_BAR_POSITION])
preferences[PreferencesKeys.SEARCH_BAR_POSITION] ?: "TOP"
}
suspend fun saveSearchBarPosition(position: SearchBarPosition) {
suspend fun saveSearchBarPosition(position: String) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position.name
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position
}
}
@@ -228,9 +226,9 @@ class PreferencesManager(private val context: Context) {
}
val searchHistory: Flow<List<String>> = context.dataStore.data
.map { preferences -> preferences[PreferencesKeys.SEARCH_HISTORY] }
.distinctUntilChanged()
.map(::decodeSearchHistory)
.map { preferences ->
decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
}
suspend fun addSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences ->
@@ -5,26 +5,59 @@ import androidx.paging.PagingState
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.performance.ReSearchTrace
import com.example.research.core.util.sanitizeQuery
import com.example.research.data.parser.IndexSearcher
import com.example.research.data.parser.sortByCharCountThenNatural
import com.example.research.data.parser.SearchRankingContext
import com.example.research.data.parser.rankBySearchRelevance
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.CancellationException
import java.io.File
/**
* Two-phase search paging.
*
* Page one (key = null) is the ranked head: the capped, relevance-ordered result the
* search screen has always shown, merged across active dictionaries. Every range scan
* that hit its cap hands back a [IndexSearcher.RangeCursor]; those become the tail key.
*
* Tail pages continue the unfinished range scans directly from the index files and
* merge them alphabetically (k-way, by index word), so scrolling eventually surfaces
* every prefix match in every active dictionary. Articles already shown -- by the head
* or by an earlier tail page, including diacritic alias duplicates -- are filtered
* through [emittedArticles], which grows only as far as the user actually scrolls.
*
* A new query builds a new PagingSource (see SearchViewModel's flatMapLatest), so an
* in-flight head or tail load is cancelled by Paging when the user keeps typing.
*/
class IndexEntryPagingSource(
private val indexSearcher: IndexSearcher,
dictionaries: List<Dictionary>,
private val dictionaries: List<Dictionary>,
private val query: String
) : PagingSource<Int, IndexEntry>() {
) : PagingSource<IndexEntryPagingSource.TailKey, IndexEntry>() {
private val activeDictionaries = dictionaries.filter { it.isActive }
data class StreamCursor(
val dictPosition: Int,
val cursor: IndexSearcher.RangeCursor
)
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IndexEntry> {
data class TailKey(val streams: List<StreamCursor>)
private data class EmittedKey(
val dictPosition: Int,
val word: String,
val offset: Long
)
private val emittedArticles = HashSet<EmittedKey>()
private val activeDictionaries = dictionaries.withIndex().filter { it.value.isActive }
override suspend fun load(params: LoadParams<TailKey>): LoadResult<TailKey, IndexEntry> {
return try {
loadAll()
val key = params.key
if (key == null) loadHead() else loadTail(key, params.loadSize)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -32,22 +65,21 @@ class IndexEntryPagingSource(
}
}
private suspend fun loadAll(): LoadResult<Int, IndexEntry> =
private suspend fun loadHead(): LoadResult<TailKey, IndexEntry> =
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) {
coroutineScope {
val perDictionary = activeDictionaries
.map { dict ->
.map { (position, dict) ->
async {
val indexFile = File(dict.indexPath)
if (!indexFile.exists()) return@async null
try {
val entries = indexSearcher.search(
val result = indexSearcher.searchWithCursors(
pathOrUri = indexFile.absolutePath,
query = query,
includeSubstringMatches = false
query = query
)
dict to entries
Triple(position, dict, result)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
@@ -58,28 +90,105 @@ class IndexEntryPagingSource(
.awaitAll()
.filterNotNull()
val rawResults = perDictionary.flatMap { (dict, entries) ->
entries.map { entry ->
entry.withDictionary(
val rawResults = perDictionary.flatMap { (position, dict, result) ->
result.entries.map { entry ->
position to entry.withDictionary(
dict.metadata.name.ifBlank { dict.name },
dict.path
)
}
}
val ranked = rawResults
.sortByCharCountThenNatural()
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val ranked = rawResults.map { it.second }
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) }
val streams = perDictionary.flatMap { (position, _, result) ->
result.cursors.map { StreamCursor(position, it) }
}
LoadResult.Page(
data = ranked,
prevKey = null,
nextKey = null
nextKey = if (streams.isEmpty()) null else TailKey(streams)
)
}
}
override fun getRefreshKey(state: PagingState<Int, IndexEntry>): Int? {
private suspend fun loadTail(key: TailKey, loadSize: Int): LoadResult<TailKey, IndexEntry> {
val streams = key.streams.map { TailStream(it.dictPosition, it.cursor) }
val page = ArrayList<IndexEntry>(loadSize)
val pageKeys = HashSet<EmittedKey>()
val chunkSize = maxOf(loadSize, MIN_CHUNK)
while (page.size < loadSize) {
streams.forEach { it.ensureBuffered(chunkSize) }
val next = streams
.filter { it.hasBuffered }
.minByOrNull { it.peekWord }
?: break
val scanned = next.consume()
val dict = dictionaries[next.dictPosition]
val entry = scanned.entry.withDictionary(
dict.metadata.name.ifBlank { dict.name },
dict.path
)
val emittedKey = entry.emittedKey(next.dictPosition)
if (emittedKey !in emittedArticles && pageKeys.add(emittedKey)) {
page.add(entry)
}
}
emittedArticles.addAll(pageKeys)
val remaining = streams.mapNotNull { stream ->
stream.nextCursor?.let { StreamCursor(stream.dictPosition, it) }
}
return LoadResult.Page(
data = page,
prevKey = null,
nextKey = if (remaining.isEmpty()) null else TailKey(remaining)
)
}
private inner class TailStream(
val dictPosition: Int,
initialCursor: IndexSearcher.RangeCursor
) {
var nextCursor: IndexSearcher.RangeCursor? = initialCursor
private set
private var buffer: List<IndexSearcher.ScannedEntry> = emptyList()
private var position = 0
val hasBuffered: Boolean get() = position < buffer.size
val peekWord: String get() = buffer[position].entry.word
suspend fun ensureBuffered(chunkSize: Int) {
if (hasBuffered) return
val cursor = nextCursor ?: return
val indexPath = File(dictionaries[dictPosition].indexPath).absolutePath
buffer = indexSearcher.scanTailChunk(indexPath, cursor, chunkSize)
position = 0
if (buffer.isEmpty()) nextCursor = null
}
fun consume(): IndexSearcher.ScannedEntry {
val scanned = buffer[position]
position++
nextCursor = scanned.cursorAfter
return scanned
}
}
private fun IndexEntry.emittedKey(dictPosition: Int) =
EmittedKey(dictPosition, word, offset.value)
override fun getRefreshKey(state: PagingState<TailKey, IndexEntry>): TailKey? {
return null
}
}
private const val MIN_CHUNK = 48
@@ -1,8 +1,5 @@
package com.example.research.data.parser
import com.example.research.core.util.rewriteAccentTags
import com.example.research.core.util.startsWithAt
object DslHeadwordParser {
private val FORMATTING_LITERAL_TAGS = arrayOf(
@@ -120,12 +117,12 @@ object DslHeadwordParser {
}
private fun reconstructDisplayPrefix(value: String): String {
return removeFormattingTags(removeCurlyBraceDelimiters(value).applyAccentMarks()).trim()
}
private fun String.applyAccentMarks(): String = rewriteAccentTags { builder, from, to ->
if (to > from) {
builder.append(this[from]).append('\u0301').append(this, from + 1, to)
val reconstructed = removeCurlyBraceDelimiters(value)
val formattingTagIndex = firstFormattingTagIndex(reconstructed)
return if (formattingTagIndex >= 0) {
reconstructed.substring(0, formattingTagIndex).trim()
} else {
reconstructed.trim()
}
}
@@ -148,6 +145,21 @@ object DslHeadwordParser {
}
}
private fun firstFormattingTagIndex(value: String): Int {
var index = 0
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
return index
}
index++
}
return -1
}
private fun firstCurlyContent(value: String): String? {
var index = 0
while (index < value.length) {
@@ -334,6 +346,14 @@ object DslHeadwordParser {
return if (index < value.length && value[index] == ']') index + 1 else -1
}
private fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) {
if (this[start + i] != literal[i]) return false
}
return true
}
private fun Char.isTagWhitespace(): Boolean {
return this == ' ' ||
this == '\t' ||
@@ -24,7 +24,7 @@ class DslIndexer(context: Context? = null) {
companion object {
private const val TAG = "DslIndexer"
const val INDEX_VERSION = 7
const val INDEX_VERSION = 6
const val BUFFER_SIZE = 1048576
const val BATCH_BUFFER_SIZE = 524288
private const val INDEX_SORT_PARALLELISM = 1
@@ -49,7 +49,6 @@ class DslIndexer(context: Context? = null) {
suspend fun createIndex(
dslInputStream: java.io.InputStream,
indexFile: File,
totalInputBytes: Long = -1L,
onProgress: (Int) -> Unit = {},
onOperationChange: (String) -> Unit = {},
onFileProgress: (Float) -> Unit = {},
@@ -66,7 +65,7 @@ class DslIndexer(context: Context? = null) {
try { foldedIndexFile(indexFile).delete() } catch (_: Throwable) { /* Legacy v5 leftover */ }
onOperationChange("Parsing file")
onFileProgress(0.0f)
counts = parseDslToTempFile(bufferedStream, tempFile, totalInputBytes,
counts = parseDslToTempFile(bufferedStream, tempFile,
onProgress, onFileProgress, onCharsetDetected)
onFileProgress(0.5f)
onOperationChange("Sorting index")
@@ -89,7 +88,6 @@ class DslIndexer(context: Context? = null) {
private suspend fun parseDslToTempFile(
inputStream: java.io.InputStream,
tempFile: File,
totalInputBytes: Long,
onProgress: (Int) -> Unit,
onFileProgress: (Float) -> Unit = {},
onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {}
@@ -232,12 +230,8 @@ class DslIndexer(context: Context? = null) {
val yieldThreshold = adaptiveConfig.yieldInterval
if (yieldCounter >= yieldThreshold) {
onProgress(entryCount)
val parseProgress = if (totalInputBytes > 0L) {
(reader.position.toFloat() / totalInputBytes.toFloat())
} else {
(lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000)
}
onFileProgress(parseProgress.coerceIn(0f, 0.4f))
val estimatedProgress = (lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000)
onFileProgress(estimatedProgress.coerceIn(0f, 0.4f))
if (resourceManager != null) {
if (resourceManager.shouldYield(adaptiveConfig)) {
@@ -18,7 +18,6 @@ import kotlinx.coroutines.yield
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.DataInputStream
import java.io.EOFException
import java.io.File
import java.io.FileInputStream
import java.io.RandomAccessFile
@@ -26,6 +25,7 @@ import java.io.RandomAccessFile
class IndexSearcher(private val context: Context) {
private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L)
private val resultsCache = ResultsLruCache<List<IndexEntry>>(maxEntries = 20)
private val cursorResultsCache = ResultsLruCache<CursorSearchResult>(maxEntries = 20)
/**
* Pre-allocated reusable buffer for readEntry() to reduce allocations.
@@ -45,6 +45,27 @@ class IndexSearcher(private val context: Context) {
val dataStartOffset: Long
)
data class RangeCursor(
val rangeQuery: String,
val absoluteIndex: Int,
val fileOffset: Long
)
internal class RangeScanResult(
val entries: List<IndexEntry>,
val nextCursor: RangeCursor?
)
class CursorSearchResult(
val entries: List<IndexEntry>,
val cursors: List<RangeCursor>
)
class ScannedEntry(
val entry: IndexEntry,
val cursorAfter: RangeCursor?
)
suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
try {
@@ -88,7 +109,7 @@ class IndexSearcher(private val context: Context) {
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val foldedQuery = normalizedQuery.foldLatinDiacritics()
val cacheKey = "$pathOrUri:$includeSubstringMatches:${normalizedQuery.sanitizeCacheKey()}"
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
resultsCache.get(cacheKey)?.let {
return@withContext it
}
@@ -97,9 +118,9 @@ class IndexSearcher(private val context: Context) {
val count = metadata.totalCount
if (count == 0) return@withContext emptyList()
val results = openSource(pathOrUri).use { source ->
val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).toMutableList()
val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).entries.toMutableList()
if (foldedQuery != normalizedQuery) {
prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery))
prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery).entries)
}
val substringMatches = mutableListOf<IndexEntry>()
@@ -122,7 +143,9 @@ class IndexSearcher(private val context: Context) {
}
}
}
(prefixMatches + substringMatches).distinctBy { it.articleKey() }
(prefixMatches + substringMatches)
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
.distinctBy { it.articleKey() }
}
resultsCache.put(cacheKey, results)
results
@@ -142,9 +165,81 @@ class IndexSearcher(private val context: Context) {
metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
}
suspend fun searchWithCursors(
pathOrUri: String,
query: String
): CursorSearchResult = withContext(Dispatchers.Default) {
if (query.isBlank()) {
return@withContext CursorSearchResult(emptyList(), emptyList())
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val foldedQuery = normalizedQuery.foldLatinDiacritics()
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
cursorResultsCache.get(cacheKey)?.let {
return@withContext it
}
try {
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
if (metadata.totalCount == 0) {
return@withContext CursorSearchResult(emptyList(), emptyList())
}
val result = openSource(pathOrUri).use { source ->
val scans = buildList {
add(readPrefixMatches(source, metadata, normalizedQuery))
if (foldedQuery != normalizedQuery) {
add(readPrefixMatches(source, metadata, foldedQuery))
}
}
val entries = scans.flatMap { it.entries }
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
.distinctBy { it.articleKey() }
CursorSearchResult(entries, scans.mapNotNull { it.nextCursor })
}
cursorResultsCache.put(cacheKey, result)
result
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "searchWithCursors() - error: ${e.message}", e)
if (e is DictionaryError) throw e
throw DictionaryError.SearchError.QueryFailed(context.getString(R.string.error_search_failed, query.take(20), pathOrUri.take(20)), e)
}
}
suspend fun scanTailChunk(
pathOrUri: String,
cursor: RangeCursor,
maxEntries: Int
): List<ScannedEntry> = withContext(Dispatchers.Default) {
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
if (cursor.absoluteIndex >= metadata.totalCount) return@withContext emptyList()
openSource(pathOrUri).use { source ->
source.seek(cursor.fileOffset)
val out = ArrayList<ScannedEntry>(maxEntries)
var currentIdx = cursor.absoluteIndex
var iterationCount = 0
while (currentIdx < metadata.totalCount && out.size < maxEntries) {
if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(cursor.rangeQuery)) break
currentIdx++
iterationCount++
val cursorAfter = if (currentIdx < metadata.totalCount) {
RangeCursor(cursor.rangeQuery, currentIdx, source.position())
} else {
null
}
out.add(ScannedEntry(entry.copy(word = entryWordLower), cursorAfter))
}
out
}
}
fun trimMemory(level: Int) {
metadataCache.trim(level)
resultsCache.trim(level)
cursorResultsCache.trim(level)
}
suspend fun isMetadataLoaded(pathOrUri: String): Boolean {
@@ -254,9 +349,9 @@ class IndexSearcher(private val context: Context) {
source: RandomAccessSource,
metadata: IndexMetadata,
query: String
): List<IndexEntry> {
): RangeScanResult {
val firstMatch = findFirstMatch(source, metadata, query)
?: return emptyList()
?: return RangeScanResult(emptyList(), null)
return readMatchingEntries(source, metadata, firstMatch, query)
}
@@ -320,21 +415,26 @@ class IndexSearcher(private val context: Context) {
metadata: IndexMetadata,
firstMatch: Pair<Int, IndexEntry>,
query: String
): List<IndexEntry> {
): RangeScanResult {
val results = mutableListOf<IndexEntry>()
results.add(firstMatch.second)
val uniqueArticles = hashSetOf(firstMatch.second.articleKey())
var currentIdx = firstMatch.first + 1
var iterationCount = 0
while (currentIdx < metadata.totalCount) {
if (uniqueArticles.size >= MAX_PREFIX_RESULTS) {
return RangeScanResult(results, RangeCursor(query, currentIdx, source.position()))
}
if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(query)) break
uniqueArticles.add(entry.articleKey())
results.add(entry.copy(word = entryWordLower))
currentIdx++
iterationCount++
}
return results
return RangeScanResult(results, null)
}
private fun readPooledString(source: RandomAccessSource, length: Int): String {
@@ -383,6 +483,7 @@ class IndexSearcher(private val context: Context) {
private const val TAG = "IndexSearcher"
private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000
private const val SPARSE_INTERVAL_V1 = 128
private const val MAX_PREFIX_RESULTS = 200
private const val MAX_SUBSTRING_RESULTS = 100
}
}
@@ -487,89 +588,30 @@ interface RandomAccessSource : Closeable {
}
class FileSource(file: File) : RandomAccessSource {
private val raf = RandomAccessFile(file, "r")
private val buffer = ByteArray(BUFFER_SIZE)
private var bufferStart = 0L
private var bufferEnd = 0L
private var pos = 0L
override fun seek(pos: Long) {
this.pos = pos
}
override fun position(): Long = pos
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 readUnsignedByte(): Int {
if (!refill(1)) throw EOFException()
return buffer[(pos++ - bufferStart).toInt()].toInt() and 0xFF
}
override fun position(): Long = raf.filePointer
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 readUnsignedByte(): Int = raf.readUnsignedByte()
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 readUnsignedShort(): Int = raf.readUnsignedShort()
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 readInt(): Int = raf.readInt()
override fun readLong(): Long = raf.readLong()
override fun readFully(b: ByteArray) {
readFully(b, 0, b.size)
raf.readFully(b)
}
override fun readFully(b: ByteArray, off: Int, len: Int) {
if (len > BUFFER_SIZE / 2) {
raf.seek(pos)
raf.readFully(b, off, len)
pos += len
bufferEnd = bufferStart
return
}
var remaining = len
var dst = off
while (remaining > 0) {
if (!refill(1)) throw EOFException()
val i = (pos - bufferStart).toInt()
val chunk = minOf(remaining, (bufferEnd - pos).toInt())
System.arraycopy(buffer, i, b, dst, chunk)
pos += chunk
dst += chunk
remaining -= chunk
}
raf.readFully(b, off, len)
}
override fun close() {
raf.close()
}
private companion object {
private const val BUFFER_SIZE = 64 * 1024
}
}
@@ -1,38 +1,45 @@
package com.example.research.data.parser
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.NaturalStringComparator
import com.example.research.core.util.foldLatinDiacritics
internal fun List<IndexEntry>.sortByCharCountThenNatural(): List<IndexEntry> {
internal data class SearchRankingContext(
val normalizedQuery: String,
val foldedQuery: String = normalizedQuery.foldLatinDiacritics(),
)
private data class SortKey(
val matchTier: Int,
val exactMatch: Int,
val charCount: Int,
val word: String,
val entry: IndexEntry,
)
internal fun List<IndexEntry>.rankBySearchRelevance(
ranking: SearchRankingContext
): List<IndexEntry> {
if (size < 2) return this
val charCounts = IntArray(size)
var minCount = Int.MAX_VALUE
var maxCount = Int.MIN_VALUE
for (i in indices) {
val entry = this[i]
return map { entry ->
val word = entry.word
val displayKey = entry.originalWord
.trim()
.ifEmpty { entry.word.trim() }
val count = displayKey.codePointCount(0, displayKey.length)
charCounts[i] = count
if (count < minCount) minCount = count
if (count > maxCount) maxCount = count
}
val buckets = arrayOfNulls<ArrayList<IndexEntry>>(maxCount - minCount + 1)
for (i in indices) {
val bucketIndex = charCounts[i] - minCount
(buckets[bucketIndex] ?: ArrayList<IndexEntry>().also { buckets[bucketIndex] = it })
.add(this[i])
SortKey(
matchTier = when {
!entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0
entry.isAlias && word.startsWith(ranking.foldedQuery) -> 1
!entry.isAlias && word.foldLatinDiacritics().startsWith(ranking.foldedQuery) -> 1
else -> 2
},
exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
charCount = displayKey.codePointCount(0, displayKey.length),
word = word,
entry = entry,
)
}
val naturalOrder = compareBy<IndexEntry, String>(NaturalStringComparator) { it.word }
val result = ArrayList<IndexEntry>(size)
for (bucket in buckets) {
if (bucket == null) continue
if (bucket.size > 1) bucket.sortWith(naturalOrder)
result.addAll(bucket)
}
return result
.sortedWith(compareBy({ it.matchTier }, { it.exactMatch }, { it.charCount }, { it.word }))
.map { it.entry }
}
@@ -22,7 +22,6 @@ import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.RandomAccessFile
import java.util.concurrent.CancellationException
import java.util.zip.GZIPInputStream
@@ -52,14 +51,12 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
val indexFile = File(indexPath)
val metadata = readMetadata(dictPath)
val totalInputBytes = uncompressedInputLength(dictFile)
val articleCount = withContext(Dispatchers.IO) {
openDslInputStream(dictPath).use { inputStream ->
dslIndexer.createIndex(
dslInputStream = inputStream,
indexFile = indexFile,
totalInputBytes = totalInputBytes,
onProgress = { count ->
onArticlesIndexed(count)
},
@@ -210,30 +207,6 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
}
}
private fun uncompressedInputLength(file: File): Long {
val name = file.name.lowercase()
if (!name.endsWith(".dz") && !name.endsWith(".gz")) return file.length()
return readGzipUncompressedLength(file).takeIf { it > 0L } ?: file.length()
}
private fun readGzipUncompressedLength(file: File): Long = try {
RandomAccessFile(file, "r").use { raf ->
val fileLength = raf.length()
if (fileLength < 18L) {
-1L
} else {
raf.seek(fileLength - 4L)
val b0 = raf.readUnsignedByte()
val b1 = raf.readUnsignedByte()
val b2 = raf.readUnsignedByte()
val b3 = raf.readUnsignedByte()
(b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)).toLong() and 0xFFFFFFFFL
}
}
} catch (_: Exception) {
-1L
}
private fun resolveIndexPath(indexArg: String): String {
val candidate = File(indexArg)
if (candidate.exists()) return candidate.absolutePath
@@ -52,19 +52,11 @@ class DownloadManager(
var onDownloadSuccess: (() -> Unit)? = null
var onTerminal: ((DownloadState) -> Unit)? = null
private suspend fun expireErrorStateToIdle() {
delay(TERMINAL_STATE_DURATION)
if (downloadState.value is DownloadState.Error) {
updateDownloadProgressState(DownloadState.Idle, 0f)
}
}
fun startDownload() {
if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return
onFlowStarted?.invoke()
cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch {
mutex.withLock {
try {
@@ -86,10 +78,11 @@ class DownloadManager(
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
expireErrorStateToIdle()
return@withLock
}
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -143,7 +136,6 @@ class DownloadManager(
}
}
expireErrorStateToIdle()
}
} finally {
downloadJob = null
@@ -157,7 +149,6 @@ class DownloadManager(
if (!downloadScope.isActive) return
onFlowStarted?.invoke()
cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch {
mutex.withLock {
try {
@@ -183,10 +174,11 @@ class DownloadManager(
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
expireErrorStateToIdle()
return@withLock
}
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -240,7 +232,6 @@ class DownloadManager(
}
}
expireErrorStateToIdle()
}
} finally {
downloadJob = null
@@ -50,36 +50,63 @@ class DictionaryChecker(
}
}
suspend fun sourcesWithUpdates(
suspend fun areDictionariesUpToDate(
folderUri: Uri,
sources: List<DictionarySource>,
files: List<DocumentFileInfo>? = null
): Set<String> = withContext(Dispatchers.IO) {
): Boolean = withContext(Dispatchers.IO) {
try {
val enabledSources = sources.filter { it.isEnabled }
if (enabledSources.isEmpty()) {
return@withContext true
}
val currentDate = DateUtils.getCurrentDateString()
val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files)
sources
.filter { it.isEnabled && DictionarySource.hasDatePlaceholder(it.urlTemplate) }
.filter { source ->
when {
dictFiles.hasFileForSource(source, currentDate) -> false
dictFiles.hasFileForSource(source, previousDate) ->
isSourceAvailableOnServer(source, currentDate)
else -> when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
}
}
val datedSources = enabledSources.filter { source ->
DictionarySource.hasDatePlaceholder(source.urlTemplate)
}
if (datedSources.isEmpty()) {
return@withContext true
}
val allHaveToday = datedSources.all { source ->
dictFiles.hasFileForSource(source, currentDate)
}
if (allHaveToday) {
return@withContext true
}
val allHaveYesterday = datedSources.all { source ->
dictFiles.hasFileForSource(source, previousDate)
}
if (allHaveYesterday) {
val serverHasToday = datedSources.any { source ->
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) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e)
emptySet()
true
}
}
@@ -25,20 +25,21 @@ class DictionaryRepository(
private val extractor = DictionaryExtractor(context, fileStorageManager)
private val cleaner = DictionaryCleaner(context, fileStorageManager)
private class DownloadSession(val filesBefore: Set<String>, val prefixes: Set<String>)
@Volatile
private var session: DownloadSession? = null
private var filesBeforeDownload = mutableSetOf<String>()
private var downloadedPrefixes = mutableSetOf<String>()
companion object {
private const val TAG = "DictionaryRepository"
}
suspend fun sourcesWithUpdates(
sources: List<DictionarySource>
): Set<String> = withContext(Dispatchers.IO) {
val uri = Uri.fromFile(File(preferencesManager.dictionaryPath))
checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri))
suspend fun areDictionariesUpToDate(
sources: List<DictionarySource>? = null
): Boolean = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first()
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
}
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
@@ -80,10 +81,15 @@ class DictionaryRepository(
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
session = DownloadSession(
filesBefore = files.mapTo(mutableSetOf()) { it.name },
prefixes = sources.mapNotNullTo(mutableSetOf()) { DictionarySource.extractPrefix(it.urlTemplate) },
)
synchronized(filesBeforeDownload) {
filesBeforeDownload = files.map { it.name }.toMutableSet()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes = sources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries(
folderUri = uri,
@@ -108,22 +114,63 @@ class DictionaryRepository(
cleaner.deleteUnprocessedFiles(uri, files)
val current = session ?: return@withContext Result.success(Unit)
session = null
val filesAfterStandardCleanup = fileStorageManager.listFilesInFolder(uri)
fileStorageManager.listFilesInFolder(uri)
.filter { file ->
file.name !in current.filesBefore &&
current.prefixes.any { prefix -> file.name.startsWith("${prefix}_") }
val existingFiles = synchronized(filesBeforeDownload) {
filesBeforeDownload.toSet()
}
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}_") }
}
.forEach { (fileName, fileUri) ->
val docFile = fileStorageManager.getDocumentFile(fileUri)
if (docFile != null && docFile.exists() && !docFile.delete() &&
!fileStorageManager.forceDeleteFile(docFile, fileName)
) {
Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName")
} else {
filesAfterStandardCleanup.filter { file ->
file.name !in existingFiles &&
finalPrefixes.any { prefix -> file.name.startsWith("${prefix}_") }
}
}
if (filesToDelete.isNotEmpty()) {
var deletedCount = 0
for ((fileName, uri) in filesToDelete) {
val docFile = fileStorageManager.getDocumentFile(uri)
if (docFile != null && docFile.exists()) {
val deleted = docFile.delete()
if (deleted) {
deletedCount++
} else {
val forceDeleted = fileStorageManager.forceDeleteFile(docFile, fileName)
if (!forceDeleted) {
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)
}
@@ -136,7 +183,15 @@ class DictionaryRepository(
val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
val result = cleaner.performAllCleanup(uri, prefixes, files)
session = null
synchronized(filesBeforeDownload) {
filesBeforeDownload.clear()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
result
}
}
@@ -14,9 +14,8 @@ import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.ui.article.ArticleState
import com.example.research.ui.article.ArticleTextWarmUp
import com.example.research.ui.article.DslBlock
import com.example.research.ui.article.DslAnnotatedParser
import com.example.research.ui.article.HEAVY_ARTICLE_BLOCK_COUNT
import com.example.research.ui.article.mergeDslBlocks
import com.example.research.R
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@@ -30,6 +29,7 @@ sealed interface SearchAction {
data class ArticleClicked(val entry: IndexEntry) : SearchAction
data object ClearArticle : SearchAction
data class SearchAndLoadArticle(val word: String) : SearchAction
data class HandleExternalSearch(val text: String) : SearchAction
}
class SearchViewModel(
@@ -64,7 +64,7 @@ class SearchViewModel(
private var indexWarmupJob: Job? = null
private var debounceSaveQueryJob: Job? = null
private val parseCache = java.util.LinkedHashMap<String, ArticleState.Success>(PARSE_CACHE_SIZE, 0.75f, true)
private val parseCache = java.util.LinkedHashMap<String, List<DslBlock>>(PARSE_CACHE_SIZE, 0.75f, true)
private val articleHistory = ArrayDeque<ArticleState.Success>()
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
@@ -112,7 +112,9 @@ class SearchViewModel(
Pager(
config = PagingConfig(
pageSize = 12,
enablePlaceholders = false
prefetchDistance = 1,
enablePlaceholders = false,
initialLoadSize = 12
),
pagingSourceFactory = {
IndexEntryPagingSource(
@@ -140,6 +142,7 @@ class SearchViewModel(
}
SearchAction.ClearArticle -> clearArticle()
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
}
}
@@ -183,7 +186,6 @@ class SearchViewModel(
private fun recordSearchHistory(query: String) {
val trimmed = query.trim()
if (trimmed.isBlank()) return
if (searchHistory.value.firstOrNull() == trimmed) return
viewModelScope.launch {
preferencesManager.addSearchHistoryEntry(trimmed)
}
@@ -210,7 +212,12 @@ class SearchViewModel(
val cached = synchronized(parseCache) { parseCache[cacheKey] }
if (cached != null) {
mutableSelectedArticle.update {
cached.copy(title = entry.originalWord, dictionaryName = entry.dictionaryName)
ArticleState.Success(
title = entry.originalWord,
dslContent = cached,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
}
return@launch
}
@@ -222,13 +229,6 @@ class SearchViewModel(
colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME,
)
}
val article = ArticleState.Success(
title = entry.originalWord,
dslContent = mergeDslBlocks(annotatedStringList),
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
isHeavy = annotatedStringList.size > HEAVY_ARTICLE_BLOCK_COUNT,
)
synchronized(parseCache) {
if (parseCache.size >= PARSE_CACHE_SIZE) {
val iterator = parseCache.entries.iterator()
@@ -236,12 +236,19 @@ class SearchViewModel(
iterator.next().run { iterator.remove() }
}
}
parseCache[cacheKey] = article
parseCache[cacheKey] = annotatedStringList
}
viewModelScope.launch(Dispatchers.Default) {
ArticleTextWarmUp.warmUp(getApplication(), article.dslContent)
ArticleTextWarmUp.warmUp(getApplication(), annotatedStringList)
}
mutableSelectedArticle.update {
ArticleState.Success(
title = entry.originalWord,
dslContent = annotatedStringList,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
}
mutableSelectedArticle.update { article }
}
.onError { message, exception ->
Log.e(TAG, "loadArticle() - error: $message", exception)
@@ -315,9 +322,17 @@ class SearchViewModel(
}
}
fun handleExternalSearch(text: String) {
onSearchQueryChanged(text)
}
companion object {
private const val TAG = "SearchViewModel"
// Parse cache: stores parsed DslBlock lists to avoid re-parsing articles
// Size: 50 articles - typical user navigation depth in a session
// Memory estimate: ~50KB per article average (text + formatting)
// Total: ~2.5MB worst case
private const val PARSE_CACHE_SIZE = 50
private const val KEY_SEARCH_QUERY = "search_query"
@@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -55,6 +56,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
onNavigateBack: () -> Unit
@@ -154,14 +156,13 @@ private fun AboutHeader(versionName: String) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline()
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = versionName,
text = stringResource(R.string.version_format, versionName),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline()
modifier = Modifier.padding(bottom = 2.dp)
)
}
@@ -308,6 +309,7 @@ private fun AboutLibraryRow(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LicenseDialog(
license: AboutLicense,
@@ -44,6 +44,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
@@ -54,6 +55,54 @@ import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
private fun mergeDslBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
val merged = ArrayList<DslBlock>(blocks.size / 4)
var i = 0
while (i < blocks.size) {
val builder = AnnotatedString.Builder()
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
var j = i
while (j < blocks.size) {
val block = blocks[j]
repeat(block.indent) { builder.append(' ') }
val paraStart = builder.length
builder.append(block.text)
block.lazyRefs.forEach { ref ->
refs.add(
ref.copy(
startIndex = ref.startIndex + paraStart,
endIndex = ref.endIndex + paraStart
)
)
}
val more = j + 1 < blocks.size &&
(j + 1 - i) < MERGED_BLOCK_MAX_ITEMS &&
builder.length < MERGED_BLOCK_MAX_CHARS
if (more) {
builder.append('\n')
}
j++
if (!more) {
break
}
}
merged.add(
DslBlock(
text = builder.toAnnotatedString(),
indent = 0,
lazyRefs = refs,
continuesPrevious = blocks[i].continuesPrevious
)
)
i = j
}
return merged
}
@Composable
fun ArticleContent(
articleState: ArticleState?,
@@ -134,7 +183,8 @@ fun ArticleContent(
}
}
val isHeavyArticle = articleState.isHeavy
val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
@@ -142,12 +192,14 @@ fun ArticleContent(
baseTextStyle,
darkTheme
) {
val merged =
mergeDslBlocks(articleState.dslContent)
if (darkTheme) {
articleState.dslContent.map {
merged.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
}
} else {
articleState.dslContent
merged
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
@@ -244,7 +296,7 @@ fun ArticleTitleBar(
val titleLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp()
}
Modifier.offset(y = (titleLineHeight - 48.dp) / 2 + 3.dp)
Modifier.offset(y = (titleLineHeight - 48.dp) / 2)
} else {
Modifier
}
@@ -37,7 +37,6 @@ sealed class ArticleState {
val dictionaryPath: String = "",
val scrollIndex: Int = 0,
val scrollOffset: Int = 0,
val isHeavy: Boolean = false,
) : ArticleState()
@Immutable
data class Error(
@@ -13,14 +13,11 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100
internal const val HEAVY_ARTICLE_TOTAL_CHARS = 1_500
object ArticleTextWarmUp {
suspend fun warmUp(context: Context, blocks: List<DslBlock>) {
if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT &&
blocks.sumOf { it.text.length } <= HEAVY_ARTICLE_TOTAL_CHARS
) return
if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT) return
val density = Density(context)
val measurer = TextMeasurer(
defaultFontFamilyResolver = createFontFamilyResolver(context),
@@ -1,51 +0,0 @@
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
}
@@ -14,7 +14,7 @@ import com.example.research.core.domain.model.IndexEntry
import com.example.research.ui.article.ArticleContent
import com.example.research.ui.article.ArticleTitleBar
import com.example.research.ui.article.ArticleState
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.main.components.MainTopBar
import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -55,13 +55,17 @@ fun AdaptiveMainScreen(
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
val searchBar: @Composable () -> Unit = {
MainSearchBar(
MainTopBar(
searchState = searchState,
canSearch = canSearch,
enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
searchHistory = searchHistory,
onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
onClearHistory = { onAction(MainAction.ClearHistory) },
onNavigateToSettings = onNavigateToSettings
)
}
@@ -70,7 +74,7 @@ fun AdaptiveMainScreen(
.fillMaxSize()
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
Modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
} else {
Modifier
}
@@ -107,7 +111,6 @@ fun AdaptiveMainScreen(
)
.padding(start = Spacing.contentStart, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
alignFirstResultWithTitleBar = isSearchBarAtBottom,
)
},
detailPane = {
@@ -125,7 +128,7 @@ fun AdaptiveMainScreen(
articleState = articleState,
showBackButton = isShowingDetail && articleState != null,
onNavigateBack = onNavigateBackFromArticle,
compact = !isSearchBarAtBottom,
compact = true,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
@@ -1,9 +1,14 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -22,7 +27,6 @@ import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.core.domain.model.IndexEntry
import com.example.research.feature.download.config.DictionaryConfig
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
@@ -44,7 +48,11 @@ fun MainRoute(
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val isSearchBarAtBottom = settingsState.searchBarPosition == SearchBarPosition.BOTTOM
if (!settingsState.hasLoadedPreferences) {
Box(modifier = Modifier.fillMaxSize())
return
}
val isSearchBarAtBottom = settingsState.searchBarPosition == "BOTTOM"
val scrollPosition = remember(searchViewModel) {
searchViewModel.scrollPosition.value
@@ -64,11 +72,7 @@ fun MainRoute(
dictionary.isActive && dictionary.articleCount > 0
}
val downloadSettled = settingsState.downloadState.let { state ->
state is DownloadState.Idle || state is DownloadState.Error
}
downloadSettled &&
settingsState.downloadState is DownloadState.Idle &&
!settingsState.indexingProgress.isIndexing &&
settingsState.importState !is ImportState.Importing &&
settingsState.importState !is ImportState.Extracting &&
@@ -97,7 +101,6 @@ fun MainRoute(
searchViewModel.saveScrollPosition(action.index, action.offset)
is MainAction.HistoryEntryClicked ->
if (isWide) {
searchViewModel.onSearchQueryChanged(action.query)
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(action.query))
} else {
searchViewModel.onSearchQueryChanged(action.query)
@@ -153,29 +156,6 @@ fun MainRoute(
}
}
@Composable
internal fun MainSearchBar(
searchState: TextFieldState,
canSearch: Boolean,
isSearchBarAtBottom: Boolean,
searchHistory: List<String>,
onAction: (MainAction) -> Unit,
onNavigateToSettings: () -> Unit,
) {
MainTopBar(
searchState = searchState,
enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
searchHistory = searchHistory,
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
onClearHistory = { onAction(MainAction.ClearHistory) },
onNavigateToSettings = onNavigateToSettings
)
}
@Composable
internal fun MainScreen(
searchState: TextFieldState,
@@ -191,13 +171,17 @@ internal fun MainScreen(
onNavigateToSettings: () -> Unit
) {
val searchBar: @Composable () -> Unit = {
MainSearchBar(
MainTopBar(
searchState = searchState,
canSearch = canSearch,
enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
searchHistory = searchHistory,
onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
onClearHistory = { onAction(MainAction.ClearHistory) },
onNavigateToSettings = onNavigateToSettings
)
}
@@ -205,7 +189,7 @@ internal fun MainScreen(
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
) {
SearchResultsPane(
searchState = searchState,
@@ -220,7 +204,6 @@ internal fun MainScreen(
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp),
alignFirstResultWithTitleBar = true,
)
searchBar()
}
@@ -16,12 +16,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.text.rememberTextMeasurer
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.Spacing
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.example.research.core.domain.model.IndexEntry
@@ -48,7 +43,6 @@ internal fun SearchResultsPane(
onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier,
listModifier: Modifier = Modifier,
alignFirstResultWithTitleBar: Boolean = false,
) {
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition.first,
@@ -61,7 +55,6 @@ internal fun SearchResultsPane(
val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp()
}
val topPadding = if (alignFirstResultWithTitleBar) titleBarAlignedTopPadding() else 0.dp
LaunchedEffect(listState) {
snapshotFlow {
@@ -146,21 +139,8 @@ internal fun SearchResultsPane(
modifier = listModifier.weight(1f),
listState = listState,
showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(top = topPadding, bottom = imeBottomPadding),
contentPadding = PaddingValues(bottom = imeBottomPadding),
staleEntries = shownStale,
)
}
}
@Composable
private fun titleBarAlignedTopPadding(): Dp {
val textMeasurer = rememberTextMeasurer(cacheSize = 0)
val style = MaterialTheme.typography.dictionaryTitleLarge
val density = LocalDensity.current
return remember(textMeasurer, style, density) {
val titleHeight = with(density) {
textMeasurer.measure(text = "A", style = style, maxLines = 1).size.height.toDp()
}
(Spacing.topBarCenter - Spacing.listItemVertical - titleHeight / 2).coerceAtLeast(0.dp)
}
}
@@ -1,15 +1,18 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -29,8 +32,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,19 +50,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.iconClickable
import com.example.research.common.ui.theme.dictionaryTitleLarge
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
@@ -74,7 +76,6 @@ private val TopBarHeight = Spacing.topBarHeight
private val TopBarShape = RoundedCornerShape(28.dp)
private val HistorySheetChrome = 64.dp
private val HistorySheetMinHeight = 300.dp
private val HistorySheetBottomPadding = 16.dp
@Composable
fun MainTopBar(
@@ -102,6 +103,10 @@ fun MainTopBar(
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
LaunchedEffect(searchHistory.isEmpty()) {
if (searchHistory.isEmpty()) showHistorySheet = false
}
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
@@ -120,7 +125,9 @@ fun MainTopBar(
)
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.bottomNavigation)
Modifier.windowInsetsPadding(
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
)
} else {
Modifier
}
@@ -172,7 +179,7 @@ fun MainTopBar(
}
}
if (showHistorySheet && searchHistory.isNotEmpty()) {
if (showHistorySheet) {
SearchHistorySheet(
history = searchHistory,
isSearchBarAtBottom = isSearchBarAtBottom,
@@ -234,8 +241,13 @@ private fun SearchField(
modifier = Modifier
.offset(x = 4.dp)
.size(28.dp)
.appTestTag("history_button")
.iconClickable(enabled = historyEnabled, onClick = onShowHistory)
.testTag("history_button")
.semantics { testTagsAsResourceId = true }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
enabled = historyEnabled
) { onShowHistory() }
)
},
trailingIcon = if (searchState.text.isNotEmpty()) {
@@ -292,23 +304,20 @@ private fun SearchHistorySheet(
onEntryRemove: (String) -> Unit,
onClearAll: () -> Unit
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val density = LocalDensity.current
val windowHeightPx = LocalWindowInfo.current.containerSize.height
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
val maxContentHeight = remember {
with(density) {
val screenHeight = windowHeightPx.toDp()
if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
} else {
screenHeight - topBarBottomPx.toDp()
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
var frozenHeight by remember { mutableStateOf<Dp?>(null) }
if (frozenHeight == null) {
frozenHeight = if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - with(density) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
} else {
screenHeight - with(density) { topBarBottomPx.toDp() }
}
}
val maxContentHeight = frozenHeight!!
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -321,7 +330,6 @@ private fun SearchHistorySheet(
onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove,
onClearAll = onClearAll,
contentPadding = PaddingValues(bottom = HistorySheetBottomPadding),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
@@ -2,6 +2,7 @@ package com.example.research.ui.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -18,17 +19,20 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.iconClickable
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.Spacing
@@ -44,7 +48,8 @@ fun SearchHistoryList(
) {
LazyColumn(
modifier = modifier
.appTestTag("search_history"),
.testTag("search_history")
.semantics { testTagsAsResourceId = true },
state = listState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -71,7 +76,8 @@ fun SearchHistoryList(
.padding(end = 18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.appTestTag("clear_history")
.testTag("clear_history")
.semantics { testTagsAsResourceId = true }
.clickable { onClearAll() }
.padding(horizontal = 12.dp, vertical = 6.dp)
)
@@ -100,7 +106,8 @@ private fun SearchHistoryItem(
Row(
modifier = Modifier
.fillMaxWidth()
.appTestTag("search_history_entry")
.testTag("search_history_entry")
.semantics { testTagsAsResourceId = true }
.clickable { onEntryClick(entry) }
.padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically
@@ -129,8 +136,12 @@ private fun SearchHistoryItem(
contentDescription = stringResource(R.string.remove_search_history_entry),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.appTestTag("remove_history_entry")
.iconClickable { onEntryRemove(entry) }
.testTag("remove_history_entry")
.semantics { testTagsAsResourceId = true }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp)
) { onEntryRemove(entry) }
.size(20.dp)
)
}
@@ -6,13 +6,17 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.*
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes
@@ -49,7 +53,7 @@ fun SearchResultsList(
contentType = { "search_result" }
) { index ->
val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results[index] else null
else if (index in 0 until results.itemCount) results.peek(index) else null
if (entry != null) {
SearchResultItem(
entry = entry,
@@ -59,6 +63,26 @@ 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,6 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -103,6 +104,7 @@ fun SettingsRoute(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
uiState: SettingsUiState,
@@ -3,7 +3,6 @@ package com.example.research.ui.settings
import android.net.Uri
import androidx.compose.runtime.Immutable
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress
@@ -14,7 +13,7 @@ import com.example.research.feature.download.model.DownloadState
data class SettingsUiState(
val theme: AppTheme = AppTheme.SYSTEM,
val language: String = "system",
val searchBarPosition: SearchBarPosition = SearchBarPosition.TOP,
val searchBarPosition: String = "TOP",
val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false,
val isSearchBarExpanded: Boolean = false,
@@ -26,6 +25,7 @@ data class SettingsUiState(
val importState: ImportState = ImportState.Idle,
val dictionarySources: List<DictionarySource> = emptyList(),
val hasCompletedStartupScan: Boolean = false,
val hasLoadedPreferences: Boolean = false,
val pipelineIdle: Boolean = true,
val appVersion: String = "1.0",
)
@@ -33,7 +33,7 @@ data class SettingsUiState(
sealed interface SettingsUiEvent {
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
data class LanguageChanged(val language: String) : SettingsUiEvent
data class SearchBarPositionChanged(val position: SearchBarPosition) : SettingsUiEvent
data class SearchBarPositionChanged(val position: String) : SettingsUiEvent
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
data class DictionaryToggled(val path: String) : SettingsUiEvent
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
@@ -9,7 +9,6 @@ import com.example.research.R
import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.DateUtils
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress
@@ -37,16 +36,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
private const val STATUS_UP_TO_DATE = "UpToDate"
private const val STATUS_NEEDS_UPDATE = "NeedsUpdate"
private const val STATUS_EMPTY = "Empty"
private data class PersistedDictionaryStatus(
val name: String,
val date: String,
val sourceIds: Set<String>,
)
private data class DictionaryStateInputs(
val theme: AppTheme,
val dictionarySources: List<DictionarySource>,
@@ -57,7 +46,7 @@ private data class DictionaryStateInputs(
private data class OperationStateInputs(
val downloadState: DownloadState,
val language: String,
val searchBarPosition: SearchBarPosition,
val searchBarPosition: String,
val importState: ImportState,
val dictionaryStatus: DictionaryStatus,
val hasCompletedStartupScan: Boolean,
@@ -65,6 +54,7 @@ private data class OperationStateInputs(
val isLanguageExpanded: Boolean,
val isSearchBarExpanded: Boolean,
val isDictionariesExpanded: Boolean,
val hasLoadedPreferences: Boolean,
)
class SettingsViewModel(
@@ -77,7 +67,6 @@ class SettingsViewModel(
progressStateHolder: DictionaryProgressStateHolder,
appVersion: String,
initialSectionExpansion: SectionExpansionStates,
initialSearchBarPosition: SearchBarPosition,
) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -90,9 +79,7 @@ class SettingsViewModel(
appVersion = appVersion,
isThemeExpanded = initialSectionExpansion.isThemeExpanded,
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
isSearchBarExpanded = initialSectionExpansion.isSearchBarExpanded,
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
searchBarPosition = initialSearchBarPosition,
)
)
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
@@ -105,14 +92,15 @@ class SettingsViewModel(
private val dictionaryStatus = MutableStateFlow<DictionaryStatus>(DictionaryStatus.Unknown)
private val language = MutableStateFlow("system")
private val searchBarPosition = MutableStateFlow("TOP")
private val hasCompletedStartupScan = MutableStateFlow(false)
private val hasLoadedPreferences = MutableStateFlow(false)
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
private var isDownloadInProgress = false
private var cancelRefreshPending = false
private var errorStateHandled = false
private var successStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init {
@@ -125,18 +113,14 @@ class SettingsViewModel(
private fun persistDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.collect { status ->
val persisted = when (status) {
DictionaryStatus.UpToDate ->
"$STATUS_UP_TO_DATE|${DateUtils.getCurrentDateString()}"
is DictionaryStatus.NeedsUpdate ->
"$STATUS_NEEDS_UPDATE|${DateUtils.getCurrentDateString()}|" +
status.sourceIds.joinToString(",")
DictionaryStatus.Empty ->
"$STATUS_EMPTY|${DateUtils.getCurrentDateString()}"
val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate"
DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty"
else -> null
}
if (persisted != null) {
preferencesManager.saveDictionaryStatus(persisted)
if (persistedName != null) {
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
}
}
}
@@ -172,7 +156,7 @@ class SettingsViewModel(
val operationStateInputs = combine(
downloadManager.downloadState,
language,
preferencesManager.searchBarPosition,
searchBarPosition,
dictionaryImportManager.importState,
dictionaryStatus,
hasCompletedStartupScan,
@@ -180,12 +164,13 @@ class SettingsViewModel(
preferencesManager.isLanguageExpanded,
preferencesManager.isSearchBarExpanded,
preferencesManager.isDictionariesExpanded,
hasLoadedPreferences,
) { params: Array<Any> ->
@Suppress("UNCHECKED_CAST")
OperationStateInputs(
downloadState = params[0] as DownloadState,
language = params[1] as String,
searchBarPosition = params[2] as SearchBarPosition,
searchBarPosition = params[2] as String,
importState = params[3] as ImportState,
dictionaryStatus = params[4] as DictionaryStatus,
hasCompletedStartupScan = params[5] as Boolean,
@@ -193,6 +178,7 @@ class SettingsViewModel(
isLanguageExpanded = params[7] as Boolean,
isSearchBarExpanded = params[8] as Boolean,
isDictionariesExpanded = params[9] as Boolean,
hasLoadedPreferences = params[10] as Boolean,
)
}
@@ -203,34 +189,23 @@ class SettingsViewModel(
isDownloadInProgress = true
cancelRefreshPending = false
errorStateHandled = false
successStateHandled = false
}
is DownloadState.Success -> {
pendingSourceUrls.clear()
isDownloadInProgress = false
if (!successStateHandled) {
successStateHandled = true
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
is DownloadState.Error -> {
val startedHere = statusBeforeDownload != DictionaryStatus.Unknown ||
isDownloadInProgress
isDownloadInProgress = false
if (!errorStateHandled) {
errorStateHandled = true
if (dictionaryState.dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
} else if (startedHere) {
when (statusBeforeDownload) {
DictionaryStatus.Unknown, DictionaryStatus.Checking ->
viewModelScope.launch {
restoreOrEvaluateDictionaryStatus()
}
else -> dictionaryStatus.value = statusBeforeDownload
}
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else when (statusBeforeDownload) {
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
else -> statusBeforeDownload
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
@@ -282,6 +257,7 @@ class SettingsViewModel(
importState = operationState.importState,
dictionaryStatus = operationState.dictionaryStatus,
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
hasLoadedPreferences = operationState.hasLoadedPreferences,
pipelineIdle = !isIndexing &&
!operationState.downloadState.isActive &&
!operationState.importState.isActive,
@@ -304,7 +280,7 @@ class SettingsViewModel(
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
} else {
restoreOrEvaluateDictionaryStatus()
dictionaryStatus.value = DictionaryStatus.UpToDate
false
}
} catch (e: Exception) {
@@ -322,6 +298,12 @@ class SettingsViewModel(
} catch (e: Exception) {
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
}
try {
searchBarPosition.value = preferencesManager.searchBarPosition.first()
} catch (e: Exception) {
android.util.Log.w("SettingsViewModel", "Failed to load search bar position: ${e.message}")
}
hasLoadedPreferences.value = true
hasCompletedStartupScan.value = true
if (scanCompleted) {
preferencesManager.setHadNoDictionaries(
@@ -386,8 +368,8 @@ class SettingsViewModel(
return DictionaryStatus.UpToDate
}
val updatable = dictionaryRepository.sourcesWithUpdates(enabledSources)
return if (updatable.isEmpty()) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate(updatable)
val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources)
return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
@@ -409,9 +391,10 @@ class SettingsViewModel(
}
}
private fun saveSearchBarPosition(position: SearchBarPosition) {
private fun saveSearchBarPosition(position: String) {
viewModelScope.launch {
preferencesManager.saveSearchBarPosition(position)
searchBarPosition.value = position
}
}
@@ -572,71 +555,6 @@ class SettingsViewModel(
): List<DictionarySource> =
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
private suspend fun restoreOrEvaluateDictionaryStatus() {
val restored = restorePersistedDictionaryStatus()
if (restored != null) {
dictionaryStatus.value = restored
return
}
dictionaryStatus.value = DictionaryStatus.Checking
dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
}
private suspend fun restorePersistedDictionaryStatus(): DictionaryStatus? {
val persisted = readPersistedDictionaryStatus() ?: return null
if (persisted.date != DateUtils.getCurrentDateString()) return null
return when (persisted.name) {
STATUS_UP_TO_DATE ->
if (withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }) {
DictionaryStatus.UpToDate
} else {
null
}
STATUS_NEEDS_UPDATE -> {
val updatableIds = withContext(Dispatchers.IO) { sourcesMissingTodaysFile() }
val restorableIds = persisted.sourceIds intersect updatableIds
if (restorableIds.isNotEmpty()) DictionaryStatus.NeedsUpdate(restorableIds) else null
}
else -> null
}
}
private suspend fun readPersistedDictionaryStatus(): PersistedDictionaryStatus? {
val raw = preferencesManager.dictionaryStatus() ?: return null
val parts = raw.split('|')
if (parts.isEmpty() || parts.first().isEmpty()) return null
return PersistedDictionaryStatus(
name = parts.first(),
date = parts.getOrElse(1) { "" },
sourceIds = parts.getOrElse(2) { "" }
.split(',')
.map(String::trim)
.filter(String::isNotEmpty)
.toSet(),
)
}
private suspend fun sourcesMissingTodaysFile(): Set<String> {
val sources = preferencesManager.dictionarySources.first()
val dictionaries = localDictionaryRepository.dictionaries.first()
val datedEnabledSources = installedEnabledSources(sources, dictionaries)
.filter { DictionarySource.hasDatePlaceholder(it.urlTemplate) }
if (datedEnabledSources.isEmpty()) return emptySet()
val fileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
) ?: return emptySet()
val today = DateUtils.getCurrentDateString()
return datedEnabledSources
.filterNot { source -> fileNames.any { it.startsWith(fileBaseNameFor(source, today)) } }
.mapTo(mutableSetOf(), DictionarySource::id)
}
private fun fileBaseNameFor(source: DictionarySource, date: String): String =
DictionarySource.extractFileName(source.urlTemplate, date)
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
private suspend fun installedDictionariesAreCurrent(): Boolean {
val sources = preferencesManager.dictionarySources.first()
val dictionaries = localDictionaryRepository.dictionaries.first()
@@ -648,7 +566,11 @@ class SettingsViewModel(
) ?: return true
val today = DateUtils.getCurrentDateString()
return datedEnabledSources.all { source ->
fileNames.any { it.startsWith(fileBaseNameFor(source, today)) }
val baseName = DictionarySource.extractFileName(source.urlTemplate, today)
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
fileNames.any { it.startsWith(baseName) }
}
}
@@ -704,7 +626,24 @@ class SettingsViewModel(
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
restoreOrEvaluateDictionaryStatus()
val savedStatus = preferencesManager.dictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = when (savedStatusName) {
"UpToDate" -> DictionaryStatus.UpToDate
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
else -> null
}
if (restoredStatus != null &&
savedStatusDate == DateUtils.getCurrentDateString() &&
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
) {
dictionaryStatus.value = restoredStatus
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
}
if (result.data > 0) {
@@ -50,7 +50,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.BadgedIcon
import com.example.research.core.domain.model.Dictionary
import kotlinx.coroutines.launch
@@ -85,8 +84,7 @@ fun DictionaryListItem(
onToggle: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false },
hasUpdate: Boolean = false,
isDeleteBlocked: () -> Boolean = { false }
) {
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
@@ -197,15 +195,11 @@ fun DictionaryListItem(
modifier = Modifier.size(IconButtonSize),
contentAlignment = Alignment.Center
) {
BadgedIcon(
Icon(
painter = painterResource(R.drawable.ic_book),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
size = IconSize,
badgeDescription = if (hasUpdate) {
stringResource(R.string.dictionary_update_available)
} else {
null
},
modifier = Modifier.size(IconSize)
)
}
@@ -16,8 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -48,7 +47,6 @@ import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.EmptyState
import com.example.research.common.ui.components.SectionCard
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState
@@ -77,10 +75,7 @@ fun DictionaryManagement(
) {
val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) }
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val haptic = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current
@@ -95,21 +90,6 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
}
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
val isUpdateAvailable = dictionaryStatus is DictionaryStatus.NeedsUpdate &&
!isInProgress && hasEnabledSources && dictionaries.isNotEmpty()
val updatablePaths = remember(dictionaryStatus, sources, dictionaries, isUpdateAvailable) {
val ids = (dictionaryStatus as? DictionaryStatus.NeedsUpdate)?.sourceIds
if (!isUpdateAvailable || ids.isNullOrEmpty()) {
emptySet()
} else {
val updatableSources = sources.filter { it.id in ids }
dictionaries
.filter { dictionary ->
updatableSources.any { DictionarySourceFileMatcher.matches(it, dictionary) }
}
.mapTo(mutableSetOf(), Dictionary::path)
}
}
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
@@ -138,12 +118,7 @@ fun DictionaryManagement(
isExpanded = isExpanded,
onToggle = onToggleExpanded,
subtitle = headerSubtitle,
testTag = "dictionary_header",
badgeDescription = if (isUpdateAvailable && !isExpanded) {
stringResource(R.string.dictionaries_update_available)
} else {
null
},
testTag = "dictionary_header"
)
AnimatedVisibility(
@@ -203,7 +178,7 @@ fun DictionaryManagement(
)
}
} else {
if (isUpdateAvailable) {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) {
DictionaryUpdateCard(
onStartDownload = onStartDownload,
modifier = backgroundModifier
@@ -217,8 +192,7 @@ fun DictionaryManagement(
modifier = Modifier
.fillMaxWidth()
.then(backgroundModifier),
isDeleteBlocked = isDeleteBlocked,
updatablePaths = updatablePaths,
isDeleteBlocked = isDeleteBlocked
)
if (!isInProgress) {
@@ -290,7 +264,6 @@ private fun DictionaryProgressSectionHost(
label = label,
onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0,
indeterminate = progressSnapshot?.indeterminate ?: false,
testTag = progressTestTag,
)
}
@@ -301,8 +274,7 @@ private fun DictionaryListSection(
onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false },
updatablePaths: Set<String> = emptySet(),
isDeleteBlocked: () -> Boolean = { false }
) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) {
@@ -329,8 +301,7 @@ private fun DictionaryListSection(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) },
isDeleteBlocked = isDeleteBlocked,
hasUpdate = dictionary.path in updatablePaths,
isDeleteBlocked = isDeleteBlocked
)
}
}
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
@@ -32,7 +31,6 @@ fun DictionaryProgressSection(
onCancel: () -> Unit,
modifier: Modifier = Modifier,
percent: Int,
indeterminate: Boolean,
testTag: String,
) {
AnimatedVisibility(
@@ -46,7 +44,6 @@ fun DictionaryProgressSection(
var displayedLabel by remember { mutableStateOf(label) }
var displayedPercent by remember { mutableIntStateOf(percent) }
var displayedTestTag by remember { mutableStateOf(testTag) }
var displayedIndeterminate by remember { mutableStateOf(indeterminate) }
LaunchedEffect(visible) {
if (visible) {
@@ -63,7 +60,6 @@ fun DictionaryProgressSection(
displayedLabel = label
displayedPercent = percent
displayedTestTag = testTag
displayedIndeterminate = indeterminate
}
}
val animatedState = remember { Animatable(0f) }
@@ -92,33 +88,21 @@ fun DictionaryProgressSection(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
if (!displayedIndeterminate) {
Text(
text = "$displayedPercent%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = "$displayedPercent%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Box(
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.height(WavyProgressIndicatorDefaults.LinearContainerHeight),
contentAlignment = Alignment.Center,
) {
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))
.height(4.dp)
.clip(MaterialTheme.shapes.small),
)
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
@@ -66,15 +66,6 @@ private fun validateDictionaryUrl(context: UrlValidationContext): String? {
}
}
private val urlSplitRegex = Regex("[\\s,;]+")
private fun splitPastedUrls(text: String): List<String>? =
text.split(urlSplitRegex).filter { it.isNotEmpty() }.takeIf { tokens ->
tokens.size > 1 && tokens.all {
DictionarySource.validateTemplate(it) is DictionarySource.ValidationResult.Valid
}
}
private val urlInputTransformation = InputTransformation {
val text = toString()
val filtered = text
@@ -101,17 +92,6 @@ fun DictionaryUrlInputContent(
val scrollState = rememberScrollState()
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
snapshotFlow { urls.map { it.text.toString() } }.collect { texts ->
val (index, tokens) = texts.withIndex().firstNotNullOfOrNull { (i, text) ->
splitPastedUrls(text)?.let { i to it }
} ?: return@collect
urls[index].edit { replace(0, length, tokens.first()) }
urls.addAll(index + 1, tokens.subList(1, tokens.size).map { TextFieldState(it) })
validationErrors.clear()
}
}
val errorMessages = ErrorMessages(
empty = stringResource(R.string.dictionary_source_url_empty),
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
@@ -133,7 +113,7 @@ fun DictionaryUrlInputContent(
val inputUrlPrefixes = remember {
derivedStateOf {
urls.associate { urlState ->
val urlString = urlState.trimmedText()
val urlString = urlState.text.trim().toString()
urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString)
} else null
@@ -158,7 +138,7 @@ fun DictionaryUrlInputContent(
urls.forEachIndexed { index, urlState ->
val isLastItem = index == urls.lastIndex
val urlString = urlState.trimmedText()
val urlString = urlState.text.trim().toString()
val cachedPrefix = inputUrlPrefixes.value[urlString]
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
validateDictionaryUrl(
@@ -166,7 +146,7 @@ fun DictionaryUrlInputContent(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.trimmedText() },
urlsInForm = urls.map { it.text.trim().toString() },
urlPrefix = cachedPrefix,
errorMessages = errorMessages
)
@@ -249,7 +229,7 @@ fun DictionaryUrlInputContent(
val seenUrls = mutableSetOf<String>()
urls.forEachIndexed { index, urlState ->
val urlString = urlState.trimmedText()
val urlString = urlState.text.trim().toString()
if (urlString.isBlank()) {
if (urls.size == 1) {
validationErrors[index] = errorMessages.empty
@@ -287,9 +267,9 @@ fun DictionaryUrlInputContent(
.weight(1f)
.testTag("add_confirm_button")
.semantics { testTagsAsResourceId = true },
enabled = urls.any { it.trimmedText().isNotBlank() } &&
enabled = urls.any { it.text.trim().toString().isNotBlank() } &&
urls.none { urlState ->
val urlString = urlState.trimmedText()
val urlString = urlState.text.trim().toString()
if (urlString.isEmpty()) {
false
} else {
@@ -299,7 +279,7 @@ fun DictionaryUrlInputContent(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.trimmedText() },
urlsInForm = urls.map { it.text.trim().toString() },
urlPrefix = urlPrefix,
errorMessages = errorMessages
)
@@ -313,5 +293,3 @@ fun DictionaryUrlInputContent(
Spacer(modifier = Modifier.height(8.dp))
}
}
private fun TextFieldState.trimmedText(): String = text.trim().toString()
@@ -10,29 +10,30 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.core.domain.model.SearchBarPosition
@Composable
fun SearchBarPositionSection(
currentPosition: SearchBarPosition,
onPositionChanged: (SearchBarPosition) -> Unit,
currentPosition: String,
onPositionChanged: (String) -> Unit,
modifier: Modifier = Modifier
) {
val positions = listOf(
"TOP" to stringResource(R.string.search_bar_position_top),
"BOTTOM" to stringResource(R.string.search_bar_position_bottom)
)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
SearchBarPosition.entries.forEach { position ->
val isSelected = currentPosition == position
positions.forEach { (code, label) ->
val isSelected = currentPosition == code
OutlinedChoiceButton(
text = when (position) {
SearchBarPosition.TOP -> stringResource(R.string.search_bar_position_top)
SearchBarPosition.BOTTOM -> stringResource(R.string.search_bar_position_bottom)
},
onClick = { onPositionChanged(position) },
text = label,
onClick = { onPositionChanged(code) },
modifier = Modifier
.weight(1f)
.testTag("search_bar_${position.name}"),
.testTag("search_bar_$code"),
selected = isSelected
)
}
@@ -6,7 +6,6 @@ object Spacing {
// Semantic spacing constants for specific UI elements
val topBarPadding = 8.dp
val topBarHeight = 56.dp
val topBarCenter = topBarPadding + topBarHeight / 2
val listItemVertical = 8.dp
val contentStart = 32.dp
@@ -24,20 +24,12 @@ object AppWindowInsets {
@Composable
get() = WindowInsets.navigationBars.union(WindowInsets.ime)
val statusBarsAndIme: WindowInsets
@Composable
get() = WindowInsets.statusBars.union(WindowInsets.ime)
val fullScreen: WindowInsets
@Composable
get() = WindowInsets.statusBars
.union(WindowInsets.navigationBars)
.union(WindowInsets.displayCutout)
val bottomNavigation: WindowInsets
@Composable
get() = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
val navigationOnly: WindowInsets
@Composable
get() = WindowInsets.navigationBars
+2 -1
View File
@@ -2,6 +2,7 @@
<string name="app_name">ReSearch</string>
<string name="settings_title">Настройки</string>
<string name="action_back">Назад</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">неизвестно</string>
<string name="button_cancel_download">Отмена</string>
<string name="action_ok">ОК</string>
@@ -62,6 +63,7 @@
<string name="error_scan_directory_failed">Не удалось просканировать папку со словарями</string>
<string name="error_search_failed">Ошибка поиска запроса \'%1$s\' в %2$s</string>
<string name="error_failed_load_index_metadata">Не удалось загрузить метаданные индекса: %1$s</string>
<string name="error_loading_more">Ошибка загрузки</string>
<plurals name="active_dictionaries_count">
<item quantity="one">%1$d активный словарь</item>
<item quantity="few">%1$d активных словаря</item>
@@ -87,7 +89,6 @@
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
<string name="dictionaries_not_downloaded">Словари еще не загружены</string>
<string name="dictionaries_update_available">Доступны обновления словарей</string>
<string name="dictionary_update_available">Доступно обновление</string>
<string name="action_cancel">Отмена</string>
<string name="action_add">Добавить</string>
<string name="action_add_another_url">Добавить ещё URL</string>
+2 -1
View File
@@ -2,6 +2,7 @@
<string name="app_name">ReSearch</string>
<string name="settings_title">Settings</string>
<string name="action_back">Back</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">unknown</string>
<string name="button_cancel_download">Cancel</string>
<string name="action_ok">OK</string>
@@ -62,6 +63,7 @@
<string name="error_scan_directory_failed">Failed to scan dictionary folder</string>
<string name="error_search_failed">Search failed for query \'%1$s\' in %2$s</string>
<string name="error_failed_load_index_metadata">Failed to load index metadata: %1$s</string>
<string name="error_loading_more">Loading error</string>
<plurals name="active_dictionaries_count">
<item quantity="one">%1$d active dictionary</item>
<item quantity="other">%1$d active dictionaries</item>
@@ -85,7 +87,6 @@
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
<string name="dictionaries_update_available">Dictionary updates available</string>
<string name="dictionary_update_available">Update available</string>
<string name="action_cancel">Cancel</string>
<string name="action_add">Add</string>
<string name="action_add_another_url">Add another URL</string>
+1 -1
View File
@@ -2,7 +2,7 @@ plugins {
alias(libs.plugins.aboutlibraries) apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
id("com.github.ben-manes.versions") version "0.65.0"
id("com.github.ben-manes.versions") version "0.54.0"
}
tasks.register("assembleDebug") {
dependsOn(":app:assembleDebug")
@@ -1 +1 @@
- Initial release
- Initial F-Droid release
@@ -1,6 +0,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
@@ -1,6 +0,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
@@ -1,6 +1,4 @@
- The app starts faster
- Faster dictionary indexing
- Improved app stability
- Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout
@@ -10,7 +8,9 @@
- Download progress notifications are more reliable
- Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically
- Improved app stability
- Faster dictionary indexing
- Updated build tooling and documentation
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
- Updated build tooling and documentation
@@ -1,4 +1,4 @@
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.
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.
Features:
@@ -8,7 +8,7 @@ Features:
- Import .dsl and .dsl.dz files from device storage
- Automatic indexing with binary index files for instant search
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
- Adaptive two-pane layout for tablets and large screens
- Adaptive three-pane layout for tablets and large screens
- Material 3 UI with dynamic color, plus light, dark, and system-follow themes
- English and Russian localization
- No tracking, no ads, fully open source
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

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

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

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