Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92fc5562db | ||
|
|
e9706ab12a | ||
|
|
9dac7507ed | ||
|
|
7deabeb02a | ||
|
|
c051ba9585 | ||
|
|
97eeae5876 | ||
|
|
a2079a37a1 | ||
|
|
bd253e3e37 | ||
|
|
4b8fce960e | ||
|
|
05f62a42c6 | ||
|
|
232c1955ec | ||
|
|
a62e3a1e8f | ||
|
|
36d58085d7 | ||
|
|
7dedfad68d | ||
|
|
af147ada50 | ||
|
|
32349e2358 | ||
|
|
c56bb4e2a6 | ||
|
|
6514e81804 | ||
|
|
7c87f20ace | ||
|
|
b5bb605665 | ||
|
|
5c275e16d6 |
No files matched your search
@@ -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> •
|
||||
@@ -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,12 +80,12 @@ 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.2
|
||||
- Paging 3.5.1
|
||||
- OkHttp 5.5.0
|
||||
- kotlinx.serialization 1.11.0
|
||||
- AboutLibraries 15.2.0 metadata generation
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -15,17 +15,49 @@ 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) {
|
||||
@@ -33,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) {
|
||||
@@ -59,9 +90,9 @@ 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
|
||||
)
|
||||
@@ -69,19 +100,95 @@ class IndexEntryPagingSource(
|
||||
}
|
||||
|
||||
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
||||
val ranked = rawResults
|
||||
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>()
|
||||
@@ -144,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 {
|
||||
@@ -256,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)
|
||||
}
|
||||
|
||||
@@ -322,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 {
|
||||
@@ -385,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
|
||||
}
|
||||
}
|
||||
@@ -489,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,7 +1,6 @@
|
||||
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 data class SearchRankingContext(
|
||||
@@ -41,11 +40,6 @@ internal fun List<IndexEntry>.rankBySearchRelevance(
|
||||
entry = entry,
|
||||
)
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<SortKey> { it.matchTier }
|
||||
.thenBy { it.exactMatch }
|
||||
.thenBy { it.charCount }
|
||||
.thenBy(NaturalStringComparator) { it.word }
|
||||
)
|
||||
.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
|
||||
|
||||
@@ -57,7 +57,6 @@ class DownloadManager(
|
||||
if (!downloadScope.isActive) return
|
||||
onFlowStarted?.invoke()
|
||||
cancelCleanupJob = null
|
||||
updateDownloadProgressState(DownloadState.Loading, 0f)
|
||||
downloadJob = downloadScope.launch {
|
||||
mutex.withLock {
|
||||
try {
|
||||
@@ -82,6 +81,8 @@ class DownloadManager(
|
||||
return@withLock
|
||||
}
|
||||
|
||||
updateDownloadProgressState(DownloadState.Loading, 0f)
|
||||
|
||||
var lastReportedProgressBucket = -1
|
||||
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
|
||||
dictionaryRepository.downloadSources(downloadableSources) { progress ->
|
||||
@@ -148,7 +149,6 @@ class DownloadManager(
|
||||
if (!downloadScope.isActive) return
|
||||
onFlowStarted?.invoke()
|
||||
cancelCleanupJob = null
|
||||
updateDownloadProgressState(DownloadState.Loading, 0f)
|
||||
downloadJob = downloadScope.launch {
|
||||
mutex.withLock {
|
||||
try {
|
||||
@@ -177,6 +177,8 @@ class DownloadManager(
|
||||
return@withLock
|
||||
}
|
||||
|
||||
updateDownloadProgressState(DownloadState.Loading, 0f)
|
||||
|
||||
var lastReportedProgressBucket = -1
|
||||
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
|
||||
dictionaryRepository.downloadSources(downloadableSources) { progress ->
|
||||
|
||||
@@ -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>> =
|
||||
@@ -142,6 +142,7 @@ class SearchViewModel(
|
||||
}
|
||||
SearchAction.ClearArticle -> clearArticle()
|
||||
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
|
||||
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,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)
|
||||
}
|
||||
@@ -212,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
|
||||
}
|
||||
@@ -224,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()
|
||||
@@ -238,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)
|
||||
@@ -317,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
|
||||
@@ -93,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)
|
||||
@@ -149,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,
|
||||
@@ -187,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,7 +189,7 @@ internal fun MainScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
|
||||
.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
|
||||
) {
|
||||
SearchResultsPane(
|
||||
searchState = searchState,
|
||||
@@ -216,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
|
||||
@@ -47,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,
|
||||
@@ -55,6 +54,7 @@ private data class OperationStateInputs(
|
||||
val isLanguageExpanded: Boolean,
|
||||
val isSearchBarExpanded: Boolean,
|
||||
val isDictionariesExpanded: Boolean,
|
||||
val hasLoadedPreferences: Boolean,
|
||||
)
|
||||
|
||||
class SettingsViewModel(
|
||||
@@ -67,7 +67,6 @@ class SettingsViewModel(
|
||||
progressStateHolder: DictionaryProgressStateHolder,
|
||||
appVersion: String,
|
||||
initialSectionExpansion: SectionExpansionStates,
|
||||
initialSearchBarPosition: SearchBarPosition,
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
|
||||
@@ -80,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()
|
||||
@@ -95,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 {
|
||||
@@ -117,7 +115,7 @@ class SettingsViewModel(
|
||||
dictionaryStatus.collect { status ->
|
||||
val persistedName = when (status) {
|
||||
DictionaryStatus.UpToDate -> "UpToDate"
|
||||
is DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
|
||||
DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
|
||||
DictionaryStatus.Empty -> "Empty"
|
||||
else -> null
|
||||
}
|
||||
@@ -158,7 +156,7 @@ class SettingsViewModel(
|
||||
val operationStateInputs = combine(
|
||||
downloadManager.downloadState,
|
||||
language,
|
||||
preferencesManager.searchBarPosition,
|
||||
searchBarPosition,
|
||||
dictionaryImportManager.importState,
|
||||
dictionaryStatus,
|
||||
hasCompletedStartupScan,
|
||||
@@ -166,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,
|
||||
@@ -179,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,16 +189,12 @@ 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 -> {
|
||||
@@ -261,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,
|
||||
@@ -301,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(
|
||||
@@ -365,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()) {
|
||||
@@ -388,9 +391,10 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveSearchBarPosition(position: SearchBarPosition) {
|
||||
private fun saveSearchBarPosition(position: String) {
|
||||
viewModelScope.launch {
|
||||
preferencesManager.saveSearchBarPosition(position)
|
||||
searchBarPosition.value = position
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,7 +629,11 @@ class SettingsViewModel(
|
||||
val savedStatus = preferencesManager.dictionaryStatus()
|
||||
val savedStatusName = savedStatus?.substringBefore('|')
|
||||
val savedStatusDate = savedStatus?.substringAfter('|', "")
|
||||
val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate else null
|
||||
val restoredStatus = when (savedStatusName) {
|
||||
"UpToDate" -> DictionaryStatus.UpToDate
|
||||
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
|
||||
else -> null
|
||||
}
|
||||
if (restoredStatus != null &&
|
||||
savedStatusDate == DateUtils.getCurrentDateString() &&
|
||||
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 79 KiB |
|
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 с динамическими цветами, светлая, тёмная и системная темы
|
||||
- Локализация на английский и русский языки
|
||||
- Без трекеров, без рекламы, полностью открытый исходный код
|
||||
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 229 KiB |
@@ -6,11 +6,10 @@ 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.2"
|
||||
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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%
|
||||