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