Compare commits
98
Commits
1.1.0
..
cea46c0955
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cea46c0955 | ||
|
|
37127b7bc9 | ||
|
|
84fcd77f02 | ||
|
|
3d83d04cea | ||
|
|
4b627b4d05 | ||
|
|
5c655e769c | ||
|
|
0c4ab6dad5 | ||
|
|
87dccb4993 | ||
|
|
9de47c7f0f | ||
|
|
9b6a34b607 | ||
|
|
d2c9dd7701 | ||
|
|
f227546111 | ||
|
|
cec914ed00 | ||
|
|
73384bc0eb | ||
|
|
c4e4664542 | ||
|
|
59dfb53756 | ||
|
|
e423bd6c58 | ||
|
|
0bae625c01 | ||
|
|
344852a871 | ||
|
|
6146c0ebfe | ||
|
|
1202662afe | ||
|
|
c90f658bfe | ||
|
|
cd589b30a4 | ||
|
|
65684b291d | ||
|
|
35f2ded634 | ||
|
|
c7f658dd94 | ||
|
|
ed4a4436b8 | ||
|
|
c7c080939c | ||
|
|
2c8668dab3 | ||
|
|
e4e50db08e | ||
|
|
98ec38f272 | ||
|
|
6040ca8db5 | ||
|
|
71fea4472b | ||
|
|
b5fc7e7246 | ||
|
|
447d086cdf | ||
|
|
ac9a22dbe1 | ||
|
|
cf29f19511 | ||
|
|
ed1e1db7f2 | ||
|
|
a5b54def51 | ||
|
|
ee19f84f91 | ||
|
|
4e42a269a7 | ||
|
|
4caf3a534d | ||
|
|
c0c9211b82 | ||
|
|
f5880d72ad | ||
|
|
95f4317eaf | ||
|
|
df96deb1fc | ||
|
|
451267688a | ||
|
|
1d84eaf9c0 | ||
|
|
59115ff9a3 | ||
|
|
eb918ca09f | ||
|
|
638e886498 | ||
|
|
c2b2bf1c8a | ||
|
|
30aa86f39f | ||
|
|
8a506763a5 | ||
|
|
4ce39d0e4f | ||
|
|
058330dd34 | ||
|
|
f39aa2a341 | ||
|
|
f51684367a | ||
|
|
bddcb99177 | ||
|
|
2dadfa11e9 | ||
|
|
2394c7cf5e | ||
|
|
830f99578a | ||
|
|
214b9f91e4 | ||
|
|
edf6a6074e | ||
|
|
8319973161 | ||
|
|
1631e5384c | ||
|
|
1d4990e5b7 | ||
|
|
8c6e43f3db | ||
|
|
2f22144d19 | ||
|
|
9807a0cf27 | ||
|
|
de27eecd9e | ||
|
|
c4b3a57ccb | ||
|
|
d96fad9cab | ||
|
|
e21a134d65 | ||
|
|
b9baf61264 | ||
|
|
7b63ee4377 | ||
|
|
d892427a3f | ||
|
|
628813c3f6 | ||
|
|
4f202f8c10 | ||
|
|
44ebe68f75 | ||
|
|
5de466792a | ||
|
|
b6e6bd328c | ||
|
|
5132ae66b9 | ||
|
|
5151665216 | ||
|
|
96461bd7cf | ||
|
|
98ef99ab53 | ||
|
|
43823e3bfa | ||
|
|
bab03b6c73 | ||
|
|
d4e6302274 | ||
|
|
bc3fa85b9d | ||
|
|
457bbe43b6 | ||
|
|
f751ac0d36 | ||
|
|
910bc8313c | ||
|
|
9d18d0c927 | ||
|
|
69bbb97b2a | ||
|
|
c8adf537d4 | ||
|
|
84612b26f2 | ||
|
|
a5f3af83a4 |
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 OneWay
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#screenshots">Screenshots</a> •
|
<a href="#screenshots">Screenshots</a> •
|
||||||
<a href="#features">Features</a> •
|
<a href="#features">Features</a> •
|
||||||
<a href="#architecture">Architecture</a> •
|
|
||||||
<a href="#dictionary-pipeline">Pipeline</a> •
|
<a href="#dictionary-pipeline">Pipeline</a> •
|
||||||
<a href="#build">Build</a>
|
<a href="#build">Build</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -22,32 +21,19 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Fast headword search across multiple DSL dictionaries.
|
- Fast headword search across multiple DSL dictionaries.
|
||||||
|
- Search history in the empty search state: recent queries with per-entry removal and clear-all.
|
||||||
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
|
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
|
||||||
- Download dictionaries from configured URLs.
|
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
|
||||||
- Automatic extraction, DictZip compression, and indexing.
|
- Automatic extraction, DictZip compression, and indexing.
|
||||||
|
- Faithful article rendering with DSL indentation, margins, and named colors.
|
||||||
- Offline search after dictionaries are indexed.
|
- Offline search after dictionaries are indexed.
|
||||||
- Random-access article loading from DictZip dictionaries.
|
- Random-access article loading from DictZip dictionaries.
|
||||||
- Background processing with progress shown in Settings and notifications.
|
- Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification.
|
||||||
- Dictionary management with enable, disable, update, and delete actions.
|
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
|
||||||
- Material 3 interface with light, dark, and system themes.
|
- Material 3 interface with dynamic color, light, dark, and system themes.
|
||||||
- English and Russian localization.
|
- English and Russian localization.
|
||||||
- About screen with open source license information.
|
- About screen with open source license information.
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
ReSearch is an Android app written in Kotlin and Jetpack Compose.
|
|
||||||
|
|
||||||
```text
|
|
||||||
com.example.research/
|
|
||||||
├── common/ # shared progress, UI, file, archive, notification utilities
|
|
||||||
├── core/ # domain models, source validation, operation results
|
|
||||||
├── data/ # DictZip, DSL parsing, indexing, paging, repositories
|
|
||||||
├── feature/ # search, dictionary download, local import workflows
|
|
||||||
└── ui/ # Compose screens, article view, settings, navigation
|
|
||||||
```
|
|
||||||
|
|
||||||
State is exposed through Kotlin Flow and collected by Compose screens. Long-running work uses coroutines and foreground services where Android requires visible progress.
|
|
||||||
|
|
||||||
## Dictionary Pipeline
|
## Dictionary Pipeline
|
||||||
|
|
||||||
ReSearch supports both downloaded and locally imported dictionaries.
|
ReSearch supports both downloaded and locally imported dictionaries.
|
||||||
@@ -94,16 +80,16 @@ The main search screen remains usable as long as at least one indexed dictionary
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Kotlin 2.4.0
|
- Kotlin 2.4.20
|
||||||
- Jetpack Compose (BOM 2026.06.01)
|
- Jetpack Compose (BOM 2026.09.00)
|
||||||
- Material 3
|
- Material 3
|
||||||
- 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.0
|
- Paging 3.5.1
|
||||||
- OkHttp 5.4.0
|
- OkHttp 5.5.0
|
||||||
- kotlinx.serialization 1.11.0
|
- kotlinx.serialization 1.11.0
|
||||||
- AboutLibraries 15.0.3 metadata generation
|
- AboutLibraries 15.2.0 metadata generation
|
||||||
- Android Gradle Plugin 9.2.1
|
- Android Gradle Plugin 9.4.1
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -111,8 +97,8 @@ The main search screen remains usable as long as at least one indexed dictionary
|
|||||||
./gradlew :app:assembleRelease
|
./gradlew :app:assembleRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
|
The release APK targets `arm64-v8a`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
[MIT](LICENSE)
|
||||||
+5
-14
@@ -92,14 +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 = 2
|
versionCode = 9
|
||||||
versionName = "1.1.0"
|
versionName = "1.6.0"
|
||||||
|
|
||||||
val isFdroid = project.hasProperty("fdroid")
|
|
||||||
if (isFdroid) {
|
|
||||||
applicationIdSuffix = ".fdroid"
|
|
||||||
versionNameSuffix = "-fdroid"
|
|
||||||
}
|
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@@ -120,12 +114,9 @@ android {
|
|||||||
abiFilters += "arm64-v8a"
|
abiFilters += "arm64-v8a"
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinifyEnabled = true
|
optimization {
|
||||||
isShrinkResources = true
|
enable = true
|
||||||
proguardFiles(
|
}
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.research.core.performance
|
||||||
|
|
||||||
|
import android.os.Trace
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
object ReSearchTrace {
|
||||||
|
const val SEARCH_PAGING = "ReSearch/Search/Paging"
|
||||||
|
const val SEARCH_DIRECT = "ReSearch/Search/Direct"
|
||||||
|
const val ARTICLE_PARSE = "ReSearch/Article/Parse"
|
||||||
|
const val ARTICLE_READ = "ReSearch/Article/Read"
|
||||||
|
const val ARTICLE_PRE_MEASURE = "ReSearch/Article/PreMeasure"
|
||||||
|
const val DICTIONARY_DOWNLOAD = "ReSearch/Dictionary/Download"
|
||||||
|
const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract"
|
||||||
|
const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index"
|
||||||
|
const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import"
|
||||||
|
const val FLOW_UPDATE_CARD_APPEAR = "ReSearch/Flow/UpdateCardAppear"
|
||||||
|
const val FLOW_UPDATING = "ReSearch/Flow/Updating"
|
||||||
|
const val FLOW_PROGRESS_DISAPPEAR = "ReSearch/Flow/ProgressDisappear"
|
||||||
|
|
||||||
|
private val nextCookie = AtomicInteger()
|
||||||
|
|
||||||
|
@PublishedApi
|
||||||
|
internal val isAndroidRuntime = System.getProperty("java.vm.name") == "Dalvik"
|
||||||
|
|
||||||
|
inline fun <T> section(name: String, block: () -> T): T {
|
||||||
|
if (!isAndroidRuntime) return block()
|
||||||
|
Trace.beginSection(name)
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
Trace.endSection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <T> asyncSection(name: String, block: suspend () -> T): T {
|
||||||
|
if (!isAndroidRuntime) return block()
|
||||||
|
val cookie = nextCookie.incrementAndGet()
|
||||||
|
Trace.beginAsyncSection(name, cookie)
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
Trace.endAsyncSection(name, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.example.research
|
package com.example.research
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface DictionaryStatus {
|
sealed interface DictionaryStatus {
|
||||||
data object Unknown : DictionaryStatus
|
data object Unknown : DictionaryStatus
|
||||||
data object Checking : DictionaryStatus
|
data object Checking : DictionaryStatus
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ import com.example.research.common.ui.AppLocaleProvider
|
|||||||
import com.example.research.common.ui.BackgroundTextMeasurementProvider
|
import com.example.research.common.ui.BackgroundTextMeasurementProvider
|
||||||
import com.example.research.common.ui.theme.ReSearchTheme
|
import com.example.research.common.ui.theme.ReSearchTheme
|
||||||
import com.example.research.core.domain.model.AppTheme
|
import com.example.research.core.domain.model.AppTheme
|
||||||
|
import com.example.research.core.domain.model.SearchBarPosition
|
||||||
|
import com.example.research.data.local.preferences.SectionExpansionStates
|
||||||
import com.example.research.feature.search.SearchAction
|
import com.example.research.feature.search.SearchAction
|
||||||
import com.example.research.feature.search.SearchViewModel
|
import com.example.research.feature.search.SearchViewModel
|
||||||
import com.example.research.ui.navigation.AppNavigation
|
import com.example.research.ui.navigation.AppNavigation
|
||||||
import com.example.research.ui.settings.SettingsViewModel
|
import com.example.research.ui.settings.SettingsViewModel
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val app = application as ReSearchApplication
|
val app = application as ReSearchApplication
|
||||||
viewModelFactory {
|
viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle())
|
SearchViewModel(application, app.localDictionaryRepository, app.preferencesManager, createSavedStateHandle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
app.dictionaryImportManager,
|
app.dictionaryImportManager,
|
||||||
app.dictionaryProgressStateHolder,
|
app.dictionaryProgressStateHolder,
|
||||||
versionName,
|
versionName,
|
||||||
|
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
|
||||||
|
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,17 +81,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestNotificationPermissionIfNeeded()
|
requestNotificationPermissionIfNeeded()
|
||||||
|
|
||||||
// Load theme and language synchronously before setContent to prevent flashing
|
|
||||||
val app = application as ReSearchApplication
|
val app = application as ReSearchApplication
|
||||||
val initialTheme = loadInitialTheme(app)
|
|
||||||
val initialLanguage = loadInitialLanguage(app)
|
|
||||||
|
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
setContent {
|
setContent {
|
||||||
|
val initialLaunch by app.initialLaunch.collectAsStateWithLifecycle()
|
||||||
|
val launch = initialLaunch ?: return@setContent
|
||||||
|
|
||||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
// Use loaded values initially, then switch to state values when ready
|
// Use loaded values initially, then switch to state values when ready
|
||||||
val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: initialTheme
|
val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: launch.theme
|
||||||
val language = settingsState.language.takeIf { it != "system" } ?: initialLanguage
|
val language = settingsState.language.takeIf { it != "system" } ?: launch.language
|
||||||
val darkTheme = when (theme) {
|
val darkTheme = when (theme) {
|
||||||
AppTheme.LIGHT -> false
|
AppTheme.LIGHT -> false
|
||||||
AppTheme.DARK -> true
|
AppTheme.DARK -> true
|
||||||
@@ -104,7 +105,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
) {
|
) {
|
||||||
AppNavigation(
|
AppNavigation(
|
||||||
searchViewModel = searchViewModel,
|
searchViewModel = searchViewModel,
|
||||||
settingsViewModel = settingsViewModel
|
settingsViewModel = settingsViewModel,
|
||||||
|
seedNoDictionaries = launch.seedNoDictionaries
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,44 +129,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load initial theme synchronously from DataStore to prevent theme flashing.
|
|
||||||
* This is called before setContent to ensure the correct theme is applied immediately.
|
|
||||||
* Uses runBlocking to synchronously read from DataStore.
|
|
||||||
*/
|
|
||||||
private fun loadInitialTheme(app: ReSearchApplication): AppTheme {
|
|
||||||
return try {
|
|
||||||
runBlocking {
|
|
||||||
val themeString = app.preferencesManager.themeMode.first()
|
|
||||||
try {
|
|
||||||
AppTheme.valueOf(themeString)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
android.util.Log.w("MainActivity", "Invalid theme value: $themeString, using SYSTEM")
|
|
||||||
AppTheme.SYSTEM
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("MainActivity", "Failed to load theme preference: ${e.message}")
|
|
||||||
AppTheme.SYSTEM
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load initial language synchronously from DataStore to prevent language flashing.
|
|
||||||
* This is called before setContent to ensure the correct language is applied immediately.
|
|
||||||
* Uses runBlocking to synchronously read from DataStore.
|
|
||||||
*/
|
|
||||||
private fun loadInitialLanguage(app: ReSearchApplication): String {
|
|
||||||
return try {
|
|
||||||
runBlocking {
|
|
||||||
app.preferencesManager.language.first()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("MainActivity", "Failed to load language preference: ${e.message}")
|
|
||||||
"system"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
@@ -180,7 +144,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
|
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
|
||||||
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
|
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
|
||||||
text?.toString()?.let {
|
text?.toString()?.let {
|
||||||
searchViewModel.onAction(SearchAction.HandleExternalSearch(it))
|
searchViewModel.onAction(SearchAction.QueryChanged(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package com.example.research
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.example.research.common.progress.DictionaryProgressStateHolder
|
import com.example.research.common.progress.DictionaryProgressStateHolder
|
||||||
|
import com.example.research.common.util.NotificationHelper
|
||||||
|
import com.example.research.core.domain.model.AppTheme
|
||||||
|
import com.example.research.core.domain.model.SearchBarPosition
|
||||||
import com.example.research.data.local.preferences.PreferencesManager
|
import com.example.research.data.local.preferences.PreferencesManager
|
||||||
|
import com.example.research.data.local.preferences.SectionExpansionStates
|
||||||
import com.example.research.data.repository.LocalDictionaryRepository
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.feature.download.DownloadManager
|
import com.example.research.feature.download.DownloadManager
|
||||||
import com.example.research.feature.download.repository.DictionaryRepository
|
import com.example.research.feature.download.repository.DictionaryRepository
|
||||||
@@ -11,10 +15,26 @@ import com.example.research.feature.import.DictionaryImportManager
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Protocol
|
import okhttp3.Protocol
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values a cold start needs before the first composed frame: the initial
|
||||||
|
* theme, locale, empty-library navigation seed, and settings expansion state.
|
||||||
|
*/
|
||||||
|
data class InitialLaunch(
|
||||||
|
val theme: AppTheme,
|
||||||
|
val language: String,
|
||||||
|
val searchBarPosition: SearchBarPosition,
|
||||||
|
val seedNoDictionaries: Boolean,
|
||||||
|
val sectionExpansion: SectionExpansionStates,
|
||||||
|
)
|
||||||
|
|
||||||
class ReSearchApplication : Application() {
|
class ReSearchApplication : Application() {
|
||||||
|
|
||||||
lateinit var preferencesManager: PreferencesManager
|
lateinit var preferencesManager: PreferencesManager
|
||||||
@@ -36,16 +56,29 @@ class ReSearchApplication : Application() {
|
|||||||
private set
|
private set
|
||||||
lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder
|
lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder
|
||||||
private set
|
private set
|
||||||
|
lateinit var dictionaryProgressPresenter: com.example.research.common.progress.DictionaryProgressPresenter
|
||||||
|
private set
|
||||||
|
lateinit var dictionaryPipelineCoordinator: com.example.research.common.progress.DictionaryPipelineCoordinator
|
||||||
|
private set
|
||||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
private val _initialLaunch = MutableStateFlow<InitialLaunch?>(null)
|
||||||
|
val initialLaunch: StateFlow<InitialLaunch?> = _initialLaunch.asStateFlow()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
val notificationHelper = NotificationHelper(this)
|
||||||
|
notificationHelper.cancelProgressNotification()
|
||||||
|
|
||||||
preferencesManager = PreferencesManager(this)
|
preferencesManager = PreferencesManager(this)
|
||||||
applicationScope.launch(Dispatchers.IO) {
|
applicationScope.launch(Dispatchers.IO) {
|
||||||
preferencesManager.sanitizeDictionarySources()
|
preferencesManager.sanitizeDictionarySources()
|
||||||
}
|
}
|
||||||
localDictionaryRepository = LocalDictionaryRepository(this)
|
applicationScope.launch(Dispatchers.IO) {
|
||||||
|
_initialLaunch.value = loadInitialLaunch()
|
||||||
|
}
|
||||||
|
localDictionaryRepository = LocalDictionaryRepository(this, preferencesManager = preferencesManager)
|
||||||
|
|
||||||
okHttpClient = OkHttpClient.Builder()
|
okHttpClient = OkHttpClient.Builder()
|
||||||
.protocols(listOf(Protocol.HTTP_1_1))
|
.protocols(listOf(Protocol.HTTP_1_1))
|
||||||
@@ -62,7 +95,8 @@ class ReSearchApplication : Application() {
|
|||||||
|
|
||||||
downloadManager = DownloadManager(
|
downloadManager = DownloadManager(
|
||||||
dictionaryRepository = downloadDictionaryRepository,
|
dictionaryRepository = downloadDictionaryRepository,
|
||||||
unknownErrorMessage = getString(R.string.unknown_error)
|
unknownErrorMessage = getString(R.string.unknown_error),
|
||||||
|
sourceUnavailableMessage = getString(R.string.download_file_not_found)
|
||||||
)
|
)
|
||||||
|
|
||||||
dictionaryImportManager = DictionaryImportManager(
|
dictionaryImportManager = DictionaryImportManager(
|
||||||
@@ -76,6 +110,54 @@ class ReSearchApplication : Application() {
|
|||||||
localDictionaryRepository = localDictionaryRepository,
|
localDictionaryRepository = localDictionaryRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dictionaryProgressPresenter = com.example.research.common.progress.DictionaryProgressPresenter(
|
||||||
|
scope = applicationScope,
|
||||||
|
progressSnapshot = dictionaryProgressStateHolder.progressSnapshot,
|
||||||
|
notifications = notificationHelper,
|
||||||
|
)
|
||||||
|
|
||||||
|
dictionaryPipelineCoordinator = com.example.research.common.progress.DictionaryPipelineCoordinator(
|
||||||
|
scope = applicationScope,
|
||||||
|
steps = com.example.research.common.progress.DefaultReindexingSteps(
|
||||||
|
localDictionaryRepository = localDictionaryRepository,
|
||||||
|
downloadDictionaryRepository = downloadDictionaryRepository,
|
||||||
|
),
|
||||||
|
importOperations = dictionaryImportManager,
|
||||||
|
ui = dictionaryProgressPresenter,
|
||||||
|
dictionaryPath = { preferencesManager.dictionaryPath },
|
||||||
|
finishService = {
|
||||||
|
val intent = android.content.Intent(
|
||||||
|
this,
|
||||||
|
com.example.research.feature.download.service.DictionaryForegroundService::class.java
|
||||||
|
).apply {
|
||||||
|
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_FINISH
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startService(intent)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
dictionaryImportManager.onFlowStarted = { dictionaryProgressPresenter.beginFlow() }
|
||||||
|
dictionaryImportManager.onTerminal = dictionaryPipelineCoordinator::onImportTerminal
|
||||||
|
downloadManager.onFlowStarted = { dictionaryProgressPresenter.beginFlow() }
|
||||||
|
downloadManager.onDownloadSuccess = dictionaryPipelineCoordinator::onDownloadSuccess
|
||||||
|
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadInitialLaunch(): InitialLaunch {
|
||||||
|
return InitialLaunch(
|
||||||
|
theme = runCatching { AppTheme.valueOf(preferencesManager.themeMode.first()) }
|
||||||
|
.getOrDefault(AppTheme.SYSTEM),
|
||||||
|
language = runCatching { preferencesManager.language.first() }.getOrDefault("system"),
|
||||||
|
searchBarPosition = runCatching { preferencesManager.searchBarPosition.first() }
|
||||||
|
.getOrDefault(SearchBarPosition.TOP),
|
||||||
|
seedNoDictionaries = runCatching { preferencesManager.hadNoDictionaries.first() }
|
||||||
|
.getOrDefault(true),
|
||||||
|
sectionExpansion = runCatching { preferencesManager.sectionExpansionStates() }
|
||||||
|
.getOrDefault(SectionExpansionStates()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTrimMemory(level: Int) {
|
override fun onTrimMemory(level: Int) {
|
||||||
|
|||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
package com.example.research.common.progress
|
||||||
|
|
||||||
|
import com.example.research.core.util.OperationResult
|
||||||
|
import com.example.research.feature.download.model.DownloadState
|
||||||
|
import com.example.research.ui.settings.ImportState
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class DictionaryPipelineCoordinator(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val steps: ReindexingSteps,
|
||||||
|
private val importOperations: ImportFlowOperations,
|
||||||
|
private val ui: PipelineTerminalUi,
|
||||||
|
private val dictionaryPath: () -> String,
|
||||||
|
private val finishService: () -> Unit,
|
||||||
|
private val launchDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) {
|
||||||
|
private var pipelineJob: Job? = null
|
||||||
|
|
||||||
|
fun onImportTerminal(state: ImportState) {
|
||||||
|
when (state) {
|
||||||
|
is ImportState.Success -> runPipeline(isImport = true)
|
||||||
|
is ImportState.Error -> {
|
||||||
|
ui.showError()
|
||||||
|
finishService()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDownloadSuccess() = runPipeline(isImport = false)
|
||||||
|
|
||||||
|
fun onDownloadTerminal(state: DownloadState) {
|
||||||
|
when (state) {
|
||||||
|
is DownloadState.Error -> {
|
||||||
|
ui.showError()
|
||||||
|
finishService()
|
||||||
|
}
|
||||||
|
is DownloadState.Cancelled -> {
|
||||||
|
ui.cancelProgress()
|
||||||
|
finishService()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelPipeline() {
|
||||||
|
pipelineJob?.cancel()
|
||||||
|
pipelineJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runPipeline(isImport: Boolean) {
|
||||||
|
pipelineJob?.cancel()
|
||||||
|
pipelineJob = scope.launch(launchDispatcher) {
|
||||||
|
val dir = File(dictionaryPath())
|
||||||
|
val filesBeforeReindex = dir.listFiles()?.map { it.name }?.toSet() ?: emptySet()
|
||||||
|
try {
|
||||||
|
triggerReindexing(isImport)
|
||||||
|
if (isImport) {
|
||||||
|
importOperations.getAndClearImportedFiles()
|
||||||
|
importOperations.clearImportState()
|
||||||
|
ui.showImportSuccess()
|
||||||
|
} else {
|
||||||
|
ui.showDownloadSuccess()
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
if (isImport) {
|
||||||
|
val filesToCleanup = importOperations.getAndClearImportedFiles()
|
||||||
|
filesToCleanup.forEach { file ->
|
||||||
|
try {
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupNewFiles(dir, filesBeforeReindex)
|
||||||
|
}
|
||||||
|
cleanupOrphanedFiles(dir)
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ui.showError()
|
||||||
|
} finally {
|
||||||
|
finishService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun triggerReindexing(isImport: Boolean) = withContext(ioDispatcher) {
|
||||||
|
steps.waitForIndexingCompletion()
|
||||||
|
delay(1.seconds)
|
||||||
|
|
||||||
|
val path = dictionaryPath()
|
||||||
|
if (isImport) {
|
||||||
|
importOperations.updateExtractionProgress(0f)
|
||||||
|
}
|
||||||
|
steps.extractArchives(onProgress = { progress ->
|
||||||
|
if (isImport) {
|
||||||
|
importOperations.updateExtractionProgress(progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
val result = steps.scanDirectory(path)
|
||||||
|
if (result is OperationResult.Success && result.data > 0) {
|
||||||
|
steps.warmupIndexes()
|
||||||
|
steps.performAllCleanup()
|
||||||
|
}
|
||||||
|
if (isImport) {
|
||||||
|
importOperations.markImportPipelineSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupOrphanedFiles(dir: File?) {
|
||||||
|
try {
|
||||||
|
dir?.listFiles()?.forEach { file ->
|
||||||
|
val n = file.name.lowercase()
|
||||||
|
val isRawDsl = n.endsWith(".dsl") && !n.endsWith(".dsl.dz") && !n.endsWith(".dsl.gz")
|
||||||
|
val isBareDz = n.endsWith(".dz") && !n.endsWith(".dsl.dz")
|
||||||
|
val orphaned = when {
|
||||||
|
isBareDz -> true
|
||||||
|
isRawDsl -> !File(file.parent, file.name + ".dz").exists()
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (orphaned) {
|
||||||
|
try { file.delete() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupNewFiles(dir: File?, filesBeforeSnapshot: Set<String>) {
|
||||||
|
try {
|
||||||
|
dir?.listFiles()?.forEach { file ->
|
||||||
|
if (file.name !in filesBeforeSnapshot) {
|
||||||
|
try {
|
||||||
|
file.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,7 +109,7 @@ object DictionaryProgressModel {
|
|||||||
return Snapshot(
|
return Snapshot(
|
||||||
progress = p,
|
progress = p,
|
||||||
percent = (p * 100f).toInt().coerceIn(0, 100),
|
percent = (p * 100f).toInt().coerceIn(0, 100),
|
||||||
titleRes = R.string.notification_indexing_title,
|
titleRes = R.string.notification_preparing_indexing_title,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ object DictionaryProgressModel {
|
|||||||
} else {
|
} else {
|
||||||
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT
|
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT
|
||||||
}
|
}
|
||||||
val pct = (p * 100f).toInt().coerceIn(0, 100)
|
val pct = (p * 100f).toInt().coerceIn(0, 99)
|
||||||
|
|
||||||
return Snapshot(
|
return Snapshot(
|
||||||
progress = p,
|
progress = p,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.example.research.common.progress
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class DictionaryProgressPresenter(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
|
||||||
|
private val notifications: NotificationPort,
|
||||||
|
mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||||
|
) : PipelineTerminalUi {
|
||||||
|
@Volatile
|
||||||
|
private var terminalActive = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch(mainDispatcher) {
|
||||||
|
progressSnapshot.collect { snapshot ->
|
||||||
|
when {
|
||||||
|
snapshot == null -> {
|
||||||
|
if (!terminalActive && notifications.hasActiveProgressNotification()) {
|
||||||
|
notifications.cancelProgressNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!terminalActive -> notifications.showProgress(snapshot.percent, snapshot.titleRes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beginFlow() {
|
||||||
|
terminalActive = false
|
||||||
|
notifications.cancelTerminalNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showImportSuccess() {
|
||||||
|
notifications.showImportSuccess()
|
||||||
|
terminalActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showDownloadSuccess() {
|
||||||
|
notifications.showDownloadSuccess()
|
||||||
|
terminalActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showError() {
|
||||||
|
notifications.showError()
|
||||||
|
terminalActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelProgress() {
|
||||||
|
terminalActive = false
|
||||||
|
notifications.cancelProgressNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.research.common.progress
|
||||||
|
|
||||||
|
interface NotificationPort {
|
||||||
|
fun showProgress(percent: Int, titleRes: Int)
|
||||||
|
fun showImportSuccess()
|
||||||
|
fun showDownloadSuccess()
|
||||||
|
fun showError()
|
||||||
|
fun cancelProgressNotification()
|
||||||
|
fun cancelTerminalNotification()
|
||||||
|
fun hasActiveProgressNotification(): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.example.research.common.progress
|
||||||
|
|
||||||
|
import com.example.research.core.util.OperationResult
|
||||||
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
|
import com.example.research.feature.download.repository.DictionaryRepository
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
interface PipelineTerminalUi {
|
||||||
|
fun showImportSuccess()
|
||||||
|
fun showDownloadSuccess()
|
||||||
|
fun showError()
|
||||||
|
fun cancelProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportFlowOperations {
|
||||||
|
fun updateExtractionProgress(progress: Float)
|
||||||
|
fun markImportPipelineSuccess()
|
||||||
|
fun clearImportState()
|
||||||
|
fun getAndClearImportedFiles(): List<File>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReindexingSteps {
|
||||||
|
suspend fun waitForIndexingCompletion(): Boolean
|
||||||
|
suspend fun extractArchives(onProgress: (Float) -> Unit)
|
||||||
|
suspend fun scanDirectory(path: String): OperationResult<Int>
|
||||||
|
suspend fun warmupIndexes()
|
||||||
|
suspend fun performAllCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultReindexingSteps(
|
||||||
|
private val localDictionaryRepository: LocalDictionaryRepository,
|
||||||
|
private val downloadDictionaryRepository: DictionaryRepository,
|
||||||
|
) : ReindexingSteps {
|
||||||
|
|
||||||
|
override suspend fun waitForIndexingCompletion(): Boolean {
|
||||||
|
if (!localDictionaryRepository.indexingProgress.first().isIndexing) return true
|
||||||
|
var waitCount = 0
|
||||||
|
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < MAX_POLL) {
|
||||||
|
delay(POLL_INTERVAL)
|
||||||
|
waitCount++
|
||||||
|
}
|
||||||
|
return waitCount < MAX_POLL
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun extractArchives(onProgress: (Float) -> Unit) {
|
||||||
|
downloadDictionaryRepository.extractArchives(onProgress = onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun scanDirectory(path: String): OperationResult<Int> =
|
||||||
|
localDictionaryRepository.scanDirectory(path)
|
||||||
|
|
||||||
|
override suspend fun warmupIndexes() = localDictionaryRepository.warmupIndexes()
|
||||||
|
|
||||||
|
override suspend fun performAllCleanup() {
|
||||||
|
downloadDictionaryRepository.performAllCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_POLL = 100
|
||||||
|
val POLL_INTERVAL = 200.milliseconds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.research.common.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.research.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppBackButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onClick, modifier = modifier.size(48.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.action_back),
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.example.research.common.ui
|
package com.example.research.common.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
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.unit.dp
|
||||||
|
|
||||||
fun Modifier.appTestTag(tag: String): Modifier = this
|
fun Modifier.appTestTag(tag: String): Modifier = this
|
||||||
.testTag(tag)
|
.testTag(tag)
|
||||||
@@ -24,3 +28,14 @@ fun Modifier.clickableWithHaptics(
|
|||||||
onClick()
|
onClick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Modifier.iconClickable(
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit
|
||||||
|
): Modifier = this.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = ripple(bounded = false, radius = 20.dp),
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.research.common.ui.theme
|
package com.example.research.common.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.PlatformTextStyle
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -85,3 +86,8 @@ val Typography = Typography(
|
|||||||
letterSpacing = 0.5.sp
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val Typography.dictionaryTitleLarge: TextStyle
|
||||||
|
get() = titleLarge.copy(
|
||||||
|
platformStyle = PlatformTextStyle(includeFontPadding = true)
|
||||||
|
)
|
||||||
@@ -13,7 +13,7 @@ object DateUtils {
|
|||||||
return dateFormat.format(calendar.time)
|
return dateFormat.format(calendar.time)
|
||||||
}
|
}
|
||||||
fun extractDateFromFileName(fileName: String): String? {
|
fun extractDateFromFileName(fileName: String): String? {
|
||||||
val regex = Regex("""_(\d{6})\.(gz|dsl|idx|dsl\.dz|dsl\.idx|dsl\.gz)$""")
|
val regex = Regex("""_(\d{6})\.(gz|dsl|idx|idx\.fold|dsl\.dz|dsl\.idx|dsl\.idx\.fold|dsl\.gz)$""")
|
||||||
return regex.find(fileName)?.groupValues?.get(1)
|
return regex.find(fileName)?.groupValues?.get(1)
|
||||||
}
|
}
|
||||||
fun isDateBefore(date1: String, date2: String): Boolean {
|
fun isDateBefore(date1: String, date2: String): Boolean {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class FileStorageManager {
|
|||||||
private const val DELETE_WAIT_INTERVAL = 50L
|
private const val DELETE_WAIT_INTERVAL = 50L
|
||||||
private const val MAX_DELETE_WAIT_COUNT = 10
|
private const val MAX_DELETE_WAIT_COUNT = 10
|
||||||
private const val DEFAULT_MAX_RETRIES = 5
|
private const val DEFAULT_MAX_RETRIES = 5
|
||||||
|
private const val CREATE_RETRY_DELAY = 100L
|
||||||
|
private const val MAX_CREATE_RETRIES = 3
|
||||||
private const val TAG = "FileStorageManager"
|
private const val TAG = "FileStorageManager"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +89,7 @@ class FileStorageManager {
|
|||||||
Log.e(TAG, "Could not delete existing file: $fileName")
|
Log.e(TAG, "Could not delete existing file: $fileName")
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
awaitGone(targetFile::exists)
|
||||||
} else {
|
} else {
|
||||||
return@withContext Uri.fromFile(targetFile)
|
return@withContext Uri.fromFile(targetFile)
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,18 @@ class FileStorageManager {
|
|||||||
if (tempBinFile.exists()) {
|
if (tempBinFile.exists()) {
|
||||||
tempBinFile.delete()
|
tempBinFile.delete()
|
||||||
}
|
}
|
||||||
if (targetFile.createNewFile()) {
|
var created = targetFile.createNewFile()
|
||||||
|
var createAttempt = 0
|
||||||
|
while (!created && createAttempt < MAX_CREATE_RETRIES) {
|
||||||
|
delay(CREATE_RETRY_DELAY.milliseconds)
|
||||||
|
if (targetFile.exists()) {
|
||||||
|
targetFile.delete()
|
||||||
|
awaitGone(targetFile::exists)
|
||||||
|
}
|
||||||
|
created = targetFile.createNewFile()
|
||||||
|
createAttempt++
|
||||||
|
}
|
||||||
|
if (created) {
|
||||||
Uri.fromFile(targetFile)
|
Uri.fromFile(targetFile)
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "Failed to create file: $fileName")
|
Log.e(TAG, "Failed to create file: $fileName")
|
||||||
@@ -116,7 +130,7 @@ class FileStorageManager {
|
|||||||
for (attempt in 1..maxRetries) {
|
for (attempt in 1..maxRetries) {
|
||||||
try {
|
try {
|
||||||
if (file.delete()) {
|
if (file.delete()) {
|
||||||
waitForFileDeletion(file)
|
awaitGone(file::exists)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
return@withContext true
|
return@withContext true
|
||||||
}
|
}
|
||||||
@@ -141,9 +155,10 @@ class FileStorageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun waitForFileDeletion(file: DocumentFile) {
|
// FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window
|
||||||
|
private suspend fun awaitGone(exists: () -> Boolean) {
|
||||||
var waitCount = 0
|
var waitCount = 0
|
||||||
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
|
while (exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
|
||||||
delay(DELETE_WAIT_INTERVAL.milliseconds)
|
delay(DELETE_WAIT_INTERVAL.milliseconds)
|
||||||
waitCount++
|
waitCount++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import androidx.core.app.*
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.example.research.MainActivity
|
import com.example.research.MainActivity
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.progress.NotificationPort
|
||||||
import com.example.research.feature.download.receiver.DownloadCancelReceiver
|
import com.example.research.feature.download.receiver.DownloadCancelReceiver
|
||||||
class NotificationHelper(private val context: Context) {
|
class NotificationHelper(private val context: Context) : NotificationPort {
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "download_progress_channel"
|
const val CHANNEL_ID = "download_progress_channel"
|
||||||
const val NOTIFICATION_ID = 1
|
const val NOTIFICATION_ID = 1
|
||||||
|
const val TERMINAL_NOTIFICATION_ID = 2
|
||||||
}
|
}
|
||||||
private val notificationManager = NotificationManagerCompat.from(context)
|
private val notificationManager = NotificationManagerCompat.from(context)
|
||||||
private var lastNotificationKey: Int = 0
|
private var lastNotificationKey: Int = 0
|
||||||
@@ -57,13 +59,28 @@ class NotificationHelper(private val context: Context) {
|
|||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||||
manager?.createNotificationChannel(channel)
|
manager?.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
private fun canShowNotification(): Boolean {
|
private fun canShowNotification(): Boolean =
|
||||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return true
|
android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU ||
|
||||||
return ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
android.Manifest.permission.POST_NOTIFICATIONS
|
android.Manifest.permission.POST_NOTIFICATIONS
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
override fun showProgress(percent: Int, titleRes: Int) {
|
||||||
|
showUnifiedProgressNotification(
|
||||||
|
title = context.getString(titleRes),
|
||||||
|
contentText = "$percent%",
|
||||||
|
progressPercent = percent,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun showDownloadSuccess() = showSuccessNotification()
|
||||||
|
|
||||||
|
override fun showImportSuccess() = showImportSuccessNotification()
|
||||||
|
|
||||||
|
override fun showError() = showErrorNotification()
|
||||||
|
|
||||||
|
override fun cancelProgressNotification() = cancelNotification()
|
||||||
|
|
||||||
fun showSuccessNotification() {
|
fun showSuccessNotification() {
|
||||||
if (!canShowNotification()) return
|
if (!canShowNotification()) return
|
||||||
lastNotificationKey = 0
|
lastNotificationKey = 0
|
||||||
@@ -74,7 +91,7 @@ class NotificationHelper(private val context: Context) {
|
|||||||
.setContentIntent(mainActivityPendingIntent)
|
.setContentIntent(mainActivityPendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
safeNotify(notification)
|
safeNotifyTerminal(notification)
|
||||||
}
|
}
|
||||||
fun showErrorNotification() {
|
fun showErrorNotification() {
|
||||||
if (!canShowNotification()) return
|
if (!canShowNotification()) return
|
||||||
@@ -86,13 +103,19 @@ class NotificationHelper(private val context: Context) {
|
|||||||
.setContentIntent(mainActivityPendingIntent)
|
.setContentIntent(mainActivityPendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
safeNotify(notification)
|
safeNotifyTerminal(notification)
|
||||||
}
|
}
|
||||||
fun cancelNotification() {
|
fun cancelNotification() {
|
||||||
lastNotificationKey = 0
|
lastNotificationKey = 0
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun cancelTerminalNotification() {
|
||||||
|
notificationManager.cancel(TERMINAL_NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
|
||||||
|
|
||||||
fun showUnifiedProgressNotification(
|
fun showUnifiedProgressNotification(
|
||||||
title: String,
|
title: String,
|
||||||
contentText: String,
|
contentText: String,
|
||||||
@@ -142,6 +165,14 @@ class NotificationHelper(private val context: Context) {
|
|||||||
// Notification permission denied or security policy blocked
|
// Notification permission denied or security policy blocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun safeNotifyTerminal(notification: Notification) {
|
||||||
|
try {
|
||||||
|
notificationManager.notify(TERMINAL_NOTIFICATION_ID, notification)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
fun showImportSuccessNotification() {
|
fun showImportSuccessNotification() {
|
||||||
if (!canShowNotification()) return
|
if (!canShowNotification()) return
|
||||||
lastNotificationKey = 0
|
lastNotificationKey = 0
|
||||||
@@ -152,7 +183,7 @@ class NotificationHelper(private val context: Context) {
|
|||||||
.setContentIntent(mainActivityPendingIntent)
|
.setContentIntent(mainActivityPendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
safeNotify(notification)
|
safeNotifyTerminal(notification)
|
||||||
}
|
}
|
||||||
fun createForegroundNotification(): Notification {
|
fun createForegroundNotification(): Notification {
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
|||||||
@@ -102,12 +102,15 @@ data class DictionarySource(
|
|||||||
|
|
||||||
fun validateTemplate(template: String): ValidationResult {
|
fun validateTemplate(template: String): ValidationResult {
|
||||||
val trimmed = template.trim()
|
val trimmed = template.trim()
|
||||||
|
val lowerTemplate = trimmed.lowercase()
|
||||||
return when {
|
return when {
|
||||||
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
|
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
|
||||||
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
|
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
|
||||||
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
|
!trimmed.startsWith("https://") ->
|
||||||
ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
|
ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
|
||||||
!trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
|
!lowerTemplate.endsWith(".gz") &&
|
||||||
|
!lowerTemplate.endsWith(".dsl") &&
|
||||||
|
!lowerTemplate.endsWith(".dsl.dz") ->
|
||||||
ValidationResult.Error(ValidationError.INVALID_EXTENSION)
|
ValidationResult.Error(ValidationError.INVALID_EXTENSION)
|
||||||
!isValidUrlStructure(trimmed) ->
|
!isValidUrlStructure(trimmed) ->
|
||||||
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
|
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
|
||||||
@@ -132,8 +135,15 @@ data class DictionarySource(
|
|||||||
val parts = afterProtocol.split("/")
|
val parts = afterProtocol.split("/")
|
||||||
if (parts.size < 2) return false
|
if (parts.size < 2) return false
|
||||||
|
|
||||||
val domain = parts[0]
|
val hostWithPort = parts[0]
|
||||||
if (domain.isEmpty()) return false
|
if (hostWithPort.isEmpty()) return false
|
||||||
|
|
||||||
|
val colonIndex = hostWithPort.lastIndexOf(':')
|
||||||
|
if (colonIndex >= 0) {
|
||||||
|
val port = hostWithPort.substring(colonIndex + 1).toIntOrNull()
|
||||||
|
if (port == null || port !in 1..65535) return false
|
||||||
|
}
|
||||||
|
val domain = if (colonIndex >= 0) hostWithPort.substring(0, colonIndex) else hostWithPort
|
||||||
if (domain != "localhost" && !domain.contains(".")) return false
|
if (domain != "localhost" && !domain.contains(".")) return false
|
||||||
|
|
||||||
val filename = parts.last()
|
val filename = parts.last()
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ data class IndexEntry(
|
|||||||
val offset: ArticleOffset = ArticleOffset.ZERO,
|
val offset: ArticleOffset = ArticleOffset.ZERO,
|
||||||
val length: ArticleLength = ArticleLength.ZERO,
|
val length: ArticleLength = ArticleLength.ZERO,
|
||||||
val dictionaryName: String = "",
|
val dictionaryName: String = "",
|
||||||
val dictionaryPath: String = ""
|
val dictionaryPath: String = "",
|
||||||
|
val isAlias: Boolean = false
|
||||||
) : Comparable<IndexEntry> {
|
) : Comparable<IndexEntry> {
|
||||||
init {
|
init {
|
||||||
require(word.isNotBlank()) { "Word cannot be blank" }
|
require(word.isNotBlank()) { "Word cannot be blank" }
|
||||||
|
|||||||
@@ -1,42 +1,16 @@
|
|||||||
package com.example.research.core.domain.model
|
package com.example.research.core.domain.model
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot of an ongoing indexing operation.
|
* Snapshot of an ongoing indexing operation.
|
||||||
*
|
*
|
||||||
* [progress] is the authoritative [0f, 1f] completion value and is O(1) to
|
* [progress] is the authoritative [0f, 1f] completion value: the repository
|
||||||
* compute: the repository's internal `ProgressTracker` maintains a running
|
* aggregates a file-size-weighted sum across files incrementally while
|
||||||
* sum across files incrementally and stores it in [aggregateSum], avoiding
|
* indexing, so reading it is O(1).
|
||||||
* an O(n) walk over [perFileProgress] on every read.
|
|
||||||
* [perFileProgress] is kept for diagnostics; callers should prefer
|
|
||||||
* [progress] / [progressPercent].
|
|
||||||
*/
|
*/
|
||||||
|
@Immutable
|
||||||
data class IndexingProgress(
|
data class IndexingProgress(
|
||||||
val currentFile: String = "",
|
|
||||||
val currentIndex: Int = 0,
|
|
||||||
val totalFiles: Int = 0,
|
|
||||||
val isIndexing: Boolean = false,
|
val isIndexing: Boolean = false,
|
||||||
val currentFileProgress: Float = 0f,
|
val progress: Float = 0f,
|
||||||
val label: String = "",
|
)
|
||||||
val perFileProgress: Map<String, Float> = emptyMap(),
|
|
||||||
/** Pre-aggregated sum in [0f, totalFiles] supplied by the producer; -1f = unknown. */
|
|
||||||
val aggregateSum: Float = -1f,
|
|
||||||
) {
|
|
||||||
val progress: Float
|
|
||||||
get() = if (totalFiles > 0) {
|
|
||||||
when {
|
|
||||||
aggregateSum >= 0f -> (aggregateSum / totalFiles).coerceIn(0f, 1f)
|
|
||||||
perFileProgress.isNotEmpty() -> {
|
|
||||||
val totalProgress = perFileProgress.values.sum()
|
|
||||||
(totalProgress / totalFiles).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val completedFiles = (currentIndex - 1).coerceAtLeast(0)
|
|
||||||
((completedFiles + currentFileProgress) / totalFiles).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else 0f
|
|
||||||
|
|
||||||
val progressPercent: Int
|
|
||||||
get() = (progress * 100).roundToInt().coerceIn(0, 100)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.research.core.domain.model
|
||||||
|
|
||||||
|
enum class SearchBarPosition {
|
||||||
|
TOP,
|
||||||
|
BOTTOM;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromStorage(value: String?): SearchBarPosition =
|
||||||
|
entries.firstOrNull { it.name == value } ?: TOP
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.research.core.domain.usecase
|
||||||
|
|
||||||
|
import com.example.research.core.domain.model.Dictionary
|
||||||
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object DictionarySourceFileMatcher {
|
||||||
|
fun matches(source: DictionarySource, fileName: String): Boolean =
|
||||||
|
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
|
||||||
|
|
||||||
|
fun matches(source: DictionarySource, dictionary: Dictionary): Boolean =
|
||||||
|
matches(source, File(dictionary.path).name)
|
||||||
|
|
||||||
|
fun installedSources(
|
||||||
|
sources: List<DictionarySource>,
|
||||||
|
dictionaries: List<Dictionary>,
|
||||||
|
): List<DictionarySource> = sources.filter { source ->
|
||||||
|
dictionaries.any { dictionary -> matches(source, dictionary) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installedSourcesForFileNames(
|
||||||
|
sources: List<DictionarySource>,
|
||||||
|
fileNames: Collection<String>,
|
||||||
|
): List<DictionarySource> = sources.filter { source ->
|
||||||
|
fileNames.any { fileName -> matches(source, fileName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
-8
@@ -43,12 +43,4 @@ class DictionarySourceValidator {
|
|||||||
return ValidationResult.Valid
|
return ValidationResult.Valid
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findMatchingSourceWithPrecomputedPrefixes(
|
|
||||||
dictionaryPrefix: String,
|
|
||||||
sourcesWithPrefixes: List<Pair<DictionarySource, String?>>
|
|
||||||
): DictionarySource? {
|
|
||||||
return sourcesWithPrefixes.find { (_, sourcePrefix) ->
|
|
||||||
sourcePrefix != null && dictionaryPrefix.equals(sourcePrefix, ignoreCase = true)
|
|
||||||
}?.first
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+16
-10
@@ -30,17 +30,23 @@ class ManageDictionarySourcesUseCase(
|
|||||||
preferencesManager.removeDictionarySource(sourceId)
|
preferencesManager.removeDictionarySource(sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeSourceForDictionary(dictionary: Dictionary) {
|
suspend fun removeSourceForDictionary(
|
||||||
val dictionaryPrefix = dictionary.name
|
dictionary: Dictionary,
|
||||||
val sources = preferencesManager.dictionarySources.first()
|
remainingDictionaryFileNames: Collection<String>?,
|
||||||
val sourcesWithPrefixes = sources.map { source ->
|
) {
|
||||||
source to DictionarySource.extractPrefix(source.urlTemplate)
|
if (remainingDictionaryFileNames == null) return
|
||||||
}
|
|
||||||
|
|
||||||
val matchingSource = validator.findMatchingSourceWithPrecomputedPrefixes(dictionaryPrefix, sourcesWithPrefixes)
|
val sources = preferencesManager.dictionarySources.first()
|
||||||
if (matchingSource != null) {
|
val sourceIdsToRemove = sources
|
||||||
preferencesManager.removeDictionarySource(matchingSource.id)
|
.filter { source ->
|
||||||
}
|
DictionarySourceFileMatcher.matches(source, dictionary) &&
|
||||||
|
remainingDictionaryFileNames.none { fileName ->
|
||||||
|
DictionarySourceFileMatcher.matches(source, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(DictionarySource::id)
|
||||||
|
|
||||||
|
preferencesManager.removeDictionarySources(sourceIdsToRemove)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class AddSourceResult {
|
sealed class AddSourceResult {
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ fun Throwable.isNetworkError(): Boolean {
|
|||||||
if (isSslHandshakeError()) return true
|
if (isSslHandshakeError()) return true
|
||||||
|
|
||||||
return when (this) {
|
return when (this) {
|
||||||
|
is java.net.UnknownHostException,
|
||||||
|
is java.net.ConnectException,
|
||||||
|
is java.net.NoRouteToHostException,
|
||||||
|
is java.net.BindException -> true
|
||||||
is java.io.IOException -> {
|
is java.io.IOException -> {
|
||||||
val message = message ?: ""
|
val message = message ?: ""
|
||||||
message.contains("connection", ignoreCase = true) ||
|
message.contains("connection", ignoreCase = true) ||
|
||||||
@@ -49,9 +53,14 @@ fun Throwable.isNetworkError(): Boolean {
|
|||||||
message.contains("unreachable", ignoreCase = true) ||
|
message.contains("unreachable", ignoreCase = true) ||
|
||||||
message.contains("no route", ignoreCase = true) ||
|
message.contains("no route", ignoreCase = true) ||
|
||||||
message.contains("broken pipe", ignoreCase = true) ||
|
message.contains("broken pipe", ignoreCase = true) ||
|
||||||
|
message.contains("resolve host", ignoreCase = true) ||
|
||||||
|
message.contains("unexpected end of stream", ignoreCase = true) ||
|
||||||
|
message.contains("socket closed", ignoreCase = true) ||
|
||||||
message.contains("ECONNRESET", ignoreCase = true) ||
|
message.contains("ECONNRESET", ignoreCase = true) ||
|
||||||
message.contains("ECONNREFUSED", ignoreCase = true) ||
|
message.contains("ECONNREFUSED", ignoreCase = true) ||
|
||||||
message.contains("ENETUNREACH", ignoreCase = true)
|
message.contains("ECONNABORTED", ignoreCase = true) ||
|
||||||
|
message.contains("ENETUNREACH", ignoreCase = true) ||
|
||||||
|
message.contains("EHOSTUNREACH", ignoreCase = true)
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,32 @@ fun String.sanitizeQuery(): String {
|
|||||||
.take(MAX_QUERY_LENGTH)
|
.take(MAX_QUERY_LENGTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.foldLatinDiacritics(): String {
|
||||||
|
val decomposed = java.text.Normalizer.normalize(this, java.text.Normalizer.Form.NFD)
|
||||||
|
val folded = StringBuilder(decomposed.length)
|
||||||
|
var followsLatinBase = false
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
while (index < decomposed.length) {
|
||||||
|
val codePoint = decomposed.codePointAt(index)
|
||||||
|
val type = Character.getType(codePoint)
|
||||||
|
val isMark = type == Character.NON_SPACING_MARK.toInt() ||
|
||||||
|
type == Character.COMBINING_SPACING_MARK.toInt() ||
|
||||||
|
type == Character.ENCLOSING_MARK.toInt()
|
||||||
|
|
||||||
|
if (!(isMark && followsLatinBase)) {
|
||||||
|
folded.appendCodePoint(codePoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMark) {
|
||||||
|
followsLatinBase = Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.LATIN
|
||||||
|
}
|
||||||
|
index += Character.charCount(codePoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return java.text.Normalizer.normalize(folded, java.text.Normalizer.Form.NFC)
|
||||||
|
}
|
||||||
|
|
||||||
fun String.sanitizeCacheKey(): String {
|
fun String.sanitizeCacheKey(): String {
|
||||||
return this.replace(":", "_").replace("\n", "_").replace("\t", "_")
|
return this.replace(":", "_").replace("\n", "_").replace("\t", "_")
|
||||||
}
|
}
|
||||||
@@ -34,7 +60,10 @@ fun String.extractDictionaryPrefix(): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun String.removeAccentTagsForIndexing(): String {
|
fun String.removeAccentTagsForIndexing(): String =
|
||||||
|
rewriteAccentTags { builder, from, to -> builder.append(this, from, to) }
|
||||||
|
|
||||||
|
internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, Int) -> Unit): String {
|
||||||
val firstOpen = indexOf("[']")
|
val firstOpen = indexOf("[']")
|
||||||
if (firstOpen < 0) return this
|
if (firstOpen < 0) return this
|
||||||
|
|
||||||
@@ -54,7 +83,7 @@ fun String.removeAccentTagsForIndexing(): String {
|
|||||||
builder = StringBuilder(length)
|
builder = StringBuilder(length)
|
||||||
}
|
}
|
||||||
builder.append(this, appendStart, index)
|
builder.append(this, appendStart, index)
|
||||||
builder.append(this, index + 3, scan)
|
appendInner(builder, index + 3, scan)
|
||||||
index = scan + 4
|
index = scan + 4
|
||||||
appendStart = index
|
appendStart = index
|
||||||
continue
|
continue
|
||||||
@@ -68,7 +97,7 @@ fun String.removeAccentTagsForIndexing(): String {
|
|||||||
}?.toString() ?: this
|
}?.toString() ?: this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.startsWithAt(start: Int, literal: String): Boolean {
|
internal fun String.startsWithAt(start: Int, literal: String): Boolean {
|
||||||
if (start < 0 || start + literal.length > length) return false
|
if (start < 0 || start + literal.length > length) return false
|
||||||
for (i in literal.indices) {
|
for (i in literal.indices) {
|
||||||
if (this[start + i] != literal[i]) return false
|
if (this[start + i] != literal[i]) return false
|
||||||
|
|||||||
@@ -6,23 +6,44 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
|
import com.example.research.core.domain.model.SearchBarPosition
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
|
data class SectionExpansionStates(
|
||||||
|
val isThemeExpanded: Boolean = false,
|
||||||
|
val isLanguageExpanded: Boolean = false,
|
||||||
|
val isSearchBarExpanded: Boolean = false,
|
||||||
|
val isDictionariesExpanded: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
class PreferencesManager(private val context: Context) {
|
class PreferencesManager(private val context: Context) {
|
||||||
private object PreferencesKeys {
|
private object PreferencesKeys {
|
||||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||||
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
|
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
|
||||||
val LANGUAGE = stringPreferencesKey("language")
|
val LANGUAGE = stringPreferencesKey("language")
|
||||||
|
val SEARCH_BAR_POSITION = stringPreferencesKey("search_bar_position")
|
||||||
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
|
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
|
||||||
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
|
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
|
||||||
|
val IS_SEARCH_BAR_EXPANDED = booleanPreferencesKey("is_search_bar_expanded")
|
||||||
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
|
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
|
||||||
|
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
||||||
|
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
||||||
|
val DICTIONARY_STATUS = stringPreferencesKey("dictionary_status")
|
||||||
|
val SEARCH_HISTORY = stringPreferencesKey("search_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_SEARCH_HISTORY_ENTRIES = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
@@ -46,6 +67,17 @@ class PreferencesManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val searchBarPosition: Flow<SearchBarPosition> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
SearchBarPosition.fromStorage(preferences[PreferencesKeys.SEARCH_BAR_POSITION])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSearchBarPosition(position: SearchBarPosition) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data
|
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data
|
||||||
.map { preferences ->
|
.map { preferences ->
|
||||||
val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES]
|
val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES]
|
||||||
@@ -60,6 +92,63 @@ class PreferencesManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val disabledDictionaryPaths: Flow<Set<String>> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hadNoDictionaries: Flow<Boolean> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[PreferencesKeys.HAD_NO_DICTIONARIES] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setHadNoDictionaries(value: Boolean) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[PreferencesKeys.HAD_NO_DICTIONARIES] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dictionaryStatus(): String? =
|
||||||
|
context.dataStore.data.first()[PreferencesKeys.DICTIONARY_STATUS]
|
||||||
|
|
||||||
|
suspend fun saveDictionaryStatus(status: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[PreferencesKeys.DICTIONARY_STATUS] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sectionExpansionStates(): SectionExpansionStates =
|
||||||
|
context.dataStore.data.first().let { preferences ->
|
||||||
|
SectionExpansionStates(
|
||||||
|
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
|
||||||
|
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
|
||||||
|
isSearchBarExpanded = preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false,
|
||||||
|
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDictionaryActive(path: String, isActive: Boolean) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
|
||||||
|
.orEmpty()
|
||||||
|
.toMutableSet()
|
||||||
|
if (isActive) {
|
||||||
|
disabledPaths.remove(path)
|
||||||
|
} else {
|
||||||
|
disabledPaths.add(path)
|
||||||
|
}
|
||||||
|
if (disabledPaths.isEmpty()) {
|
||||||
|
preferences.remove(PreferencesKeys.DISABLED_DICTIONARY_PATHS)
|
||||||
|
} else {
|
||||||
|
preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] = disabledPaths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeDictionaryActiveState(path: String) {
|
||||||
|
setDictionaryActive(path, true)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun saveThemeMode(theme: String) {
|
suspend fun saveThemeMode(theme: String) {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
preferences[PreferencesKeys.THEME_MODE] = theme
|
preferences[PreferencesKeys.THEME_MODE] = theme
|
||||||
@@ -76,6 +165,11 @@ class PreferencesManager(private val context: Context) {
|
|||||||
preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false
|
preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isSearchBarExpanded: Flow<Boolean> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
val isDictionariesExpanded: Flow<Boolean> = context.dataStore.data
|
val isDictionariesExpanded: Flow<Boolean> = context.dataStore.data
|
||||||
.map { preferences ->
|
.map { preferences ->
|
||||||
preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true
|
preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true
|
||||||
@@ -86,6 +180,7 @@ class PreferencesManager(private val context: Context) {
|
|||||||
when (key) {
|
when (key) {
|
||||||
"theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded
|
"theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded
|
||||||
"language" -> preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] = isExpanded
|
"language" -> preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] = isExpanded
|
||||||
|
"search_bar" -> preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] = isExpanded
|
||||||
"dictionaries" -> preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] = isExpanded
|
"dictionaries" -> preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] = isExpanded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +227,51 @@ class PreferencesManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val searchHistory: Flow<List<String>> = context.dataStore.data
|
||||||
|
.map { preferences -> preferences[PreferencesKeys.SEARCH_HISTORY] }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.map(::decodeSearchHistory)
|
||||||
|
|
||||||
|
suspend fun addSearchHistoryEntry(query: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
|
||||||
|
.toMutableList()
|
||||||
|
current.removeAll { it.equals(query, ignoreCase = true) }
|
||||||
|
current.add(0, query)
|
||||||
|
preferences[PreferencesKeys.SEARCH_HISTORY] =
|
||||||
|
json.encodeToString(current.take(MAX_SEARCH_HISTORY_ENTRIES))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeSearchHistoryEntry(query: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
|
||||||
|
.toMutableList()
|
||||||
|
if (current.removeAll { it == query }) {
|
||||||
|
if (current.isEmpty()) {
|
||||||
|
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
|
||||||
|
} else {
|
||||||
|
preferences[PreferencesKeys.SEARCH_HISTORY] = json.encodeToString(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearSearchHistory() {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeSearchHistory(value: String?): List<String> {
|
||||||
|
if (value == null) return emptyList()
|
||||||
|
return try {
|
||||||
|
json.decodeFromString<List<String>>(value)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun sanitizeDictionarySources() {
|
suspend fun sanitizeDictionarySources() {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
val currentSources = decodeSources(
|
val currentSources = decodeSources(
|
||||||
|
|||||||
@@ -15,85 +15,180 @@ 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>,
|
private val dictionaries: List<Dictionary>,
|
||||||
private val query: String
|
private val query: String
|
||||||
) : PagingSource<Int, IndexEntry>() {
|
) : PagingSource<IndexEntryPagingSource.TailKey, IndexEntry>() {
|
||||||
|
|
||||||
private var searchResults: List<IndexEntry>? = null
|
data class StreamCursor(
|
||||||
|
val dictPosition: Int,
|
||||||
|
val cursor: IndexSearcher.RangeCursor
|
||||||
|
)
|
||||||
|
|
||||||
override val jumpingSupported: Boolean = true
|
data class TailKey(val streams: List<StreamCursor>)
|
||||||
|
|
||||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IndexEntry> {
|
private data class EmittedKey(
|
||||||
val offset = params.key ?: 0
|
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 allResults = searchResults ?: ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) {
|
val key = params.key
|
||||||
coroutineScope {
|
if (key == null) loadHead() else loadTail(key, params.loadSize)
|
||||||
val rawResults = dictionaries
|
|
||||||
.filter { it.isActive }
|
|
||||||
.map { dict ->
|
|
||||||
async {
|
|
||||||
val indexFile = File(dict.indexPath)
|
|
||||||
if (!indexFile.exists()) return@async emptyList()
|
|
||||||
|
|
||||||
try {
|
|
||||||
val results = indexSearcher.search(
|
|
||||||
pathOrUri = indexFile.absolutePath,
|
|
||||||
query = query,
|
|
||||||
includeSubstringMatches = false
|
|
||||||
)
|
|
||||||
results.map {
|
|
||||||
it.withDictionary(
|
|
||||||
dict.metadata.name.ifBlank { dict.name },
|
|
||||||
dict.path
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.awaitAll()
|
|
||||||
.flatten()
|
|
||||||
|
|
||||||
// Precompute sorting keys to optimize comparator performance.
|
|
||||||
// entry.word is already lowercase from indexing.
|
|
||||||
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
|
||||||
|
|
||||||
rawResults.rankBySearchRelevance(
|
|
||||||
SearchRankingContext(normalizedQuery)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.also { searchResults = it }
|
|
||||||
|
|
||||||
val totalSize = allResults.size
|
|
||||||
val startIndex = offset.coerceAtLeast(0).coerceAtMost(totalSize)
|
|
||||||
val endIndex = (startIndex + params.loadSize).coerceAtMost(totalSize)
|
|
||||||
val pageItems = allResults.subList(startIndex, endIndex)
|
|
||||||
val hasMore = endIndex < totalSize
|
|
||||||
val nextKey = if (hasMore) endIndex else null
|
|
||||||
val prevKey = if (offset == 0) null else (offset - params.loadSize).coerceAtLeast(0)
|
|
||||||
|
|
||||||
LoadResult.Page(
|
|
||||||
data = pageItems,
|
|
||||||
prevKey = prevKey,
|
|
||||||
nextKey = nextKey
|
|
||||||
)
|
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LoadResult.Error(e)
|
LoadResult.Error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getRefreshKey(state: PagingState<Int, IndexEntry>): Int? {
|
private suspend fun loadHead(): LoadResult<TailKey, IndexEntry> =
|
||||||
return state.anchorPosition?.let { anchorPosition ->
|
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) {
|
||||||
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(state.config.pageSize)
|
coroutineScope {
|
||||||
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(state.config.pageSize)
|
val perDictionary = activeDictionaries
|
||||||
|
.map { (position, dict) ->
|
||||||
|
async {
|
||||||
|
val indexFile = File(dict.indexPath)
|
||||||
|
if (!indexFile.exists()) return@async null
|
||||||
|
|
||||||
|
try {
|
||||||
|
val result = indexSearcher.searchWithCursors(
|
||||||
|
pathOrUri = indexFile.absolutePath,
|
||||||
|
query = query
|
||||||
|
)
|
||||||
|
Triple(position, dict, result)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
.filterNotNull()
|
||||||
|
|
||||||
|
val rawResults = perDictionary.flatMap { (position, dict, result) ->
|
||||||
|
result.entries.map { entry ->
|
||||||
|
position to entry.withDictionary(
|
||||||
|
dict.metadata.name.ifBlank { dict.name },
|
||||||
|
dict.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
||||||
|
val ranked = rawResults.map { it.second }
|
||||||
|
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
|
||||||
|
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
|
||||||
|
|
||||||
|
rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) }
|
||||||
|
|
||||||
|
val streams = perDictionary.flatMap { (position, _, result) ->
|
||||||
|
result.cursors.map { StreamCursor(position, it) }
|
||||||
|
}
|
||||||
|
LoadResult.Page(
|
||||||
|
data = ranked,
|
||||||
|
prevKey = null,
|
||||||
|
nextKey = if (streams.isEmpty()) null else TailKey(streams)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadTail(key: TailKey, loadSize: Int): LoadResult<TailKey, IndexEntry> {
|
||||||
|
val streams = key.streams.map { TailStream(it.dictPosition, it.cursor) }
|
||||||
|
val page = ArrayList<IndexEntry>(loadSize)
|
||||||
|
val pageKeys = HashSet<EmittedKey>()
|
||||||
|
val chunkSize = maxOf(loadSize, MIN_CHUNK)
|
||||||
|
|
||||||
|
while (page.size < loadSize) {
|
||||||
|
streams.forEach { it.ensureBuffered(chunkSize) }
|
||||||
|
val next = streams
|
||||||
|
.filter { it.hasBuffered }
|
||||||
|
.minByOrNull { it.peekWord }
|
||||||
|
?: break
|
||||||
|
|
||||||
|
val scanned = next.consume()
|
||||||
|
val dict = dictionaries[next.dictPosition]
|
||||||
|
val entry = scanned.entry.withDictionary(
|
||||||
|
dict.metadata.name.ifBlank { dict.name },
|
||||||
|
dict.path
|
||||||
|
)
|
||||||
|
val emittedKey = entry.emittedKey(next.dictPosition)
|
||||||
|
if (emittedKey !in emittedArticles && pageKeys.add(emittedKey)) {
|
||||||
|
page.add(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emittedArticles.addAll(pageKeys)
|
||||||
|
|
||||||
|
val remaining = streams.mapNotNull { stream ->
|
||||||
|
stream.nextCursor?.let { StreamCursor(stream.dictPosition, it) }
|
||||||
|
}
|
||||||
|
return LoadResult.Page(
|
||||||
|
data = page,
|
||||||
|
prevKey = null,
|
||||||
|
nextKey = if (remaining.isEmpty()) null else TailKey(remaining)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class TailStream(
|
||||||
|
val dictPosition: Int,
|
||||||
|
initialCursor: IndexSearcher.RangeCursor
|
||||||
|
) {
|
||||||
|
var nextCursor: IndexSearcher.RangeCursor? = initialCursor
|
||||||
|
private set
|
||||||
|
private var buffer: List<IndexSearcher.ScannedEntry> = emptyList()
|
||||||
|
private var position = 0
|
||||||
|
|
||||||
|
val hasBuffered: Boolean get() = position < buffer.size
|
||||||
|
val peekWord: String get() = buffer[position].entry.word
|
||||||
|
|
||||||
|
suspend fun ensureBuffered(chunkSize: Int) {
|
||||||
|
if (hasBuffered) return
|
||||||
|
val cursor = nextCursor ?: return
|
||||||
|
val indexPath = File(dictionaries[dictPosition].indexPath).absolutePath
|
||||||
|
buffer = indexSearcher.scanTailChunk(indexPath, cursor, chunkSize)
|
||||||
|
position = 0
|
||||||
|
if (buffer.isEmpty()) nextCursor = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(): IndexSearcher.ScannedEntry {
|
||||||
|
val scanned = buffer[position]
|
||||||
|
position++
|
||||||
|
nextCursor = scanned.cursorAfter
|
||||||
|
return scanned
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun IndexEntry.emittedKey(dictPosition: Int) =
|
||||||
|
EmittedKey(dictPosition, word, offset.value)
|
||||||
|
|
||||||
|
override fun getRefreshKey(state: PagingState<TailKey, IndexEntry>): TailKey? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val MIN_CHUNK = 48
|
||||||
@@ -180,14 +180,19 @@ internal fun parseDslArticleContent(rawContent: String): String {
|
|||||||
|
|
||||||
while (i < len) {
|
while (i < len) {
|
||||||
val lineStart = i
|
val lineStart = i
|
||||||
while (i < len && rawContent[i] != '\n') i++
|
while (i < len && rawContent[i] != '\n' && rawContent[i] != '\r') i++
|
||||||
val lineEnd = i
|
val lineEnd = i
|
||||||
if (i < len && rawContent[i] == '\n') i++
|
if (i < len && rawContent[i] == '\r') {
|
||||||
|
i++
|
||||||
|
if (i < len && rawContent[i] == '\n') i++
|
||||||
|
} else if (i < len && rawContent[i] == '\n') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
if (lineStart == lineEnd && i >= len) break
|
if (lineStart == lineEnd && i >= len) break
|
||||||
|
|
||||||
val line = rawContent.substring(lineStart, lineEnd)
|
val line = rawContent.substring(lineStart, lineEnd)
|
||||||
val trimmed = line.trimEnd()
|
val trimmed = line.trim()
|
||||||
if (trimmed.isEmpty()) continue
|
if (trimmed.isEmpty()) continue
|
||||||
|
|
||||||
if (skippingHeadwords) {
|
if (skippingHeadwords) {
|
||||||
|
|||||||
@@ -58,17 +58,23 @@ object DslCharsetDetector {
|
|||||||
private fun looksLikeUtf8(inputStream: java.io.InputStream): Boolean {
|
private fun looksLikeUtf8(inputStream: java.io.InputStream): Boolean {
|
||||||
if (!inputStream.markSupported()) return false
|
if (!inputStream.markSupported()) return false
|
||||||
inputStream.mark(8192)
|
inputStream.mark(8192)
|
||||||
return try {
|
try {
|
||||||
val sample = ByteArray(8192)
|
val sample = ByteArray(8192)
|
||||||
val read = inputStream.read(sample)
|
val read = inputStream.read(sample)
|
||||||
if (read <= 0) return false
|
if (read <= 0) return false
|
||||||
val decoder = Charsets.UTF_8.newDecoder()
|
val decoder = Charsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
|
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
|
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
|
||||||
decoder.decode(java.nio.ByteBuffer.wrap(sample, 0, read))
|
val byteBuffer = java.nio.ByteBuffer.wrap(sample, 0, read)
|
||||||
true
|
val charBuffer = java.nio.CharBuffer.allocate(8192)
|
||||||
|
while (true) {
|
||||||
|
val result = decoder.decode(byteBuffer, charBuffer, false)
|
||||||
|
if (result.isError) return false
|
||||||
|
if (result.isUnderflow) return true
|
||||||
|
charBuffer.clear()
|
||||||
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
inputStream.reset()
|
inputStream.reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.example.research.data.parser
|
package com.example.research.data.parser
|
||||||
|
|
||||||
|
import com.example.research.core.util.rewriteAccentTags
|
||||||
|
import com.example.research.core.util.startsWithAt
|
||||||
|
|
||||||
object DslHeadwordParser {
|
object DslHeadwordParser {
|
||||||
|
|
||||||
private val FORMATTING_LITERAL_TAGS = arrayOf(
|
private val FORMATTING_LITERAL_TAGS = arrayOf(
|
||||||
@@ -24,10 +27,11 @@ object DslHeadwordParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
|
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
|
||||||
|
val unescaped = unescape(trimmed)
|
||||||
return ParsedHeadword(
|
return ParsedHeadword(
|
||||||
simplified = trimmed,
|
simplified = unescaped,
|
||||||
displayText = trimmed,
|
displayText = unescaped,
|
||||||
searchableText = trimmed.lowercase()
|
searchableText = unescaped.lowercase()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +64,12 @@ object DslHeadwordParser {
|
|||||||
val searchableText = createSearchableText(trimmed)
|
val searchableText = createSearchableText(trimmed)
|
||||||
|
|
||||||
return ParsedHeadword(
|
return ParsedHeadword(
|
||||||
simplified = simplified,
|
simplified = unescape(simplified),
|
||||||
displayText = displayText,
|
displayText = unescape(displayText),
|
||||||
searchableText = searchableText
|
searchableText = unescape(searchableText)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDisplayText(
|
private fun buildDisplayText(
|
||||||
reconstructedPrefix: String,
|
reconstructedPrefix: String,
|
||||||
simplified: String,
|
simplified: String,
|
||||||
@@ -96,7 +100,7 @@ object DslHeadwordParser {
|
|||||||
.orEmpty()
|
.orEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSearchableText(originalHeadword: String): String {
|
private fun createSearchableText(originalHeadword: String): String {
|
||||||
return removeFormattingTags(
|
return removeFormattingTags(
|
||||||
removeCurlyBraceMatches(originalHeadword, removeEmpty = true)
|
removeCurlyBraceMatches(originalHeadword, removeEmpty = true)
|
||||||
@@ -116,12 +120,12 @@ object DslHeadwordParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun reconstructDisplayPrefix(value: String): String {
|
private fun reconstructDisplayPrefix(value: String): String {
|
||||||
val reconstructed = removeCurlyBraceDelimiters(value)
|
return removeFormattingTags(removeCurlyBraceDelimiters(value).applyAccentMarks()).trim()
|
||||||
val formattingTagIndex = firstFormattingTagIndex(reconstructed)
|
}
|
||||||
return if (formattingTagIndex >= 0) {
|
|
||||||
reconstructed.substring(0, formattingTagIndex).trim()
|
private fun String.applyAccentMarks(): String = rewriteAccentTags { builder, from, to ->
|
||||||
} else {
|
if (to > from) {
|
||||||
reconstructed.trim()
|
builder.append(this[from]).append('\u0301').append(this, from + 1, to)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,28 +133,30 @@ object DslHeadwordParser {
|
|||||||
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
|
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
|
||||||
|
|
||||||
return buildString(value.length) {
|
return buildString(value.length) {
|
||||||
value.forEach { char ->
|
var index = 0
|
||||||
if (char != '{' && char != '}') append(char)
|
while (index < value.length) {
|
||||||
|
val char = value[index]
|
||||||
|
if (char == '\\' && index + 1 < value.length) {
|
||||||
|
append(char)
|
||||||
|
append(value[index + 1])
|
||||||
|
index += 2
|
||||||
|
} else {
|
||||||
|
if (char != '{' && char != '}') append(char)
|
||||||
|
index++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun firstFormattingTagIndex(value: String): Int {
|
|
||||||
var index = 0
|
|
||||||
while (index < value.length) {
|
|
||||||
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun firstCurlyContent(value: String): String? {
|
private fun firstCurlyContent(value: String): String? {
|
||||||
var index = 0
|
var index = 0
|
||||||
while (index < value.length) {
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (value[index] == '{') {
|
if (value[index] == '{') {
|
||||||
val end = value.indexOf('}', startIndex = index + 1)
|
val end = matchingCurlyEnd(value, index + 1)
|
||||||
if (end > index + 1) {
|
if (end > index + 1) {
|
||||||
return value.substring(index + 1, end)
|
return value.substring(index + 1, end)
|
||||||
}
|
}
|
||||||
@@ -161,7 +167,7 @@ object DslHeadwordParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun substringBeforeFirstBracket(value: String): String {
|
private fun substringBeforeFirstBracket(value: String): String {
|
||||||
val bracketIndex = value.indexOf('[')
|
val bracketIndex = indexOfUnescaped(value, '[')
|
||||||
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
|
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +177,12 @@ object DslHeadwordParser {
|
|||||||
var index = 0
|
var index = 0
|
||||||
|
|
||||||
while (index < value.length) {
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (value[index] == '{') {
|
if (value[index] == '{') {
|
||||||
val end = value.indexOf('}', startIndex = index + 1)
|
val end = matchingCurlyEnd(value, index + 1)
|
||||||
if (end >= 0 && (removeEmpty || end > index + 1)) {
|
if (end >= 0 && (removeEmpty || end > index + 1)) {
|
||||||
if (builder == null) {
|
if (builder == null) {
|
||||||
builder = StringBuilder(value.length)
|
builder = StringBuilder(value.length)
|
||||||
@@ -197,6 +207,10 @@ object DslHeadwordParser {
|
|||||||
var index = 0
|
var index = 0
|
||||||
|
|
||||||
while (index < value.length) {
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (value[index] == '[') {
|
if (value[index] == '[') {
|
||||||
val tagEnd = formattingTagEnd(value, index)
|
val tagEnd = formattingTagEnd(value, index)
|
||||||
if (tagEnd > index) {
|
if (tagEnd > index) {
|
||||||
@@ -217,6 +231,56 @@ object DslHeadwordParser {
|
|||||||
}?.toString() ?: value
|
}?.toString() ?: value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun matchingCurlyEnd(value: String, startIndex: Int): Int {
|
||||||
|
var index = startIndex
|
||||||
|
while (index < value.length) {
|
||||||
|
when {
|
||||||
|
value[index] == '\\' && index + 1 < value.length -> index += 2
|
||||||
|
value[index] == '{' -> return -1
|
||||||
|
value[index] == '}' -> return index
|
||||||
|
else -> index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun indexOfUnescaped(
|
||||||
|
value: String,
|
||||||
|
target: Char,
|
||||||
|
startIndex: Int = 0,
|
||||||
|
): Int {
|
||||||
|
var index = startIndex.coerceAtLeast(0)
|
||||||
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
} else if (value[index] == target) {
|
||||||
|
return index
|
||||||
|
} else {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unescape(value: String): String {
|
||||||
|
val firstEscape = value.indexOf('\\')
|
||||||
|
if (firstEscape < 0) return value
|
||||||
|
|
||||||
|
return buildString(value.length) {
|
||||||
|
append(value, 0, firstEscape)
|
||||||
|
var index = firstEscape
|
||||||
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
append(value[index + 1])
|
||||||
|
index += 2
|
||||||
|
} else {
|
||||||
|
append(value[index])
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
|
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
|
||||||
val tokenStart = openBracketIndex + 1
|
val tokenStart = openBracketIndex + 1
|
||||||
if (tokenStart >= value.length) return -1
|
if (tokenStart >= value.length) return -1
|
||||||
@@ -270,14 +334,6 @@ object DslHeadwordParser {
|
|||||||
return if (index < value.length && value[index] == ']') index + 1 else -1
|
return if (index < value.length && value[index] == ']') index + 1 else -1
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.startsWithAt(start: Int, literal: String): Boolean {
|
|
||||||
if (start < 0 || start + literal.length > length) return false
|
|
||||||
for (i in literal.indices) {
|
|
||||||
if (this[start + i] != literal[i]) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Char.isTagWhitespace(): Boolean {
|
private fun Char.isTagWhitespace(): Boolean {
|
||||||
return this == ' ' ||
|
return this == ' ' ||
|
||||||
this == '\t' ||
|
this == '\t' ||
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.util.Log
|
|||||||
import com.example.research.core.domain.model.ArticleLength
|
import com.example.research.core.domain.model.ArticleLength
|
||||||
import com.example.research.core.domain.model.ArticleOffset
|
import com.example.research.core.domain.model.ArticleOffset
|
||||||
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.removeAccentTagsForIndexing
|
import com.example.research.core.util.removeAccentTagsForIndexing
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -23,7 +24,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DslIndexer"
|
private const val TAG = "DslIndexer"
|
||||||
const val INDEX_VERSION = 4
|
const val INDEX_VERSION = 7
|
||||||
const val BUFFER_SIZE = 1048576
|
const val BUFFER_SIZE = 1048576
|
||||||
const val BATCH_BUFFER_SIZE = 524288
|
const val BATCH_BUFFER_SIZE = 524288
|
||||||
private const val INDEX_SORT_PARALLELISM = 1
|
private const val INDEX_SORT_PARALLELISM = 1
|
||||||
@@ -40,6 +41,9 @@ class DslIndexer(context: Context? = null) {
|
|||||||
private const val IN_MEMORY_SORT_MAX_ENTRIES = 140_000
|
private const val IN_MEMORY_SORT_MAX_ENTRIES = 140_000
|
||||||
private const val ESTIMATED_IN_MEMORY_ENTRY_BYTES = 768L
|
private const val ESTIMATED_IN_MEMORY_ENTRY_BYTES = 768L
|
||||||
private const val IN_MEMORY_SORT_HEAP_RESERVE_BYTES = 96L * 1024L * 1024L
|
private const val IN_MEMORY_SORT_HEAP_RESERVE_BYTES = 96L * 1024L * 1024L
|
||||||
|
|
||||||
|
fun foldedIndexFile(indexFile: File): File =
|
||||||
|
File(indexFile.parent ?: ".", "${indexFile.name}.fold")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createIndex(
|
suspend fun createIndex(
|
||||||
@@ -56,21 +60,25 @@ class DslIndexer(context: Context? = null) {
|
|||||||
java.io.BufferedInputStream(dslInputStream)
|
java.io.BufferedInputStream(dslInputStream)
|
||||||
}
|
}
|
||||||
val tempFile = File(indexFile.parent, "${indexFile.name}.tmp")
|
val tempFile = File(indexFile.parent, "${indexFile.name}.tmp")
|
||||||
val entryCount: Int
|
val counts: ParsedCounts
|
||||||
try {
|
try {
|
||||||
|
try { foldedIndexFile(indexFile).delete() } catch (_: Throwable) { /* Legacy v5 leftover */ }
|
||||||
onOperationChange("Parsing file")
|
onOperationChange("Parsing file")
|
||||||
onFileProgress(0.0f)
|
onFileProgress(0.0f)
|
||||||
entryCount = parseDslToTempFile(bufferedStream, tempFile,
|
counts = parseDslToTempFile(bufferedStream, tempFile,
|
||||||
onProgress, onFileProgress, onCharsetDetected)
|
onProgress, onFileProgress, onCharsetDetected)
|
||||||
onFileProgress(0.5f)
|
onFileProgress(0.5f)
|
||||||
onOperationChange("Sorting index")
|
onOperationChange("Sorting index")
|
||||||
sortAndWriteIndexFile(tempFile, indexFile, entryCount, onOperationChange, onFileProgress)
|
sortAndWriteIndexFile(tempFile, indexFile, counts, onOperationChange, onFileProgress)
|
||||||
|
onFileProgress(1f)
|
||||||
} finally {
|
} finally {
|
||||||
try { tempFile.delete() } catch (_: Throwable) { /* Ignore cleanup failure */ }
|
try { tempFile.delete() } catch (_: Throwable) { /* Ignore cleanup failure */ }
|
||||||
}
|
}
|
||||||
try { onProgress(entryCount) } catch (_: Throwable) { /* Ignore progress callback failure */ }
|
try { onProgress(counts.articleCount) } catch (_: Throwable) { /* Ignore progress callback failure */ }
|
||||||
return entryCount
|
return counts.articleCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class ParsedCounts(val articleCount: Int, val totalEntries: Int)
|
||||||
// Blocking file I/O is safe across this file: every suspend fun here is reached only
|
// Blocking file I/O is safe across this file: every suspend fun here is reached only
|
||||||
// from createIndex(), which the sole caller (KotlinDictionaryEngine) runs inside
|
// from createIndex(), which the sole caller (KotlinDictionaryEngine) runs inside
|
||||||
// withContext(Dispatchers.IO). The dispatcher is invisible across the suspend-call
|
// withContext(Dispatchers.IO). The dispatcher is invisible across the suspend-call
|
||||||
@@ -83,7 +91,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
onProgress: (Int) -> Unit,
|
onProgress: (Int) -> Unit,
|
||||||
onFileProgress: (Float) -> Unit = {},
|
onFileProgress: (Float) -> Unit = {},
|
||||||
onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {}
|
onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {}
|
||||||
): Int {
|
): ParsedCounts {
|
||||||
val detectedCharset = DslCharsetDetector.detect(inputStream)
|
val detectedCharset = DslCharsetDetector.detect(inputStream)
|
||||||
onCharsetDetected(detectedCharset)
|
onCharsetDetected(detectedCharset)
|
||||||
|
|
||||||
@@ -98,6 +106,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
var entryCount = 0
|
var entryCount = 0
|
||||||
|
var aliasCount = 0
|
||||||
var lineCount = 0
|
var lineCount = 0
|
||||||
var headwordLineCount = 0
|
var headwordLineCount = 0
|
||||||
var bodyLineCount = 0
|
var bodyLineCount = 0
|
||||||
@@ -127,9 +136,10 @@ class DslIndexer(context: Context? = null) {
|
|||||||
fun normalizeSearchableWord(searchableText: String, fallback: String): String {
|
fun normalizeSearchableWord(searchableText: String, fallback: String): String {
|
||||||
// Parser already lowercases and strips accent tags for non-empty results;
|
// Parser already lowercases and strips accent tags for non-empty results;
|
||||||
// only the raw-headword fallback still needs normalization.
|
// only the raw-headword fallback still needs normalization.
|
||||||
return searchableText.ifEmpty {
|
val normalized = searchableText.ifEmpty {
|
||||||
fallback.removeAccentTagsForIndexing().lowercase()
|
fallback.removeAccentTagsForIndexing().lowercase()
|
||||||
}
|
}
|
||||||
|
return java.text.Normalizer.normalize(normalized, java.text.Normalizer.Form.NFC)
|
||||||
}
|
}
|
||||||
|
|
||||||
DataOutputStream(BufferedOutputStream(FileOutputStream(tempFile), BUFFER_SIZE)).use { output ->
|
DataOutputStream(BufferedOutputStream(FileOutputStream(tempFile), BUFFER_SIZE)).use { output ->
|
||||||
@@ -144,12 +154,23 @@ class DslIndexer(context: Context? = null) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val length = (groupEndOffset - groupStartOffset).coerceAtLeast(0L).toInt()
|
val length = (groupEndOffset - groupStartOffset).coerceAtLeast(0L).toInt()
|
||||||
for (idx in groupSearchable.indices) {
|
fun writeTempEntry(word: String, display: String, isAlias: Boolean) {
|
||||||
writer.write(groupSearchable[idx])
|
writer.write(word)
|
||||||
writer.write(groupDisplay[idx])
|
writer.write(display)
|
||||||
output.writeLong(groupStartOffset)
|
output.writeLong(groupStartOffset)
|
||||||
output.writeInt(length)
|
output.writeInt(length)
|
||||||
|
output.writeByte(if (isAlias) ENTRY_FLAG_ALIAS else 0)
|
||||||
|
}
|
||||||
|
for (idx in groupSearchable.indices) {
|
||||||
|
val searchableWord = groupSearchable[idx]
|
||||||
|
val displayWord = groupDisplay[idx]
|
||||||
|
writeTempEntry(searchableWord, displayWord, isAlias = false)
|
||||||
entryCount++
|
entryCount++
|
||||||
|
val foldedWord = searchableWord.foldLatinDiacritics()
|
||||||
|
if (foldedWord != searchableWord) {
|
||||||
|
writeTempEntry(foldedWord, displayWord, isAlias = true)
|
||||||
|
aliasCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
groupSearchable.clear()
|
groupSearchable.clear()
|
||||||
groupDisplay.clear()
|
groupDisplay.clear()
|
||||||
@@ -230,26 +251,28 @@ class DslIndexer(context: Context? = null) {
|
|||||||
try { onProgress(entryCount) } catch (_: Throwable) { /* Ignore progress callback failure */ }
|
try { onProgress(entryCount) } catch (_: Throwable) { /* Ignore progress callback failure */ }
|
||||||
}
|
}
|
||||||
onFileProgress(0.5f)
|
onFileProgress(0.5f)
|
||||||
return entryCount
|
return ParsedCounts(articleCount = entryCount, totalEntries = entryCount + aliasCount)
|
||||||
}
|
}
|
||||||
// Blocking file I/O safe: reached only from createIndex() (runs on Dispatchers.IO). See parseDslToTempFile().
|
// Blocking file I/O safe: reached only from createIndex() (runs on Dispatchers.IO). See parseDslToTempFile().
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun sortAndWriteIndexFile(tempFile: File, indexFile: File, entryCount: Int, onOperationChange: (String) -> Unit = {}, onFileProgress: (Float) -> Unit = {}) {
|
private suspend fun sortAndWriteIndexFile(tempFile: File, indexFile: File, counts: ParsedCounts, onOperationChange: (String) -> Unit = {}, onFileProgress: (Float) -> Unit = {}) {
|
||||||
if (entryCount == 0) {
|
if (counts.totalEntries == 0) {
|
||||||
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile))).use { output ->
|
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile))).use { output ->
|
||||||
output.writeInt(INDEX_VERSION)
|
output.writeInt(INDEX_VERSION)
|
||||||
output.writeInt(0)
|
output.writeInt(0)
|
||||||
output.writeInt(0)
|
output.writeInt(0)
|
||||||
|
output.writeInt(0)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (shouldUseInMemorySort(entryCount)) {
|
if (shouldUseInMemorySort(counts.totalEntries)) {
|
||||||
inMemorySortAndWrite(tempFile, indexFile, entryCount)
|
inMemorySortAndWrite(tempFile, indexFile, counts)
|
||||||
onFileProgress(FINAL_INDEX_DONE_PROGRESS)
|
onFileProgress(FINAL_INDEX_DONE_PROGRESS)
|
||||||
} else {
|
} else {
|
||||||
externalSortAndWrite(tempFile, indexFile, entryCount, onOperationChange, onFileProgress)
|
externalSortAndWrite(tempFile, indexFile, counts, onOperationChange, onFileProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shouldUseInMemorySort(entryCount: Int): Boolean {
|
private fun shouldUseInMemorySort(entryCount: Int): Boolean {
|
||||||
if (entryCount <= IN_MEMORY_SORT_ALWAYS_ENTRIES) return true
|
if (entryCount <= IN_MEMORY_SORT_ALWAYS_ENTRIES) return true
|
||||||
if (entryCount > IN_MEMORY_SORT_MAX_ENTRIES) return false
|
if (entryCount > IN_MEMORY_SORT_MAX_ENTRIES) return false
|
||||||
@@ -264,30 +287,22 @@ class DslIndexer(context: Context? = null) {
|
|||||||
}
|
}
|
||||||
// Blocking file I/O safe: reached only from createIndex() (runs on Dispatchers.IO). See parseDslToTempFile().
|
// Blocking file I/O safe: reached only from createIndex() (runs on Dispatchers.IO). See parseDslToTempFile().
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun inMemorySortAndWrite(tempFile: File, indexFile: File, entryCount: Int) {
|
private suspend fun inMemorySortAndWrite(tempFile: File, indexFile: File, counts: ParsedCounts) {
|
||||||
val entries = ArrayList<IndexEntry>(entryCount)
|
val entries = ArrayList<IndexEntry>(counts.totalEntries)
|
||||||
java.io.DataInputStream(java.io.BufferedInputStream(java.io.FileInputStream(tempFile), BATCH_BUFFER_SIZE)).use { input ->
|
java.io.DataInputStream(java.io.BufferedInputStream(java.io.FileInputStream(tempFile), BATCH_BUFFER_SIZE)).use { input ->
|
||||||
repeat(entryCount) {
|
repeat(counts.totalEntries) {
|
||||||
val word = input.readUtf8String()
|
entries.add(input.readTempEntry())
|
||||||
val originalWord = input.readUtf8String()
|
|
||||||
val offset = input.readLong()
|
|
||||||
val length = input.readInt()
|
|
||||||
entries.add(IndexEntry(
|
|
||||||
word = word,
|
|
||||||
originalWord = originalWord,
|
|
||||||
offset = ArticleOffset(offset),
|
|
||||||
length = ArticleLength(length)
|
|
||||||
))
|
|
||||||
if (entries.size % 5000 == 0) {
|
if (entries.size % 5000 == 0) {
|
||||||
try { yield() } catch (_: Throwable) { /* Ignore yield failure */ }
|
try { yield() } catch (_: Throwable) { /* Ignore yield failure */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entries.sort()
|
entries.sort()
|
||||||
writeIndexFile(indexFile, entries)
|
writeIndexFile(indexFile, entries, counts.articleCount)
|
||||||
entries.clear()
|
entries.clear()
|
||||||
}
|
}
|
||||||
private suspend fun externalSortAndWrite(tempFile: File, indexFile: File, entryCount: Int, onOperationChange: (String) -> Unit, onFileProgress: (Float) -> Unit) {
|
private suspend fun externalSortAndWrite(tempFile: File, indexFile: File, counts: ParsedCounts, onOperationChange: (String) -> Unit, onFileProgress: (Float) -> Unit) {
|
||||||
|
val entryCount = counts.totalEntries
|
||||||
val adaptiveConfig = resourceManager?.calculateAdaptiveConfig(estimatedEntryCount = entryCount.toLong())
|
val adaptiveConfig = resourceManager?.calculateAdaptiveConfig(estimatedEntryCount = entryCount.toLong())
|
||||||
?: ResourceManager.AdaptiveConfig(
|
?: ResourceManager.AdaptiveConfig(
|
||||||
batchSize = 100_000,
|
batchSize = 100_000,
|
||||||
@@ -317,16 +332,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
val batch = ArrayList<IndexEntry>(currentBatchSize)
|
val batch = ArrayList<IndexEntry>(currentBatchSize)
|
||||||
val batchEnd = minOf(processed + currentBatchSize, entryCount)
|
val batchEnd = minOf(processed + currentBatchSize, entryCount)
|
||||||
while (processed < batchEnd) {
|
while (processed < batchEnd) {
|
||||||
val word = input.readUtf8String()
|
batch.add(input.readTempEntry())
|
||||||
val originalWord = input.readUtf8String()
|
|
||||||
val offset = input.readLong()
|
|
||||||
val length = input.readInt()
|
|
||||||
batch.add(IndexEntry(
|
|
||||||
word = word,
|
|
||||||
originalWord = originalWord,
|
|
||||||
offset = ArticleOffset(offset),
|
|
||||||
length = ArticleLength(length)
|
|
||||||
))
|
|
||||||
processed++
|
processed++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +428,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
|
|
||||||
onFileProgress(MERGE_START_PROGRESS)
|
onFileProgress(MERGE_START_PROGRESS)
|
||||||
onOperationChange("Merging ${sortedBatches.size} batches")
|
onOperationChange("Merging ${sortedBatches.size} batches")
|
||||||
mergeSortedBatches(sortedBatches, indexFile, entryCount, onOperationChange, onFileProgress)
|
mergeSortedBatches(sortedBatches, indexFile, counts, onOperationChange, onFileProgress)
|
||||||
onFileProgress(FINAL_INDEX_DONE_PROGRESS)
|
onFileProgress(FINAL_INDEX_DONE_PROGRESS)
|
||||||
} finally {
|
} finally {
|
||||||
sortedBatches.forEach { try { it.delete() } catch (_: Throwable) { /* Ignore cleanup failure */ } }
|
sortedBatches.forEach { try { it.delete() } catch (_: Throwable) { /* Ignore cleanup failure */ } }
|
||||||
@@ -443,6 +449,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
writer.write(entry.originalWord)
|
writer.write(entry.originalWord)
|
||||||
output.writeLong(entry.offset.value)
|
output.writeLong(entry.offset.value)
|
||||||
output.writeInt(entry.length.value)
|
output.writeInt(entry.length.value)
|
||||||
|
output.writeByte(if (entry.isAlias) ENTRY_FLAG_ALIAS else 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,15 +460,16 @@ class DslIndexer(context: Context? = null) {
|
|||||||
private suspend fun mergeSortedBatches(
|
private suspend fun mergeSortedBatches(
|
||||||
batchFiles: List<File>,
|
batchFiles: List<File>,
|
||||||
indexFile: File,
|
indexFile: File,
|
||||||
totalEntries: Int,
|
counts: ParsedCounts,
|
||||||
onOperationChange: (String) -> Unit = {},
|
onOperationChange: (String) -> Unit = {},
|
||||||
onFileProgress: (Float) -> Unit,
|
onFileProgress: (Float) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (batchFiles.isEmpty()) return
|
if (batchFiles.isEmpty()) return
|
||||||
|
|
||||||
|
val totalEntries = counts.totalEntries
|
||||||
val sparsePoints = mutableListOf<SparsePoint>()
|
val sparsePoints = mutableListOf<SparsePoint>()
|
||||||
val prefixCounts = HashMap<String, Int>(minOf(totalEntries / 10, 100_000))
|
val prefixCounts = HashMap<String, Int>(minOf(totalEntries / 10, 100_000))
|
||||||
val headerSize = 4 + 4 + 4
|
val headerSize = 4 + 4 + 4 + 4
|
||||||
val dataScratchFile = File(indexFile.parent, "${indexFile.name}.data")
|
val dataScratchFile = File(indexFile.parent, "${indexFile.name}.data")
|
||||||
|
|
||||||
val pq = PriorityQueue<BatchReader>()
|
val pq = PriorityQueue<BatchReader>()
|
||||||
@@ -508,7 +516,8 @@ class DslIndexer(context: Context? = null) {
|
|||||||
val origSize = writer.write(entry.originalWord)
|
val origSize = writer.write(entry.originalWord)
|
||||||
dataOutput.writeLong(entry.offset.value)
|
dataOutput.writeLong(entry.offset.value)
|
||||||
dataOutput.writeInt(entry.length.value)
|
dataOutput.writeInt(entry.length.value)
|
||||||
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4
|
dataOutput.writeByte(if (entry.isAlias) ENTRY_FLAG_ALIAS else 0)
|
||||||
|
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 + 1
|
||||||
|
|
||||||
entryIndex++
|
entryIndex++
|
||||||
|
|
||||||
@@ -542,6 +551,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile), BUFFER_SIZE)).use { output ->
|
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile), BUFFER_SIZE)).use { output ->
|
||||||
output.writeInt(INDEX_VERSION)
|
output.writeInt(INDEX_VERSION)
|
||||||
output.writeInt(totalEntries)
|
output.writeInt(totalEntries)
|
||||||
|
output.writeInt(counts.articleCount)
|
||||||
output.writeInt(sparsePoints.size)
|
output.writeInt(sparsePoints.size)
|
||||||
|
|
||||||
sparsePoints.forEachIndexed { idx, point ->
|
sparsePoints.forEachIndexed { idx, point ->
|
||||||
@@ -632,18 +642,14 @@ class DslIndexer(context: Context? = null) {
|
|||||||
}
|
}
|
||||||
private fun readNextEntry(input: java.io.DataInputStream): IndexEntry? {
|
private fun readNextEntry(input: java.io.DataInputStream): IndexEntry? {
|
||||||
return try {
|
return try {
|
||||||
val word = input.readUtf8String()
|
input.readTempEntry()
|
||||||
val originalWord = input.readUtf8String()
|
|
||||||
val offset = ArticleOffset(input.readLong())
|
|
||||||
val length = ArticleLength(input.readInt())
|
|
||||||
IndexEntry(word, originalWord, offset, length)
|
|
||||||
} catch (_: java.io.EOFException) {
|
} catch (_: java.io.EOFException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun writeIndexFile(indexFile: File, entries: List<IndexEntry>) {
|
private fun writeIndexFile(indexFile: File, entries: List<IndexEntry>, articleCount: Int) {
|
||||||
val sparseData = buildAdaptiveSparseIndex(entries)
|
val sparseData = buildAdaptiveSparseIndex(entries)
|
||||||
val headerSize = 4 + 4 + 4
|
val headerSize = 4 + 4 + 4 + 4
|
||||||
|
|
||||||
// Cache sparse table bytes to avoid redundant conversions
|
// Cache sparse table bytes to avoid redundant conversions
|
||||||
val sparseTableBytes = sparseData.map { CachedBytes.from(it.word) }
|
val sparseTableBytes = sparseData.map { CachedBytes.from(it.word) }
|
||||||
@@ -657,13 +663,14 @@ class DslIndexer(context: Context? = null) {
|
|||||||
// Calculate sizes without allocating byte arrays
|
// Calculate sizes without allocating byte arrays
|
||||||
val wordSize = utf8ByteSize(entry.word)
|
val wordSize = utf8ByteSize(entry.word)
|
||||||
val origSize = utf8ByteSize(entry.originalWord)
|
val origSize = utf8ByteSize(entry.originalWord)
|
||||||
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4
|
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile))).use { output ->
|
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile))).use { output ->
|
||||||
val writer = Utf8Writer(output)
|
val writer = Utf8Writer(output)
|
||||||
output.writeInt(INDEX_VERSION)
|
output.writeInt(INDEX_VERSION)
|
||||||
output.writeInt(entries.size)
|
output.writeInt(entries.size)
|
||||||
|
output.writeInt(articleCount)
|
||||||
output.writeInt(sparseData.size)
|
output.writeInt(sparseData.size)
|
||||||
|
|
||||||
sparseData.forEach { point ->
|
sparseData.forEach { point ->
|
||||||
@@ -677,6 +684,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
writer.write(entry.originalWord)
|
writer.write(entry.originalWord)
|
||||||
output.writeLong(entry.offset.value)
|
output.writeLong(entry.offset.value)
|
||||||
output.writeInt(entry.length.value)
|
output.writeInt(entry.length.value)
|
||||||
|
output.writeByte(if (entry.isAlias) ENTRY_FLAG_ALIAS else 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -707,7 +715,7 @@ class DslIndexer(context: Context? = null) {
|
|||||||
// Calculate sizes without allocating byte arrays
|
// Calculate sizes without allocating byte arrays
|
||||||
val wordSize = utf8ByteSize(entry.word)
|
val wordSize = utf8ByteSize(entry.word)
|
||||||
val origSize = utf8ByteSize(entry.originalWord)
|
val origSize = utf8ByteSize(entry.originalWord)
|
||||||
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4
|
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return sparsePoints
|
return sparsePoints
|
||||||
@@ -824,6 +832,24 @@ private fun java.io.DataInputStream.readUtf8String(): String {
|
|||||||
readFully(bytes)
|
readFully(bytes)
|
||||||
return String(bytes, Charsets.UTF_8)
|
return String(bytes, Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val ENTRY_FLAG_ALIAS = 1
|
||||||
|
|
||||||
|
private fun java.io.DataInputStream.readTempEntry(): IndexEntry {
|
||||||
|
val word = readUtf8String()
|
||||||
|
val originalWord = readUtf8String()
|
||||||
|
val offset = readLong()
|
||||||
|
val length = readInt()
|
||||||
|
val flags = readUnsignedByte()
|
||||||
|
return IndexEntry(
|
||||||
|
word = word,
|
||||||
|
originalWord = originalWord,
|
||||||
|
offset = ArticleOffset(offset),
|
||||||
|
length = ArticleLength(length),
|
||||||
|
isAlias = flags and ENTRY_FLAG_ALIAS != 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private enum class LineKind {
|
private enum class LineKind {
|
||||||
HEADWORD,
|
HEADWORD,
|
||||||
BODY,
|
BODY,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.example.research.R
|
|||||||
import com.example.research.core.domain.model.ArticleLength
|
import com.example.research.core.domain.model.ArticleLength
|
||||||
import com.example.research.core.domain.model.ArticleOffset
|
import com.example.research.core.domain.model.ArticleOffset
|
||||||
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.DictionaryError
|
import com.example.research.core.util.DictionaryError
|
||||||
import com.example.research.core.util.sanitizeCacheKey
|
import com.example.research.core.util.sanitizeCacheKey
|
||||||
import com.example.research.core.util.sanitizeQuery
|
import com.example.research.core.util.sanitizeQuery
|
||||||
@@ -23,7 +24,8 @@ 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(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.
|
||||||
@@ -43,13 +45,26 @@ class IndexSearcher(private val context: Context) {
|
|||||||
val dataStartOffset: Long
|
val dataStartOffset: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IndexComparisonSummary(
|
data class RangeCursor(
|
||||||
val expectedCount: Int,
|
val rangeQuery: String,
|
||||||
val actualCount: Int,
|
val absoluteIndex: Int,
|
||||||
val comparedCount: Int
|
val fileOffset: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
class IndexComparisonException(message: String) : IllegalStateException(message)
|
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()
|
||||||
@@ -66,7 +81,7 @@ class IndexSearcher(private val context: Context) {
|
|||||||
var iterationCount = 0
|
var iterationCount = 0
|
||||||
while (currentIdx < count) {
|
while (currentIdx < count) {
|
||||||
if (iterationCount % 1000 == 0) yield()
|
if (iterationCount % 1000 == 0) yield()
|
||||||
val nextEntry = readEntry(source)
|
val nextEntry = readEntry(source, metadata)
|
||||||
val nextWordLower = nextEntry.word.lowercase()
|
val nextWordLower = nextEntry.word.lowercase()
|
||||||
if (!nextWordLower.startsWith(normalizedQuery)) break
|
if (!nextWordLower.startsWith(normalizedQuery)) break
|
||||||
if (nextWordLower == normalizedQuery) return@withContext nextEntry.copy(word = nextWordLower)
|
if (nextWordLower == normalizedQuery) return@withContext nextEntry.copy(word = nextWordLower)
|
||||||
@@ -93,6 +108,7 @@ class IndexSearcher(private val context: Context) {
|
|||||||
return@withContext emptyList()
|
return@withContext emptyList()
|
||||||
}
|
}
|
||||||
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
||||||
|
val foldedQuery = normalizedQuery.foldLatinDiacritics()
|
||||||
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
|
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
|
||||||
resultsCache.get(cacheKey)?.let {
|
resultsCache.get(cacheKey)?.let {
|
||||||
return@withContext it
|
return@withContext it
|
||||||
@@ -102,33 +118,34 @@ 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 = mutableListOf<IndexEntry>()
|
val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).entries.toMutableList()
|
||||||
val substringMatches = mutableListOf<IndexEntry>()
|
if (foldedQuery != normalizedQuery) {
|
||||||
val firstMatch = findFirstMatch(source, metadata, normalizedQuery)
|
prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery).entries)
|
||||||
if (firstMatch != null) {
|
|
||||||
prefixMatches.addAll(readMatchingEntries(source, count, firstMatch, normalizedQuery))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val substringMatches = mutableListOf<IndexEntry>()
|
||||||
|
val seenArticles = prefixMatches.mapTo(HashSet()) { it.articleKey() }
|
||||||
if (includeSubstringMatches &&
|
if (includeSubstringMatches &&
|
||||||
count <= SUBSTRING_SCAN_MAX_ENTRIES &&
|
count <= SUBSTRING_SCAN_MAX_ENTRIES &&
|
||||||
prefixMatches.size < 100 &&
|
seenArticles.size < MAX_SUBSTRING_RESULTS &&
|
||||||
normalizedQuery.length >= 2
|
normalizedQuery.length >= 2
|
||||||
) {
|
) {
|
||||||
val prefixMatchWords = prefixMatches.map { it.word }.toSet()
|
|
||||||
source.seek(metadata.dataStartOffset)
|
source.seek(metadata.dataStartOffset)
|
||||||
for (i in 0 until count) {
|
for (i in 0 until count) {
|
||||||
if (i % 1000 == 0) yield()
|
if (i % 1000 == 0) yield()
|
||||||
if (substringMatches.size >= 100) break
|
if (substringMatches.size >= MAX_SUBSTRING_RESULTS) break
|
||||||
val entry = readEntry(source)
|
val entry = readEntry(source, metadata)
|
||||||
val entryWordLower = entry.word.lowercase()
|
val entryWordLower = entry.word.lowercase()
|
||||||
if (entryWordLower.contains(normalizedQuery) && entryWordLower !in prefixMatchWords) {
|
if (entryWordLower.contains(normalizedQuery) &&
|
||||||
|
seenArticles.add(entry.articleKey())
|
||||||
|
) {
|
||||||
substringMatches.add(entry.copy(word = entryWordLower))
|
substringMatches.add(entry.copy(word = entryWordLower))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val combinedResults = prefixMatches + substringMatches
|
(prefixMatches + substringMatches)
|
||||||
combinedResults.rankBySearchRelevance(
|
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
|
||||||
SearchRankingContext(normalizedQuery)
|
.distinctBy { it.articleKey() }
|
||||||
)
|
|
||||||
}
|
}
|
||||||
resultsCache.put(cacheKey, results)
|
resultsCache.put(cacheKey, results)
|
||||||
results
|
results
|
||||||
@@ -148,99 +165,83 @@ class IndexSearcher(private val context: Context) {
|
|||||||
metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
|
metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun compareEntryGroups(
|
suspend fun searchWithCursors(
|
||||||
expectedIndexPath: String,
|
pathOrUri: String,
|
||||||
actualIndexPath: String,
|
query: String
|
||||||
onGroup: suspend (word: String, expected: List<IndexEntry>, actual: List<IndexEntry>) -> Unit
|
): CursorSearchResult = withContext(Dispatchers.Default) {
|
||||||
): IndexComparisonSummary = withContext(Dispatchers.IO) {
|
if (query.isBlank()) {
|
||||||
val expectedMetadata = loadMetadata(expectedIndexPath)
|
return@withContext CursorSearchResult(emptyList(), emptyList())
|
||||||
val actualMetadata = loadMetadata(actualIndexPath)
|
|
||||||
|
|
||||||
if (expectedMetadata.totalCount != actualMetadata.totalCount) {
|
|
||||||
throw IndexComparisonException(
|
|
||||||
"Index entry count differs: expected=${expectedMetadata.totalCount}, " +
|
|
||||||
"actual=${actualMetadata.totalCount}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
||||||
openSource(expectedIndexPath).use { expectedSource ->
|
val foldedQuery = normalizedQuery.foldLatinDiacritics()
|
||||||
openSource(actualIndexPath).use { actualSource ->
|
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
|
||||||
expectedSource.seek(expectedMetadata.dataStartOffset)
|
cursorResultsCache.get(cacheKey)?.let {
|
||||||
actualSource.seek(actualMetadata.dataStartOffset)
|
return@withContext it
|
||||||
|
}
|
||||||
var expectedNext = readEntryOrNull(expectedSource, expectedMetadata.totalCount > 0)
|
try {
|
||||||
var actualNext = readEntryOrNull(actualSource, actualMetadata.totalCount > 0)
|
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
|
||||||
var comparedCount = 0
|
if (metadata.totalCount == 0) {
|
||||||
|
return@withContext CursorSearchResult(emptyList(), emptyList())
|
||||||
while (expectedNext != null && actualNext != null) {
|
|
||||||
if (expectedNext.word != actualNext.word) {
|
|
||||||
throw IndexComparisonException(
|
|
||||||
"Index words differ at entry $comparedCount: " +
|
|
||||||
"expected=${expectedNext.word}, actual=${actualNext.word}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val word = expectedNext.word
|
|
||||||
val expectedGroup = mutableListOf<IndexEntry>()
|
|
||||||
val actualGroup = mutableListOf<IndexEntry>()
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val current = expectedNext ?: break
|
|
||||||
if (current.word != word) break
|
|
||||||
expectedGroup.add(current)
|
|
||||||
comparedCount++
|
|
||||||
expectedNext = readEntryOrNull(
|
|
||||||
expectedSource,
|
|
||||||
comparedCount < expectedMetadata.totalCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var actualReadCount = comparedCount - expectedGroup.size
|
|
||||||
while (true) {
|
|
||||||
val current = actualNext ?: break
|
|
||||||
if (current.word != word) break
|
|
||||||
actualGroup.add(current)
|
|
||||||
actualReadCount++
|
|
||||||
actualNext = readEntryOrNull(
|
|
||||||
actualSource,
|
|
||||||
actualReadCount < actualMetadata.totalCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expectedGroup.size != actualGroup.size) {
|
|
||||||
throw IndexComparisonException(
|
|
||||||
"Headword multiplicity differs for '$word': " +
|
|
||||||
"expected=${expectedGroup.size}, actual=${actualGroup.size}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
onGroup(word, expectedGroup, actualGroup)
|
|
||||||
if (comparedCount % 10_000 == 0) yield()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expectedNext != null || actualNext != null) {
|
|
||||||
throw IndexComparisonException(
|
|
||||||
"One index ended before the other at entry $comparedCount"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IndexComparisonSummary(
|
|
||||||
expectedCount = expectedMetadata.totalCount,
|
|
||||||
actualCount = actualMetadata.totalCount,
|
|
||||||
comparedCount = comparedCount
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readEntryOrNull(source: RandomAccessSource, shouldRead: Boolean): IndexEntry? =
|
suspend fun scanTailChunk(
|
||||||
if (shouldRead) readEntry(source) else null
|
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 {
|
||||||
return metadataCache.contains(pathOrUri)
|
return metadataCache.contains(pathOrUri)
|
||||||
}
|
}
|
||||||
@@ -252,7 +253,13 @@ class IndexSearcher(private val context: Context) {
|
|||||||
val count = input.readInt()
|
val count = input.readInt()
|
||||||
|
|
||||||
return@withContext if (version >= 2) {
|
return@withContext if (version >= 2) {
|
||||||
loadMetadataV2(input, version, count)
|
val headerBase = if (version >= 6) {
|
||||||
|
input.readInt()
|
||||||
|
12L
|
||||||
|
} else {
|
||||||
|
8L
|
||||||
|
}
|
||||||
|
loadMetadataV2(input, version, count, headerBase)
|
||||||
} else {
|
} else {
|
||||||
loadMetadataV1(input, version, count)
|
loadMetadataV1(input, version, count)
|
||||||
}
|
}
|
||||||
@@ -272,13 +279,13 @@ class IndexSearcher(private val context: Context) {
|
|||||||
// runs inside withContext(Dispatchers.IO). The dispatcher is not visible across the
|
// runs inside withContext(Dispatchers.IO). The dispatcher is not visible across the
|
||||||
// suspend-call boundary, so the inspection is suppressed deliberately.
|
// suspend-call boundary, so the inspection is suppressed deliberately.
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun loadMetadataV2(input: DataInputStream, version: Int, count: Int): IndexMetadata {
|
private suspend fun loadMetadataV2(input: DataInputStream, version: Int, count: Int, headerBase: Long): IndexMetadata {
|
||||||
val sparseCount = input.readInt()
|
val sparseCount = input.readInt()
|
||||||
val sparseIndices = IntArray(sparseCount)
|
val sparseIndices = IntArray(sparseCount)
|
||||||
val sparseOffsets = LongArray(sparseCount)
|
val sparseOffsets = LongArray(sparseCount)
|
||||||
val sparseWords = Array(sparseCount) { "" }
|
val sparseWords = Array(sparseCount) { "" }
|
||||||
|
|
||||||
var headerSize = 4 + 4 + 4L
|
var headerSize = headerBase + 4L
|
||||||
|
|
||||||
for (i in 0 until sparseCount) {
|
for (i in 0 until sparseCount) {
|
||||||
if (i % 1000 == 0) yield()
|
if (i % 1000 == 0) yield()
|
||||||
@@ -337,6 +344,17 @@ class IndexSearcher(private val context: Context) {
|
|||||||
dataStartOffset = 8L
|
dataStartOffset = 8L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun readPrefixMatches(
|
||||||
|
source: RandomAccessSource,
|
||||||
|
metadata: IndexMetadata,
|
||||||
|
query: String
|
||||||
|
): RangeScanResult {
|
||||||
|
val firstMatch = findFirstMatch(source, metadata, query)
|
||||||
|
?: return RangeScanResult(emptyList(), null)
|
||||||
|
return readMatchingEntries(source, metadata, firstMatch, query)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun findFirstMatch(source: RandomAccessSource, metadata: IndexMetadata, query: String): Pair<Int, IndexEntry>? {
|
private suspend fun findFirstMatch(source: RandomAccessSource, metadata: IndexMetadata, query: String): Pair<Int, IndexEntry>? {
|
||||||
val sparseIndices = metadata.sparseIndices
|
val sparseIndices = metadata.sparseIndices
|
||||||
val sparseOffsets = metadata.sparseOffsets
|
val sparseOffsets = metadata.sparseOffsets
|
||||||
@@ -359,7 +377,7 @@ class IndexSearcher(private val context: Context) {
|
|||||||
|
|
||||||
while (currentAbsoluteIdx < metadata.totalCount) {
|
while (currentAbsoluteIdx < metadata.totalCount) {
|
||||||
if (iterationCount % 1000 == 0) yield()
|
if (iterationCount % 1000 == 0) yield()
|
||||||
val entry = readEntry(source)
|
val entry = readEntry(source, metadata)
|
||||||
val entryWordLower = entry.word.lowercase()
|
val entryWordLower = entry.word.lowercase()
|
||||||
if (entryWordLower >= query) {
|
if (entryWordLower >= query) {
|
||||||
return if (entryWordLower.startsWith(query)) Pair(currentAbsoluteIdx, entry.copy(word = entryWordLower)) else null
|
return if (entryWordLower.startsWith(query)) Pair(currentAbsoluteIdx, entry.copy(word = entryWordLower)) else null
|
||||||
@@ -394,57 +412,67 @@ class IndexSearcher(private val context: Context) {
|
|||||||
}
|
}
|
||||||
private suspend fun readMatchingEntries(
|
private suspend fun readMatchingEntries(
|
||||||
source: RandomAccessSource,
|
source: RandomAccessSource,
|
||||||
totalCount: Int,
|
metadata: IndexMetadata,
|
||||||
firstMatch: Pair<Int, IndexEntry>,
|
firstMatch: Pair<Int, IndexEntry>,
|
||||||
query: String
|
query: String
|
||||||
): List<IndexEntry> {
|
): RangeScanResult {
|
||||||
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 < totalCount && results.size < 100) {
|
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)
|
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 results
|
return RangeScanResult(results, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readEntry(source: RandomAccessSource): IndexEntry {
|
private fun readPooledString(source: RandomAccessSource, length: Int): String {
|
||||||
val wordLen = source.readUnsignedShort()
|
return if (length <= 4096) {
|
||||||
val word = if (wordLen <= 4096) {
|
|
||||||
val buffer = entryBuffer.get() ?: throw IllegalStateException("Entry buffer pool exhausted")
|
val buffer = entryBuffer.get() ?: throw IllegalStateException("Entry buffer pool exhausted")
|
||||||
source.readFully(buffer, 0, wordLen)
|
source.readFully(buffer, 0, length)
|
||||||
String(buffer, 0, wordLen, Charsets.UTF_8)
|
String(buffer, 0, length, Charsets.UTF_8)
|
||||||
} else {
|
} else {
|
||||||
val bytes = ByteArray(wordLen)
|
val bytes = ByteArray(length)
|
||||||
source.readFully(bytes)
|
|
||||||
String(bytes, Charsets.UTF_8)
|
|
||||||
}
|
|
||||||
|
|
||||||
val origLen = source.readUnsignedShort()
|
|
||||||
val originalWord = if (origLen <= 4096) {
|
|
||||||
val buffer = entryBuffer.get() ?: throw IllegalStateException("Entry buffer pool exhausted")
|
|
||||||
source.readFully(buffer, 0, origLen)
|
|
||||||
String(buffer, 0, origLen, Charsets.UTF_8)
|
|
||||||
} else {
|
|
||||||
val bytes = ByteArray(origLen)
|
|
||||||
source.readFully(bytes)
|
source.readFully(bytes)
|
||||||
String(bytes, Charsets.UTF_8)
|
String(bytes, Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readEntry(source: RandomAccessSource, metadata: IndexMetadata): IndexEntry {
|
||||||
|
val word = readPooledString(source, source.readUnsignedShort())
|
||||||
|
val originalWord = readPooledString(source, source.readUnsignedShort())
|
||||||
val offset = ArticleOffset(source.readLong())
|
val offset = ArticleOffset(source.readLong())
|
||||||
val length = ArticleLength(source.readInt())
|
val length = ArticleLength(source.readInt())
|
||||||
return IndexEntry(word, originalWord, offset, length)
|
val isAlias = metadata.version >= 6 && source.readUnsignedByte() and ENTRY_FLAG_ALIAS != 0
|
||||||
|
return IndexEntry(word, originalWord, offset, length, isAlias = isAlias)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class ArticleKey(
|
||||||
|
val originalWord: String,
|
||||||
|
val offset: Long,
|
||||||
|
val length: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun IndexEntry.articleKey() = ArticleKey(originalWord, offset.value, length.value)
|
||||||
|
|
||||||
private fun openSource(pathOrUri: String): RandomAccessSource {
|
private fun openSource(pathOrUri: String): RandomAccessSource {
|
||||||
val file = File(pathOrUri)
|
val file = File(pathOrUri)
|
||||||
if (!file.exists()) throw DictionaryError.FileSystemError.NotFound(pathOrUri)
|
return if (file.exists()) {
|
||||||
return FileSource(file)
|
FileSource(file)
|
||||||
|
} else {
|
||||||
|
throw DictionaryError.FileSystemError.NotFound(pathOrUri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private fun openInputStream(pathOrUri: String): java.io.InputStream {
|
private fun openInputStream(pathOrUri: String): java.io.InputStream {
|
||||||
val file = File(pathOrUri)
|
val file = File(pathOrUri)
|
||||||
@@ -455,20 +483,22 @@ 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 class ResultsLruCache(private val maxEntries: Int) {
|
private class ResultsLruCache<V : Any>(private val maxEntries: Int) {
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private val map = object : LinkedHashMap<String, List<IndexEntry>>(maxEntries, 0.75f, true) {
|
private val map = object : LinkedHashMap<String, V>(maxEntries, 0.75f, true) {
|
||||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<IndexEntry>>): Boolean {
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, V>): Boolean {
|
||||||
return size > maxEntries
|
return size > maxEntries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun get(key: String): List<IndexEntry>? = mutex.withLock {
|
suspend fun get(key: String): V? = mutex.withLock {
|
||||||
map[key]
|
map[key]
|
||||||
}
|
}
|
||||||
suspend fun put(key: String, value: List<IndexEntry>) = mutex.withLock {
|
suspend fun put(key: String, value: V) = mutex.withLock {
|
||||||
map[key] = value
|
map[key] = value
|
||||||
}
|
}
|
||||||
fun trim(level: Int) {
|
fun trim(level: Int) {
|
||||||
@@ -548,6 +578,8 @@ private class MetadataLruCache(private val maxBytes: Long) {
|
|||||||
}
|
}
|
||||||
interface RandomAccessSource : Closeable {
|
interface RandomAccessSource : Closeable {
|
||||||
fun seek(pos: Long)
|
fun seek(pos: Long)
|
||||||
|
fun position(): Long
|
||||||
|
fun readUnsignedByte(): Int
|
||||||
fun readUnsignedShort(): Int
|
fun readUnsignedShort(): Int
|
||||||
fun readInt(): Int
|
fun readInt(): Int
|
||||||
fun readLong(): Long
|
fun readLong(): Long
|
||||||
@@ -561,6 +593,10 @@ class FileSource(file: File) : RandomAccessSource {
|
|||||||
raf.seek(pos)
|
raf.seek(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun position(): Long = raf.filePointer
|
||||||
|
|
||||||
|
override fun readUnsignedByte(): Int = raf.readUnsignedByte()
|
||||||
|
|
||||||
override fun readUnsignedShort(): Int = raf.readUnsignedShort()
|
override fun readUnsignedShort(): Int = raf.readUnsignedShort()
|
||||||
|
|
||||||
override fun readInt(): Int = raf.readInt()
|
override fun readInt(): Int = raf.readInt()
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
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
|
||||||
|
|
||||||
internal data class SearchRankingContext(
|
internal data class SearchRankingContext(
|
||||||
val normalizedQuery: String,
|
val normalizedQuery: String,
|
||||||
|
val foldedQuery: String = normalizedQuery.foldLatinDiacritics(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class SortKey(
|
private data class SortKey(
|
||||||
val charCount: Int,
|
val matchTier: Int,
|
||||||
val exactMatch: Int,
|
val exactMatch: Int,
|
||||||
val startsWithMatch: Int,
|
val charCount: Int,
|
||||||
val word: String,
|
val word: String,
|
||||||
val entry: IndexEntry,
|
val entry: IndexEntry,
|
||||||
)
|
)
|
||||||
@@ -26,13 +28,18 @@ internal fun List<IndexEntry>.rankBySearchRelevance(
|
|||||||
.ifEmpty { entry.word.trim() }
|
.ifEmpty { entry.word.trim() }
|
||||||
|
|
||||||
SortKey(
|
SortKey(
|
||||||
charCount = displayKey.codePointCount(0, displayKey.length),
|
matchTier = when {
|
||||||
|
!entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0
|
||||||
|
entry.isAlias && word.startsWith(ranking.foldedQuery) -> 1
|
||||||
|
!entry.isAlias && word.foldLatinDiacritics().startsWith(ranking.foldedQuery) -> 1
|
||||||
|
else -> 2
|
||||||
|
},
|
||||||
exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
|
exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
|
||||||
startsWithMatch = if (word.startsWith(ranking.normalizedQuery)) 0 else 1,
|
charCount = displayKey.codePointCount(0, displayKey.length),
|
||||||
word = word,
|
word = word,
|
||||||
entry = entry,
|
entry = entry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sortedWith(compareBy({ it.charCount }, { it.exactMatch }, { it.startsWithMatch }, { it.word }))
|
.sortedWith(compareBy({ it.matchTier }, { it.exactMatch }, { it.charCount }, { it.word }))
|
||||||
.map { it.entry }
|
.map { it.entry }
|
||||||
}
|
}
|
||||||
@@ -112,20 +112,20 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
|
|||||||
maxResults: Int
|
maxResults: Int
|
||||||
): List<IndexEntry> = withContext(Dispatchers.IO) {
|
): List<IndexEntry> = withContext(Dispatchers.IO) {
|
||||||
val allResults = mutableListOf<IndexEntry>()
|
val allResults = mutableListOf<IndexEntry>()
|
||||||
for (dict in dictionaries) {
|
for ((name, path, indexPath, _, _, metadata) in dictionaries) {
|
||||||
try {
|
try {
|
||||||
val indexPath = resolveIndexPath(dict.indexPath)
|
val resolvedIndexPath = resolveIndexPath(indexPath)
|
||||||
val entry = indexSearcher.findFirstEntry(indexPath, query)
|
val entry = indexSearcher.findFirstEntry(resolvedIndexPath, query)
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
allResults.add(entry.withDictionary(dict.metadata.name.ifBlank { dict.name }, dict.path))
|
allResults.add(entry.withDictionary(metadata.name.ifBlank { name }, path))
|
||||||
}
|
}
|
||||||
if (allResults.size >= maxResults) break
|
if (allResults.size >= maxResults) break
|
||||||
} catch (e: OutOfMemoryError) {
|
} catch (e: OutOfMemoryError) {
|
||||||
Log.e(TAG, "search() - OOM for ${dict.indexPath}", e)
|
Log.e(TAG, "search() - OOM for $indexPath", e)
|
||||||
trimMemory(80)
|
trimMemory(80)
|
||||||
break
|
break
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "search() - error for ${dict.indexPath}: ${e.message}")
|
Log.w(TAG, "search() - error for $indexPath: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allResults
|
allResults
|
||||||
|
|||||||
+190
-115
@@ -14,6 +14,7 @@ import com.example.research.core.util.extractDictionaryPrefix
|
|||||||
import com.example.research.core.util.sanitizeQuery
|
import com.example.research.core.util.sanitizeQuery
|
||||||
import com.example.research.core.util.toDictionaryError
|
import com.example.research.core.util.toDictionaryError
|
||||||
import com.example.research.data.dictzip.DictZipRandomAccessFile
|
import com.example.research.data.dictzip.DictZipRandomAccessFile
|
||||||
|
import com.example.research.data.local.preferences.PreferencesManager
|
||||||
import com.example.research.data.parser.DslCharsetDetector
|
import com.example.research.data.parser.DslCharsetDetector
|
||||||
import com.example.research.data.parser.DslHeaderParser
|
import com.example.research.data.parser.DslHeaderParser
|
||||||
import com.example.research.data.parser.DslIndexer
|
import com.example.research.data.parser.DslIndexer
|
||||||
@@ -26,8 +27,9 @@ import kotlinx.coroutines.awaitAll
|
|||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
@@ -68,13 +70,9 @@ private class ProgressTracker(files: List<LocalDictionaryRepository.DiscoveredFi
|
|||||||
aggregateWeightedMicros.addAndGet(newMicros - prevMicros)
|
aggregateWeightedMicros.addAndGet(newMicros - prevMicros)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun progress(): Float {
|
||||||
* Current weighted sum mapped back to [0f, totalFiles] so the existing
|
|
||||||
* IndexingProgress model can keep deriving progress as aggregate/total.
|
|
||||||
*/
|
|
||||||
fun aggregateSum(): Float {
|
|
||||||
val weightedProgress = aggregateWeightedMicros.get().toDouble() / (totalWeight.toDouble() * 1_000_000.0)
|
val weightedProgress = aggregateWeightedMicros.get().toDouble() / (totalWeight.toDouble() * 1_000_000.0)
|
||||||
return (weightedProgress.coerceIn(0.0, 1.0) * totalFiles).toFloat()
|
return weightedProgress.coerceIn(0.0, 1.0).toFloat()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
@@ -85,28 +83,23 @@ private class ProgressTracker(files: List<LocalDictionaryRepository.DiscoveredFi
|
|||||||
|
|
||||||
class LocalDictionaryRepository(
|
class LocalDictionaryRepository(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val engine: KotlinDictionaryEngine,
|
private val engine: KotlinDictionaryEngine = KotlinDictionaryEngine(context),
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
private val preferencesManager: PreferencesManager = PreferencesManager(context),
|
||||||
) : CoroutineScope {
|
) : CoroutineScope {
|
||||||
constructor(context: Context) : this(
|
|
||||||
context,
|
|
||||||
KotlinDictionaryEngine(context)
|
|
||||||
)
|
|
||||||
|
|
||||||
override val coroutineContext = Job() + ioDispatcher
|
override val coroutineContext = Job() + ioDispatcher
|
||||||
|
|
||||||
private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList())
|
private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList())
|
||||||
val dictionaries: StateFlow<List<Dictionary>>
|
val dictionaries: StateFlow<List<Dictionary>> = mutableDictionaries.asStateFlow()
|
||||||
get() = mutableDictionaries
|
|
||||||
|
|
||||||
private val mutableIndexingProgress = MutableStateFlow(IndexingProgress())
|
private val mutableIndexingProgress = MutableStateFlow(IndexingProgress())
|
||||||
val indexingProgress: StateFlow<IndexingProgress>
|
val indexingProgress: StateFlow<IndexingProgress> = mutableIndexingProgress.asStateFlow()
|
||||||
get() = mutableIndexingProgress
|
|
||||||
|
|
||||||
private val indexingMutex = Mutex()
|
private val indexingMutex = Mutex()
|
||||||
private val indexingSemaphore = Semaphore(1)
|
private val indexingSemaphore = Semaphore(1)
|
||||||
private val warmupMutex = Mutex()
|
private val warmupMutex = Mutex()
|
||||||
|
private val dictionaryStateMutex = Mutex()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var currentIndexingJob: Job? = null
|
private var currentIndexingJob: Job? = null
|
||||||
@@ -124,6 +117,24 @@ class LocalDictionaryRepository(
|
|||||||
|
|
||||||
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
|
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
|
||||||
|
|
||||||
|
suspend fun listDictionaryPayloadFileNames(path: String): List<String>? =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val directory = File(path)
|
||||||
|
when {
|
||||||
|
!directory.exists() -> {
|
||||||
|
if (directory.parentFile?.isDirectory == true) emptyList() else null
|
||||||
|
}
|
||||||
|
!directory.isDirectory -> null
|
||||||
|
else -> directory.listFiles { file -> isDictionaryPayloadFile(file) }
|
||||||
|
?.map(File::getName)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Failed to list dictionary files: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
|
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
|
||||||
if (isIndexingInProgress()) {
|
if (isIndexingInProgress()) {
|
||||||
return@withContext OperationResult.Error(
|
return@withContext OperationResult.Error(
|
||||||
@@ -140,7 +151,7 @@ class LocalDictionaryRepository(
|
|||||||
val filesToScan = scanLocalDirectory(pathOrUri)
|
val filesToScan = scanLocalDirectory(pathOrUri)
|
||||||
|
|
||||||
if (filesToScan.isEmpty()) {
|
if (filesToScan.isEmpty()) {
|
||||||
mutableDictionaries.value = emptyList()
|
replaceDictionaries(emptyList())
|
||||||
mutableIndexingProgress.value = IndexingProgress(isIndexing = false)
|
mutableIndexingProgress.value = IndexingProgress(isIndexing = false)
|
||||||
return@withContext OperationResult.Success(0)
|
return@withContext OperationResult.Success(0)
|
||||||
}
|
}
|
||||||
@@ -159,86 +170,81 @@ class LocalDictionaryRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filesToIndex.isEmpty()) {
|
if (filesToIndex.isEmpty()) {
|
||||||
mutableDictionaries.value = existingDictionaries.sortedBy { it.name }
|
replaceDictionaries(existingDictionaries)
|
||||||
engine.getIndexSearcher().trimMemory(80)
|
engine.getIndexSearcher().trimMemory(80)
|
||||||
return@withContext OperationResult.Success(existingDictionaries.size)
|
return@withContext OperationResult.Success(existingDictionaries.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutableDictionaries.value = existingDictionaries.sortedBy { it.name }
|
replaceDictionaries(existingDictionaries)
|
||||||
|
|
||||||
val totalArticlesIndexed = AtomicInteger(existingDictionaries.sumOf { it.articleCount })
|
val totalArticlesIndexed = AtomicInteger(existingDictionaries.sumOf { it.articleCount })
|
||||||
|
|
||||||
progressTracker = ProgressTracker(filesToScan)
|
progressTracker = ProgressTracker(filesToScan)
|
||||||
|
|
||||||
existingDictionaries.forEach { dict ->
|
existingDictionaries.forEach { dict ->
|
||||||
progressTracker?.updateFileProgress(dict.name, 1.0f)
|
progressTracker?.updateFileProgress(File(dict.path).name, 1.0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutableIndexingProgress.emit(
|
mutableIndexingProgress.emit(
|
||||||
IndexingProgress(
|
IndexingProgress(
|
||||||
isIndexing = true,
|
isIndexing = true,
|
||||||
currentFile = context.getString(R.string.indexing_label),
|
progress = progressTracker?.progress() ?: 0f,
|
||||||
totalFiles = filesToScan.size,
|
|
||||||
currentIndex = filesToScan.size - filesToIndex.size,
|
|
||||||
aggregateSum = progressTracker?.aggregateSum() ?: -1f,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val lastEmittedPercent = AtomicInteger(-1)
|
val lastEmittedPercent = AtomicInteger(-1)
|
||||||
val lastProgressEmitMs = AtomicLong(0L)
|
val lastProgressEmitMs = AtomicLong(0L)
|
||||||
|
|
||||||
|
val publishCurrentProgress = {
|
||||||
|
val tracker = progressTracker
|
||||||
|
if (tracker != null) {
|
||||||
|
val progress = tracker.progress()
|
||||||
|
lastEmittedPercent.set((progress * 100f).toInt().coerceIn(0, 100))
|
||||||
|
lastProgressEmitMs.set(SystemClock.elapsedRealtime())
|
||||||
|
mutableIndexingProgress.update { p ->
|
||||||
|
p.copy(progress = progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
supervisorScope {
|
supervisorScope {
|
||||||
filesToIndex.mapIndexed { index, file ->
|
filesToIndex.map { file ->
|
||||||
async {
|
async {
|
||||||
indexingSemaphore.withPermit {
|
indexingSemaphore.withPermit {
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
yield()
|
yield()
|
||||||
|
|
||||||
val res = indexDictionary(file) { _, op, prog ->
|
val res = indexDictionary(file) { _, _, prog ->
|
||||||
if (prog >= 0f) {
|
if (prog >= 0f) {
|
||||||
progressTracker?.updateFileProgress(file.name, prog)
|
progressTracker?.updateFileProgress(file.name, prog)
|
||||||
}
|
}
|
||||||
|
|
||||||
val tracker = progressTracker
|
val progress = progressTracker?.progress() ?: 0f
|
||||||
val aggregate = tracker?.aggregateSum() ?: -1f
|
val newPercent = (progress * 100f).toInt().coerceIn(0, 100)
|
||||||
val newPercent = if (filesToScan.isNotEmpty() && aggregate >= 0f) {
|
|
||||||
((aggregate / filesToScan.size) * 100f).toInt().coerceIn(0, 100)
|
|
||||||
} else -1
|
|
||||||
|
|
||||||
val currentIdx = filesToScan.size - filesToIndex.size + index + 1
|
|
||||||
val previous = mutableIndexingProgress.value
|
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = SystemClock.elapsedRealtime()
|
||||||
val percentChanged = newPercent >= 0 && newPercent != lastEmittedPercent.get()
|
val percentChanged = newPercent != lastEmittedPercent.get()
|
||||||
val fileChanged = previous.currentFile != file.name
|
|
||||||
val completed = newPercent >= 100 || prog >= 1f
|
val completed = newPercent >= 100 || prog >= 1f
|
||||||
val intervalElapsed =
|
val intervalElapsed =
|
||||||
now - lastProgressEmitMs.get() >= MIN_PROGRESS_EMIT_INTERVAL_MS
|
now - lastProgressEmitMs.get() >= MIN_PROGRESS_EMIT_INTERVAL_MS
|
||||||
val shouldEmit = fileChanged || completed || (percentChanged && intervalElapsed)
|
val shouldEmit = completed || (percentChanged && intervalElapsed)
|
||||||
|
|
||||||
if (shouldEmit) {
|
if (shouldEmit) {
|
||||||
if (newPercent >= 0) lastEmittedPercent.set(newPercent)
|
lastEmittedPercent.set(newPercent)
|
||||||
lastProgressEmitMs.set(now)
|
lastProgressEmitMs.set(now)
|
||||||
mutableIndexingProgress.update { p ->
|
mutableIndexingProgress.update { p ->
|
||||||
p.copy(
|
p.copy(progress = progress)
|
||||||
currentFile = file.name,
|
|
||||||
currentIndex = currentIdx,
|
|
||||||
currentFileProgress = prog,
|
|
||||||
label = op.ifEmpty { p.label },
|
|
||||||
aggregateSum = aggregate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res is OperationResult.Success) {
|
if (res is OperationResult.Success) {
|
||||||
progressTracker?.updateFileProgress(file.name, 1.0f)
|
progressTracker?.updateFileProgress(file.name, 1.0f)
|
||||||
|
publishCurrentProgress()
|
||||||
totalArticlesIndexed.addAndGet(res.data.articleCount)
|
totalArticlesIndexed.addAndGet(res.data.articleCount)
|
||||||
mutableDictionaries.update { current ->
|
val dictionary = res.data
|
||||||
(current + res.data)
|
addDictionary(dictionary)
|
||||||
.distinctBy { it.path }
|
dictionary
|
||||||
.sortedBy { it.name }
|
|
||||||
}
|
|
||||||
res.data
|
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,17 +272,37 @@ class LocalDictionaryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun replaceDictionaries(dictionaries: List<Dictionary>) {
|
||||||
|
dictionaryStateMutex.withLock { applyDictionaries(dictionaries) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun addDictionary(dictionary: Dictionary) {
|
||||||
|
dictionaryStateMutex.withLock {
|
||||||
|
applyDictionaries((mutableDictionaries.value + dictionary).distinctBy { it.path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyDictionaries(dictionaries: List<Dictionary>) {
|
||||||
|
val disabledPaths = preferencesManager.disabledDictionaryPaths.first()
|
||||||
|
mutableDictionaries.value = dictionaries
|
||||||
|
.map { it.withActiveStatus(it.path !in disabledPaths) }
|
||||||
|
.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun tryLoadExistingIndex(file: DiscoveredFile): Dictionary? {
|
private suspend fun tryLoadExistingIndex(file: DiscoveredFile): Dictionary? {
|
||||||
val dictFile = File(file.localPath)
|
val dictFile = File(file.localPath)
|
||||||
val indexFile = File(file.indexPath)
|
val indexFile = File(file.indexPath)
|
||||||
|
|
||||||
if (!indexFile.exists() || indexFile.length() == 0L) return null
|
if (!indexFile.exists() || indexFile.length() == 0L) {
|
||||||
|
deleteIndexFiles(indexFile)
|
||||||
|
return null
|
||||||
|
}
|
||||||
if (!dictFile.exists()) return null
|
if (!dictFile.exists()) return null
|
||||||
|
|
||||||
val header = readIndexHeader(indexFile)
|
val header = readIndexHeader(indexFile)
|
||||||
|
|
||||||
if (header == null || header.version != DslIndexer.INDEX_VERSION) {
|
if (header == null || header.version != DslIndexer.INDEX_VERSION) {
|
||||||
try { indexFile.delete() } catch (_: Exception) {}
|
deleteIndexFiles(indexFile)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,23 +352,36 @@ class LocalDictionaryRepository(
|
|||||||
)
|
)
|
||||||
OperationResult.Success(dict)
|
OperationResult.Success(dict)
|
||||||
} else {
|
} else {
|
||||||
indexFile.delete()
|
deleteIndexFiles(indexFile)
|
||||||
OperationResult.Error(result.errorMsg, null)
|
OperationResult.Error(result.errorMsg, null)
|
||||||
}
|
}
|
||||||
} catch(e: Exception) {
|
} catch(e: Exception) {
|
||||||
indexFile.delete()
|
deleteIndexFiles(indexFile)
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
OperationResult.Error(e.message ?: context.getString(R.string.error_indexing), e)
|
OperationResult.Error(e.message ?: context.getString(R.string.error_indexing), e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun deleteIndexFiles(indexFile: File, errors: MutableList<String>? = null) {
|
||||||
|
deleteFileTracked(indexFile, "index file", errors)
|
||||||
|
deleteFileTracked(DslIndexer.foldedIndexFile(indexFile), "legacy folded index file", errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteFileTracked(file: File, label: String, errors: MutableList<String>?) {
|
||||||
|
try {
|
||||||
|
if (file.exists() && !file.delete()) {
|
||||||
|
errors?.add("Failed to delete $label: ${file.absolutePath}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errors?.add("Failed to delete $label: ${file.absolutePath} (${e.message})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun scanLocalDirectory(path: String): List<DiscoveredFile> {
|
private fun scanLocalDirectory(path: String): List<DiscoveredFile> {
|
||||||
val dir = File(path)
|
val dir = File(path)
|
||||||
if (!dir.exists() || !dir.isDirectory) return emptyList()
|
if (!dir.exists() || !dir.isDirectory) return emptyList()
|
||||||
|
|
||||||
return dir.listFiles { f ->
|
return dir.listFiles { file -> isDictionaryPayloadFile(file) }?.map { file ->
|
||||||
f.isFile && (f.name.endsWith(".dsl") || f.name.endsWith(".dsl.dz") || f.name.endsWith(".dsl.gz"))
|
|
||||||
}?.map { file ->
|
|
||||||
DiscoveredFile(
|
DiscoveredFile(
|
||||||
name = file.name,
|
name = file.name,
|
||||||
localPath = file.absolutePath,
|
localPath = file.absolutePath,
|
||||||
@@ -351,10 +390,20 @@ class LocalDictionaryRepository(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isDictionaryPayloadFile(file: File): Boolean {
|
||||||
|
if (!file.isFile) return false
|
||||||
|
val n = file.name.lowercase()
|
||||||
|
if (n.endsWith(".dsl.dz") || n.endsWith(".dsl.gz")) return true
|
||||||
|
if (n.endsWith(".dsl")) {
|
||||||
|
return File(file.parent, file.name + ".dz").exists()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
|
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
|
||||||
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
|
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
|
||||||
|
|
||||||
val activeDicts = mutableDictionaries.value
|
val activeDicts = mutableDictionaries.value.filter { it.isActive }
|
||||||
val sanitizedQuery = query.sanitizeQuery()
|
val sanitizedQuery = query.sanitizeQuery()
|
||||||
|
|
||||||
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_DIRECT) {
|
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_DIRECT) {
|
||||||
@@ -404,65 +453,81 @@ class LocalDictionaryRepository(
|
|||||||
return engine.getIndexSearcher()
|
return engine.getIndexSearcher()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleDictionaryActive(dictionaryPath: String) {
|
suspend fun toggleDictionaryActive(dictionaryPath: String) {
|
||||||
mutableDictionaries.update { currentList ->
|
dictionaryStateMutex.withLock {
|
||||||
currentList.map { dict ->
|
val isActive = mutableDictionaries.value
|
||||||
if (dict.path == dictionaryPath) {
|
.firstOrNull { it.path == dictionaryPath }
|
||||||
dict.withActiveStatus(!dict.isActive)
|
?.isActive
|
||||||
} else {
|
?.not()
|
||||||
dict
|
?: return
|
||||||
|
preferencesManager.setDictionaryActive(dictionaryPath, isActive)
|
||||||
|
mutableDictionaries.update { currentList ->
|
||||||
|
currentList.map { dict ->
|
||||||
|
if (dict.path == dictionaryPath) {
|
||||||
|
dict.withActiveStatus(isActive)
|
||||||
|
} else {
|
||||||
|
dict
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
|
private fun removeDictionaryFromState(path: String) {
|
||||||
try {
|
mutableDictionaries.update { current -> current.filter { it.path != path } }
|
||||||
mutableDictionaries.update { current ->
|
}
|
||||||
current.filter { it.path != dictionary.path }
|
|
||||||
}
|
|
||||||
|
|
||||||
launch(ioDispatcher) {
|
suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
|
||||||
try {
|
return try {
|
||||||
val deleteErrors = mutableListOf<String>()
|
val (failure, cleanupErrors) = withContext(ioDispatcher) {
|
||||||
|
val dictFile = File(dictionary.path)
|
||||||
val dictFile = File(dictionary.path)
|
if (dictFile.exists() && !dictFile.delete()) {
|
||||||
if (dictFile.exists()) {
|
return@withContext Pair(
|
||||||
if (!dictFile.delete()) {
|
"Failed to delete dictionary file: ${dictFile.absolutePath}",
|
||||||
deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}")
|
emptyList(),
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
|
|
||||||
File(dictionary.indexPath)
|
|
||||||
} else {
|
|
||||||
File("${dictionary.indexPath}.idx")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (indexFile.exists()) {
|
|
||||||
if (!indexFile.delete()) {
|
|
||||||
deleteErrors.add("Failed to delete index file: ${indexFile.absolutePath}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
engine.getIndexSearcher().trimMemory(80)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteErrors.isNotEmpty()) {
|
|
||||||
val message = deleteErrors.joinToString("; ")
|
|
||||||
Log.w(TAG, "Dictionary deletion completed with errors: $message")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Background deletion failed: ${e.message}", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val deleteErrors = mutableListOf<String>()
|
||||||
|
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
|
||||||
|
File(dictionary.indexPath)
|
||||||
|
} else {
|
||||||
|
File("${dictionary.indexPath}.idx")
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIndexFiles(indexFile, deleteErrors)
|
||||||
|
Pair<String?, List<String>>(null, deleteErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
return OperationResult.Success(Unit)
|
if (failure != null) {
|
||||||
|
return OperationResult.Error(failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
dictionaryStateMutex.withLock {
|
||||||
|
try {
|
||||||
|
preferencesManager.removeDictionaryActiveState(dictionary.path)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to clear dictionary active state: ${e.message}")
|
||||||
|
}
|
||||||
|
removeDictionaryFromState(dictionary.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
engine.getIndexSearcher().trimMemory(80)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanupErrors.isNotEmpty()) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Dictionary deletion completed with errors: ${cleanupErrors.joinToString("; ")}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationResult.Success(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return OperationResult.Error("Failed to delete dictionary", e)
|
OperationResult.Error("Failed to delete dictionary", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,12 +538,12 @@ class LocalDictionaryRepository(
|
|||||||
val dictionaries = mutableDictionaries.value
|
val dictionaries = mutableDictionaries.value
|
||||||
if (dictionaries.isEmpty()) return@withContext
|
if (dictionaries.isEmpty()) return@withContext
|
||||||
|
|
||||||
for (dict in dictionaries) {
|
for ((_, _, indexPath) in dictionaries) {
|
||||||
try {
|
try {
|
||||||
val indexPath = resolveIndexPath(dict.indexPath)
|
val resolvedIndexPath = resolveIndexPath(indexPath)
|
||||||
val indexSearcher = engine.getIndexSearcher()
|
val indexSearcher = engine.getIndexSearcher()
|
||||||
if (!indexSearcher.isMetadataLoaded(indexPath)) {
|
if (!indexSearcher.isMetadataLoaded(resolvedIndexPath)) {
|
||||||
engine.preloadIndexMetadata(indexPath)
|
engine.preloadIndexMetadata(resolvedIndexPath)
|
||||||
}
|
}
|
||||||
yield()
|
yield()
|
||||||
} catch (_: Exception) { /* Ignore preload failure */ }
|
} catch (_: Exception) { /* Ignore preload failure */ }
|
||||||
@@ -507,13 +572,22 @@ class LocalDictionaryRepository(
|
|||||||
|
|
||||||
if (count < 0) return null
|
if (count < 0) return null
|
||||||
|
|
||||||
|
var articleCount = count
|
||||||
|
if (version >= 6) {
|
||||||
|
if (indexFile.length() < INDEX_V6_HEADER_BYTES) return null
|
||||||
|
articleCount = dataInput.readInt()
|
||||||
|
if (articleCount < 0) return null
|
||||||
|
}
|
||||||
|
|
||||||
if (version >= 2) {
|
if (version >= 2) {
|
||||||
if (indexFile.length() < INDEX_V2_HEADER_BYTES) return null
|
val fixedHeaderBytes =
|
||||||
|
if (version >= 6) INDEX_V6_HEADER_BYTES else INDEX_V2_HEADER_BYTES
|
||||||
|
if (indexFile.length() < fixedHeaderBytes) return null
|
||||||
|
|
||||||
val sparseCount = dataInput.readInt()
|
val sparseCount = dataInput.readInt()
|
||||||
if (sparseCount < 0) return null
|
if (sparseCount < 0) return null
|
||||||
|
|
||||||
var headerBytes = INDEX_V2_HEADER_BYTES
|
var headerBytes = fixedHeaderBytes
|
||||||
repeat(sparseCount) {
|
repeat(sparseCount) {
|
||||||
dataInput.readInt()
|
dataInput.readInt()
|
||||||
dataInput.readLong()
|
dataInput.readLong()
|
||||||
@@ -524,7 +598,7 @@ class LocalDictionaryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IndexHeader(version, count)
|
IndexHeader(version, articleCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -602,6 +676,7 @@ class LocalDictionaryRepository(
|
|||||||
const val MIN_PROGRESS_EMIT_INTERVAL_MS = 200L
|
const val MIN_PROGRESS_EMIT_INTERVAL_MS = 200L
|
||||||
const val LEGACY_INDEX_HEADER_BYTES = 8L
|
const val LEGACY_INDEX_HEADER_BYTES = 8L
|
||||||
const val INDEX_V2_HEADER_BYTES = 12
|
const val INDEX_V2_HEADER_BYTES = 12
|
||||||
|
const val INDEX_V6_HEADER_BYTES = 16
|
||||||
const val SPARSE_ENTRY_FIXED_BYTES = 14
|
const val SPARSE_ENTRY_FIXED_BYTES = 14
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
class DownloadManager(
|
class DownloadManager(
|
||||||
private val dictionaryRepository: DictionaryRepository,
|
private val dictionaryRepository: DictionaryRepository,
|
||||||
private val unknownErrorMessage: String,
|
private val unknownErrorMessage: String,
|
||||||
|
private val sourceUnavailableMessage: String,
|
||||||
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
externalScope: CoroutineScope? = null
|
externalScope: CoroutineScope? = null
|
||||||
) {
|
) {
|
||||||
@@ -24,10 +25,11 @@ class DownloadManager(
|
|||||||
mutableDownloadProgressState.asStateFlow()
|
mutableDownloadProgressState.asStateFlow()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private var downloadJob: Job? = null
|
private var downloadJob: Job? = null
|
||||||
|
private var cancelCleanupJob: Job? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DownloadManager"
|
private const val TAG = "DownloadManager"
|
||||||
private const val DOWNLOAD_PROGRESS_STEP_PERCENT = 3
|
private const val DOWNLOAD_PROGRESS_STEP_PERCENT = 1
|
||||||
private const val EXTRACT_PROGRESS_STEP_PERCENT = 5
|
private const val EXTRACT_PROGRESS_STEP_PERCENT = 5
|
||||||
private val TERMINAL_STATE_DURATION = 2.seconds
|
private val TERMINAL_STATE_DURATION = 2.seconds
|
||||||
}
|
}
|
||||||
@@ -39,30 +41,51 @@ class DownloadManager(
|
|||||||
val clampedProgress = progress.coerceIn(0f, 1f)
|
val clampedProgress = progress.coerceIn(0f, 1f)
|
||||||
mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress)
|
mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress)
|
||||||
mutableDownloadState.value = state
|
mutableDownloadState.value = state
|
||||||
|
if (state is DownloadState.Success) {
|
||||||
|
onDownloadSuccess?.invoke()
|
||||||
|
} else if (state is DownloadState.Error || state is DownloadState.Cancelled) {
|
||||||
|
onTerminal?.invoke(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var onFlowStarted: (() -> Unit)? = null
|
||||||
|
var onDownloadSuccess: (() -> Unit)? = null
|
||||||
|
var onTerminal: ((DownloadState) -> Unit)? = null
|
||||||
|
|
||||||
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()
|
||||||
|
cancelCleanupJob = null
|
||||||
downloadJob = downloadScope.launch {
|
downloadJob = downloadScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
try {
|
try {
|
||||||
updateDownloadProgressState(DownloadState.Loading, 0f)
|
val enabledSources = try {
|
||||||
|
dictionaryRepository.getEnabledSources()
|
||||||
val hasEnabledSources = try {
|
|
||||||
dictionaryRepository.hasEnabledSources()
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasEnabledSources) {
|
if (enabledSources.isEmpty()) {
|
||||||
dictionaryRepository.performAllCleanup()
|
dictionaryRepository.performAllCleanup()
|
||||||
updateDownloadProgressState(DownloadState.Success, 1f)
|
updateDownloadProgressState(DownloadState.Idle, 0f)
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val downloadableSources =
|
||||||
|
dictionaryRepository.filterServerAvailableSources(enabledSources)
|
||||||
|
if (downloadableSources.isEmpty()) {
|
||||||
|
updateDownloadProgressState(
|
||||||
|
DownloadState.Error(sourceUnavailableMessage), 0f
|
||||||
|
)
|
||||||
|
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.downloadDictionaries { progress ->
|
dictionaryRepository.downloadSources(downloadableSources) { progress ->
|
||||||
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
|
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
|
||||||
if (progressBucket != lastReportedProgressBucket) {
|
if (progressBucket != lastReportedProgressBucket) {
|
||||||
lastReportedProgressBucket = progressBucket
|
lastReportedProgressBucket = progressBucket
|
||||||
@@ -94,7 +117,7 @@ class DownloadManager(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException) {
|
||||||
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
||||||
downloadScope.launch {
|
cancelCleanupJob = downloadScope.launch {
|
||||||
try {
|
try {
|
||||||
dictionaryRepository.deleteUnprocessedFiles()
|
dictionaryRepository.deleteUnprocessedFiles()
|
||||||
} catch (cleanupException: Exception) {
|
} catch (cleanupException: Exception) {
|
||||||
@@ -124,19 +147,41 @@ class DownloadManager(
|
|||||||
fun startDownloadForSources(sourceUrls: List<String>) {
|
fun startDownloadForSources(sourceUrls: List<String>) {
|
||||||
if (downloadState.value is DownloadState.Loading) return
|
if (downloadState.value is DownloadState.Loading) return
|
||||||
if (!downloadScope.isActive) return
|
if (!downloadScope.isActive) return
|
||||||
|
onFlowStarted?.invoke()
|
||||||
|
cancelCleanupJob = null
|
||||||
downloadJob = downloadScope.launch {
|
downloadJob = downloadScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
try {
|
try {
|
||||||
updateDownloadProgressState(DownloadState.Loading, 0f)
|
|
||||||
|
|
||||||
if (sourceUrls.isEmpty()) {
|
if (sourceUrls.isEmpty()) {
|
||||||
updateDownloadProgressState(DownloadState.Success, 1f)
|
updateDownloadProgressState(DownloadState.Idle, 0f)
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sources = try {
|
||||||
|
dictionaryRepository.getEnabledSources(sourceUrls)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
updateDownloadProgressState(DownloadState.Idle, 0f)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadableSources =
|
||||||
|
dictionaryRepository.filterServerAvailableSources(sources)
|
||||||
|
if (downloadableSources.isEmpty()) {
|
||||||
|
updateDownloadProgressState(
|
||||||
|
DownloadState.Error(sourceUnavailableMessage), 0f
|
||||||
|
)
|
||||||
|
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.downloadSpecificSources(sourceUrls) { progress ->
|
dictionaryRepository.downloadSources(downloadableSources) { progress ->
|
||||||
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
|
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
|
||||||
if (progressBucket != lastReportedProgressBucket) {
|
if (progressBucket != lastReportedProgressBucket) {
|
||||||
lastReportedProgressBucket = progressBucket
|
lastReportedProgressBucket = progressBucket
|
||||||
@@ -168,7 +213,7 @@ class DownloadManager(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException) {
|
||||||
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
||||||
downloadScope.launch {
|
cancelCleanupJob = downloadScope.launch {
|
||||||
try {
|
try {
|
||||||
dictionaryRepository.deleteUnprocessedFiles()
|
dictionaryRepository.deleteUnprocessedFiles()
|
||||||
} catch (cleanupException: Exception) {
|
} catch (cleanupException: Exception) {
|
||||||
@@ -195,10 +240,12 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun cancelDownload() {
|
fun cancelDownload() {
|
||||||
downloadJob?.cancel()
|
val jobToCancel = downloadJob
|
||||||
|
jobToCancel?.cancel()
|
||||||
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
updateDownloadProgressState(DownloadState.Cancelled, 0f)
|
||||||
downloadScope.launch {
|
downloadScope.launch {
|
||||||
delay(TERMINAL_STATE_DURATION)
|
jobToCancel?.join()
|
||||||
|
cancelCleanupJob?.join()
|
||||||
if (downloadState.value == DownloadState.Cancelled) {
|
if (downloadState.value == DownloadState.Cancelled) {
|
||||||
updateDownloadProgressState(DownloadState.Idle, 0f)
|
updateDownloadProgressState(DownloadState.Idle, 0f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
package com.example.research.feature.download.model
|
package com.example.research.feature.download.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed class DownloadState {
|
sealed class DownloadState {
|
||||||
data object Idle : DownloadState()
|
data object Idle : DownloadState()
|
||||||
data object Loading : DownloadState()
|
data object Loading : DownloadState()
|
||||||
@@ -7,3 +11,6 @@ sealed class DownloadState {
|
|||||||
data class Error(val message: String) : DownloadState()
|
data class Error(val message: String) : DownloadState()
|
||||||
data object Cancelled : DownloadState()
|
data object Cancelled : DownloadState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val DownloadState.isActive: Boolean
|
||||||
|
get() = this is DownloadState.Loading || this is DownloadState.Extracting || this is DownloadState.Success
|
||||||
+57
-8
@@ -7,11 +7,18 @@ import com.example.research.common.util.FileStorageManager
|
|||||||
import com.example.research.common.util.DocumentFileInfo
|
import com.example.research.common.util.DocumentFileInfo
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
import com.example.research.feature.download.config.DictionaryConfig
|
import com.example.research.feature.download.config.DictionaryConfig
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
|
||||||
|
enum class SourceAvailability {
|
||||||
|
AVAILABLE,
|
||||||
|
NOT_FOUND,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
class DictionaryChecker(
|
class DictionaryChecker(
|
||||||
private val fileStorageManager: FileStorageManager,
|
private val fileStorageManager: FileStorageManager,
|
||||||
private val client: OkHttpClient
|
private val client: OkHttpClient
|
||||||
@@ -58,7 +65,14 @@ class DictionaryChecker(
|
|||||||
val previousDate = DateUtils.getPreviousDateString()
|
val previousDate = DateUtils.getPreviousDateString()
|
||||||
val dictFiles = getDictFiles(folderUri, files)
|
val dictFiles = getDictFiles(folderUri, files)
|
||||||
|
|
||||||
val allHaveToday = enabledSources.all { source ->
|
val datedSources = enabledSources.filter { source ->
|
||||||
|
DictionarySource.hasDatePlaceholder(source.urlTemplate)
|
||||||
|
}
|
||||||
|
if (datedSources.isEmpty()) {
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
val allHaveToday = datedSources.all { source ->
|
||||||
dictFiles.hasFileForSource(source, currentDate)
|
dictFiles.hasFileForSource(source, currentDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,26 +80,56 @@ class DictionaryChecker(
|
|||||||
return@withContext true
|
return@withContext true
|
||||||
}
|
}
|
||||||
|
|
||||||
val allHaveYesterday = enabledSources.all { source ->
|
val allHaveYesterday = datedSources.all { source ->
|
||||||
dictFiles.hasFileForSource(source, previousDate)
|
dictFiles.hasFileForSource(source, previousDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allHaveYesterday) {
|
if (allHaveYesterday) {
|
||||||
val serverHasToday = enabledSources.any { source ->
|
val serverHasToday = datedSources.any { source ->
|
||||||
isSourceAvailableOnServer(source, currentDate)
|
isSourceAvailableOnServer(source, currentDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
return@withContext !serverHasToday
|
return@withContext !serverHasToday
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
val serverCanOfferNewer = datedSources.any { source ->
|
||||||
|
when (probeSource(source, currentDate)) {
|
||||||
|
SourceAvailability.AVAILABLE -> true
|
||||||
|
SourceAvailability.NOT_FOUND ->
|
||||||
|
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
|
||||||
|
SourceAvailability.UNKNOWN -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext !serverCanOfferNewer
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
||||||
false
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean {
|
suspend fun checkSourceAvailability(source: DictionarySource): SourceAvailability =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val currentDate = DateUtils.getCurrentDateString()
|
||||||
|
when (probeSource(source, currentDate)) {
|
||||||
|
SourceAvailability.AVAILABLE -> SourceAvailability.AVAILABLE
|
||||||
|
SourceAvailability.UNKNOWN -> SourceAvailability.UNKNOWN
|
||||||
|
SourceAvailability.NOT_FOUND -> {
|
||||||
|
if (DictionarySource.hasDatePlaceholder(source.urlTemplate)) {
|
||||||
|
probeSource(source, DateUtils.getPreviousDateString())
|
||||||
|
} else {
|
||||||
|
SourceAvailability.NOT_FOUND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean =
|
||||||
|
probeSource(source, date) == SourceAvailability.AVAILABLE
|
||||||
|
|
||||||
|
private fun probeSource(source: DictionarySource, date: String): SourceAvailability {
|
||||||
return try {
|
return try {
|
||||||
val url = DictionarySource.buildUrl(source.urlTemplate, date)
|
val url = DictionarySource.buildUrl(source.urlTemplate, date)
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -94,12 +138,17 @@ class DictionaryChecker(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
response.isSuccessful &&
|
if (response.isSuccessful &&
|
||||||
response.header("Content-Type")?.startsWith("text/html") != true
|
response.header("Content-Type")?.startsWith("text/html") != true
|
||||||
|
) {
|
||||||
|
SourceAvailability.AVAILABLE
|
||||||
|
} else {
|
||||||
|
SourceAvailability.NOT_FOUND
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
|
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
|
||||||
false
|
SourceAvailability.UNKNOWN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -54,17 +54,17 @@ class DictionaryCleaner(
|
|||||||
for ((prefix, items) in parsedByPrefix) {
|
for ((prefix, items) in parsedByPrefix) {
|
||||||
val latestDsl = latestDslDateByPrefix[prefix] ?: continue
|
val latestDsl = latestDslDateByPrefix[prefix] ?: continue
|
||||||
val dslDates = dslDatesByPrefix[prefix].orEmpty()
|
val dslDates = dslDatesByPrefix[prefix].orEmpty()
|
||||||
for (item in items) {
|
for ((name, _, date, ext) in items) {
|
||||||
if (DateUtils.isDateBefore(item.date, latestDsl)) {
|
if (DateUtils.isDateBefore(date, latestDsl)) {
|
||||||
toDelete.add(item.name)
|
toDelete.add(name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ((item.ext == "idx" || item.ext == "dsl.idx") && item.date != latestDsl) {
|
if (ext in indexExtensions && date != latestDsl) {
|
||||||
toDelete.add(item.name)
|
toDelete.add(name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ((item.ext == "idx" || item.ext == "dsl.idx") && item.date !in dslDates) {
|
if (ext in indexExtensions && date !in dslDates) {
|
||||||
toDelete.add(item.name)
|
toDelete.add(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,10 +76,13 @@ class DictionaryCleaner(
|
|||||||
val date: String,
|
val date: String,
|
||||||
val ext: String,
|
val ext: String,
|
||||||
)
|
)
|
||||||
private val validExtensions = setOf("dsl", "dsl.dz", "dsl.gz", "gz", "idx", "dsl.idx")
|
private val indexExtensions = setOf("idx", "dsl.idx", "idx.fold", "dsl.idx.fold")
|
||||||
|
private val validExtensions = setOf("dsl", "dsl.dz", "dsl.gz", "gz") + indexExtensions
|
||||||
|
|
||||||
private fun parseManagedName(name: String, prefixes: Set<String>): ManagedFileName? {
|
private fun parseManagedName(name: String, prefixes: Set<String>): ManagedFileName? {
|
||||||
val ext = when {
|
val ext = when {
|
||||||
|
name.endsWith(".dsl.idx.fold") -> "dsl.idx.fold"
|
||||||
|
name.endsWith(".idx.fold") -> "idx.fold"
|
||||||
name.endsWith(".dsl.dz") -> "dsl.dz"
|
name.endsWith(".dsl.dz") -> "dsl.dz"
|
||||||
name.endsWith(".dsl.gz") -> "dsl.gz"
|
name.endsWith(".dsl.gz") -> "dsl.gz"
|
||||||
name.endsWith(".dsl.idx") -> "dsl.idx"
|
name.endsWith(".dsl.idx") -> "dsl.idx"
|
||||||
|
|||||||
+22
-15
@@ -15,8 +15,11 @@ import com.example.research.feature.download.config.DictionaryConfig
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
@@ -58,20 +61,22 @@ class DictionaryDownloader(
|
|||||||
) {
|
) {
|
||||||
if (fileUri == null) return
|
if (fileUri == null) return
|
||||||
|
|
||||||
runCatching {
|
withContext(NonCancellable) {
|
||||||
val fileToDelete = fileStorageManager.getDocumentFile(fileUri)
|
|
||||||
if (fileToDelete != null && fileToDelete.exists()) {
|
|
||||||
val deleted = fileStorageManager.forceDeleteFile(fileToDelete, fileName)
|
|
||||||
if (!deleted) {
|
|
||||||
fileStorageManager.forceDeleteFileByName(folderUri, fileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.e(TAG, "Failed to delete partial file: $fileUri", e)
|
|
||||||
runCatching {
|
runCatching {
|
||||||
fileStorageManager.forceDeleteFileByName(folderUri, fileName)
|
val fileToDelete = fileStorageManager.getDocumentFile(fileUri)
|
||||||
}.onFailure { deleteError ->
|
if (fileToDelete != null && fileToDelete.exists()) {
|
||||||
Log.e(TAG, "Failed to delete file by name: $fileName", deleteError)
|
val deleted = fileStorageManager.forceDeleteFile(fileToDelete, fileName)
|
||||||
|
if (!deleted) {
|
||||||
|
fileStorageManager.forceDeleteFileByName(folderUri, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Failed to delete partial file: $fileUri", e)
|
||||||
|
runCatching {
|
||||||
|
fileStorageManager.forceDeleteFileByName(folderUri, fileName)
|
||||||
|
}.onFailure { deleteError ->
|
||||||
|
Log.e(TAG, "Failed to delete file by name: $fileName", deleteError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +174,7 @@ class DictionaryDownloader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun deletePartialFileIfExists(folderUri: Uri, fileName: String) {
|
private suspend fun deletePartialFileIfExists(folderUri: Uri, fileName: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO + NonCancellable) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val existingFile = fileStorageManager.findFile(folderUri, fileName)
|
val existingFile = fileStorageManager.findFile(folderUri, fileName)
|
||||||
if (existingFile != null && existingFile.exists()) {
|
if (existingFile != null && existingFile.exists()) {
|
||||||
@@ -313,7 +318,7 @@ class DictionaryDownloader(
|
|||||||
call?.cancel()
|
call?.cancel()
|
||||||
response?.close()
|
response?.close()
|
||||||
outputStream?.close()
|
outputStream?.close()
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException || !currentCoroutineContext().isActive) {
|
||||||
deletePartialFileIfExists(folderUri, fileName)
|
deletePartialFileIfExists(folderUri, fileName)
|
||||||
deletePartialFileSafely(fileUri, folderUri, fileName)
|
deletePartialFileSafely(fileUri, folderUri, fileName)
|
||||||
throw e
|
throw e
|
||||||
@@ -343,6 +348,8 @@ class DictionaryDownloader(
|
|||||||
context.getString(R.string.download_connection_reset)
|
context.getString(R.string.download_connection_reset)
|
||||||
e is SecurityException ->
|
e is SecurityException ->
|
||||||
context.getString(R.string.download_no_write_permission)
|
context.getString(R.string.download_no_write_permission)
|
||||||
|
e.isNetworkError() ->
|
||||||
|
context.getString(R.string.download_network_failed)
|
||||||
else ->
|
else ->
|
||||||
context.getString(R.string.download_error)
|
context.getString(R.string.download_error)
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-16
@@ -9,7 +9,9 @@ import com.example.research.common.util.FileStorageManager
|
|||||||
import com.example.research.data.dictzip.DictZipRandomAccessFile
|
import com.example.research.data.dictzip.DictZipRandomAccessFile
|
||||||
import com.example.research.data.dictzip.DictZipWriter
|
import com.example.research.data.dictzip.DictZipWriter
|
||||||
import com.example.research.feature.download.config.DictionaryConfig
|
import com.example.research.feature.download.config.DictionaryConfig
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class DictionaryExtractor(
|
class DictionaryExtractor(
|
||||||
@@ -44,14 +46,13 @@ class DictionaryExtractor(
|
|||||||
reportProgress(processedIndex.toFloat() / totalFiles)
|
reportProgress(processedIndex.toFloat() / totalFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (file in gzFiles) {
|
for ((fileName, uri) in gzFiles) {
|
||||||
val fileName = file.name
|
|
||||||
val baseName = fileName.dropLast(DictionaryConfig.GZ_EXTENSION.length)
|
val baseName = fileName.dropLast(DictionaryConfig.GZ_EXTENSION.length)
|
||||||
val lowerBaseName = baseName.lowercase()
|
val lowerBaseName = baseName.lowercase()
|
||||||
if (baseName.isBlank() ||
|
if (baseName.isBlank() ||
|
||||||
lowerBaseName.endsWith(DictionaryConfig.GZ_EXTENSION) ||
|
lowerBaseName.endsWith(DictionaryConfig.GZ_EXTENSION) ||
|
||||||
lowerBaseName.endsWith(".txt")) {
|
lowerBaseName.endsWith(".txt")) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -63,19 +64,19 @@ class DictionaryExtractor(
|
|||||||
val dslFileName = "$dslBaseName${DictionaryConfig.DSL_EXTENSION}"
|
val dslFileName = "$dslBaseName${DictionaryConfig.DSL_EXTENSION}"
|
||||||
val existingDslFile = fileStorageManager.findFile(folderUri, dslFileName)
|
val existingDslFile = fileStorageManager.findFile(folderUri, dslFileName)
|
||||||
if (existingDslFile != null && existingDslFile.exists()) {
|
if (existingDslFile != null && existingDslFile.exists()) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val dslUri = fileStorageManager.createFileInFolder(folderUri, dslFileName, overwrite = false)
|
val dslUri = fileStorageManager.createFileInFolder(folderUri, dslFileName, overwrite = false)
|
||||||
?: continue
|
?: continue
|
||||||
val extractResult = ArchiveUtils.extractGzWithBom(context, file.uri, dslUri)
|
val extractResult = ArchiveUtils.extractGzWithBom(context, uri, dslUri)
|
||||||
if (extractResult.isSuccess) {
|
if (extractResult.isSuccess) {
|
||||||
val extractedFile = fileStorageManager.getDocumentFile(dslUri)
|
val extractedFile = fileStorageManager.getDocumentFile(dslUri)
|
||||||
if (extractedFile != null && extractedFile.exists()) {
|
if (extractedFile != null && extractedFile.exists()) {
|
||||||
if (extractedFile.length() == 0L) {
|
if (extractedFile.length() == 0L) {
|
||||||
ArchiveUtils.deleteFile(context, dslUri)
|
ArchiveUtils.deleteFile(context, dslUri)
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,7 @@ class DictionaryExtractor(
|
|||||||
ArchiveUtils.deleteFile(context, dslUri)
|
ArchiveUtils.deleteFile(context, dslUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
} else {
|
} else {
|
||||||
Log.e("DictionaryExtractor", "File extraction error: $fileName")
|
Log.e("DictionaryExtractor", "File extraction error: $fileName")
|
||||||
ArchiveUtils.deleteFile(context, dslUri)
|
ArchiveUtils.deleteFile(context, dslUri)
|
||||||
@@ -98,12 +99,11 @@ class DictionaryExtractor(
|
|||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
}
|
}
|
||||||
|
|
||||||
for (file in rawDslFiles) {
|
for ((dslFileName, uri) in rawDslFiles) {
|
||||||
val dslFileName = file.name
|
|
||||||
val dzFileName = "$dslFileName.dz"
|
val dzFileName = "$dslFileName.dz"
|
||||||
val existingDz = fileStorageManager.findFile(folderUri, dzFileName)
|
val existingDz = fileStorageManager.findFile(folderUri, dzFileName)
|
||||||
if (existingDz != null && existingDz.exists()) {
|
if (existingDz != null && existingDz.exists()) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -111,11 +111,11 @@ class DictionaryExtractor(
|
|||||||
val fileBaseProgress = processedIndex.toFloat() / totalFiles
|
val fileBaseProgress = processedIndex.toFloat() / totalFiles
|
||||||
val fileProgressWeight = 1f / totalFiles
|
val fileProgressWeight = 1f / totalFiles
|
||||||
reportProgress(fileBaseProgress)
|
reportProgress(fileBaseProgress)
|
||||||
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri) { compressProgress ->
|
val compressResult = ensureDslDictZip(uri, dslFileName, folderUri) { compressProgress ->
|
||||||
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
|
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
|
||||||
}
|
}
|
||||||
if (compressResult.isSuccess) {
|
if (compressResult.isSuccess) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
}
|
}
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
}
|
}
|
||||||
@@ -128,21 +128,48 @@ class DictionaryExtractor(
|
|||||||
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
|
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
|
||||||
!lowerName.endsWith(".dsl.gz")
|
!lowerName.endsWith(".dsl.gz")
|
||||||
}
|
}
|
||||||
for (file in remainingRawDslFiles) {
|
for ((dslFileName, uri) in remainingRawDslFiles) {
|
||||||
val dslFileName = file.name
|
val compressResult = ensureDslDictZip(uri, dslFileName, folderUri, onProgress = null)
|
||||||
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri, onProgress = null)
|
|
||||||
if (compressResult.isSuccess) {
|
if (compressResult.isSuccess) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress?.invoke(1f)
|
onProgress?.invoke(1f)
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
deleteOrphanedFiles(folderUri)
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteOrphanedFiles(folderUri: Uri) {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO + NonCancellable) {
|
||||||
|
val files = fileStorageManager.listFilesInFolder(folderUri)
|
||||||
|
for ((name, uri) in files) {
|
||||||
|
val n = name.lowercase()
|
||||||
|
val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) &&
|
||||||
|
!n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
|
||||||
|
!n.endsWith(".dsl.gz")
|
||||||
|
val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION)
|
||||||
|
when {
|
||||||
|
isBareDz -> ArchiveUtils.deleteFile(context, uri)
|
||||||
|
isRawDsl -> {
|
||||||
|
val pair = fileStorageManager.findFile(folderUri, "$name.dz")
|
||||||
|
if (pair == null || !pair.exists()) {
|
||||||
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ensureDslDictZip(
|
private suspend fun ensureDslDictZip(
|
||||||
dslUri: Uri,
|
dslUri: Uri,
|
||||||
dslFileName: String,
|
dslFileName: String,
|
||||||
|
|||||||
+44
-59
@@ -8,6 +8,8 @@ import com.example.research.core.domain.model.DictionarySource
|
|||||||
import com.example.research.data.local.preferences.PreferencesManager
|
import com.example.research.data.local.preferences.PreferencesManager
|
||||||
import com.example.research.common.util.FileStorageManager
|
import com.example.research.common.util.FileStorageManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -40,74 +42,58 @@ class DictionaryRepository(
|
|||||||
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
|
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun hasEnabledSources(): Boolean = withContext(Dispatchers.IO) {
|
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
|
||||||
preferencesManager.dictionarySources.first().any { it.isEnabled }
|
withContext(Dispatchers.IO) {
|
||||||
}
|
val enabledSources = preferencesManager.dictionarySources.first()
|
||||||
|
.filter { it.isEnabled }
|
||||||
suspend fun downloadDictionaries(
|
if (sourceUrls == null) {
|
||||||
onProgress: (Float) -> Unit
|
return@withContext enabledSources
|
||||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
}
|
||||||
val folderUri = preferencesManager.dictionaryPath
|
val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
|
||||||
val uri = Uri.fromFile(File(folderUri))
|
enabledSources.mapNotNull { source ->
|
||||||
val files = fileStorageManager.listFilesInFolder(uri)
|
if (DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
|
||||||
cleaner.deleteCorruptedArchives(uri, files)
|
null
|
||||||
|
} else {
|
||||||
synchronized(filesBeforeDownload) {
|
source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
|
||||||
filesBeforeDownload = files.map { it.name }.toMutableSet()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val sources = preferencesManager.dictionarySources.first()
|
|
||||||
val enabledSources = sources.filter { it.isEnabled }
|
|
||||||
|
|
||||||
synchronized(downloadedPrefixes) {
|
|
||||||
downloadedPrefixes = enabledSources.mapNotNull {
|
|
||||||
DictionarySource.extractPrefix(it.urlTemplate)
|
|
||||||
}.toMutableSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
downloader.downloadDictionaries(
|
|
||||||
folderUri = uri,
|
|
||||||
sources = enabledSources,
|
|
||||||
onProgress = onProgress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadSpecificSources(
|
|
||||||
sourceUrls: List<String>,
|
|
||||||
onProgress: (Float) -> Unit
|
|
||||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
|
||||||
val folderUri = preferencesManager.dictionaryPath
|
|
||||||
val uri = Uri.fromFile(File(folderUri))
|
|
||||||
val files = fileStorageManager.listFilesInFolder(uri)
|
|
||||||
cleaner.deleteCorruptedArchives(uri, files)
|
|
||||||
|
|
||||||
synchronized(filesBeforeDownload) {
|
|
||||||
filesBeforeDownload = files.map { it.name }.toMutableSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
val allSources = preferencesManager.dictionarySources.first()
|
|
||||||
val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
|
|
||||||
val sourcesToDownload = allSources.mapNotNull { source ->
|
|
||||||
if (!source.isEnabled || DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourcesToDownload.isEmpty()) {
|
suspend fun filterServerAvailableSources(
|
||||||
return@withContext Result.success(Unit)
|
sources: List<DictionarySource>
|
||||||
|
): List<DictionarySource> = withContext(Dispatchers.IO) {
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
return@withContext emptyList()
|
||||||
|
}
|
||||||
|
sources
|
||||||
|
.map { source -> async { source to checker.checkSourceAvailability(source) } }
|
||||||
|
.awaitAll()
|
||||||
|
.filter { (_, availability) -> availability != SourceAvailability.NOT_FOUND }
|
||||||
|
.map { (source, _) -> source }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadSources(
|
||||||
|
sources: List<DictionarySource>,
|
||||||
|
onProgress: (Float) -> Unit
|
||||||
|
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
val folderUri = preferencesManager.dictionaryPath
|
||||||
|
val uri = Uri.fromFile(File(folderUri))
|
||||||
|
val files = fileStorageManager.listFilesInFolder(uri)
|
||||||
|
cleaner.deleteCorruptedArchives(uri, files)
|
||||||
|
|
||||||
|
synchronized(filesBeforeDownload) {
|
||||||
|
filesBeforeDownload = files.map { it.name }.toMutableSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized(downloadedPrefixes) {
|
synchronized(downloadedPrefixes) {
|
||||||
downloadedPrefixes = sourcesToDownload.mapNotNull {
|
downloadedPrefixes = sources.mapNotNull {
|
||||||
DictionarySource.extractPrefix(it.urlTemplate)
|
DictionarySource.extractPrefix(it.urlTemplate)
|
||||||
}.toMutableSet()
|
}.toMutableSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
downloader.downloadDictionaries(
|
downloader.downloadDictionaries(
|
||||||
folderUri = uri,
|
folderUri = uri,
|
||||||
sources = sourcesToDownload,
|
sources = sources,
|
||||||
onProgress = onProgress
|
onProgress = onProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -158,9 +144,8 @@ class DictionaryRepository(
|
|||||||
if (filesToDelete.isNotEmpty()) {
|
if (filesToDelete.isNotEmpty()) {
|
||||||
var deletedCount = 0
|
var deletedCount = 0
|
||||||
|
|
||||||
for (file in filesToDelete) {
|
for ((fileName, uri) in filesToDelete) {
|
||||||
val fileName = file.name
|
val docFile = fileStorageManager.getDocumentFile(uri)
|
||||||
val docFile = fileStorageManager.getDocumentFile(file.uri)
|
|
||||||
if (docFile != null && docFile.exists()) {
|
if (docFile != null && docFile.exists()) {
|
||||||
val deleted = docFile.delete()
|
val deleted = docFile.delete()
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
|
|||||||
+31
-181
@@ -3,19 +3,19 @@ package com.example.research.feature.download.service
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import com.example.research.ReSearchApplication
|
import com.example.research.ReSearchApplication
|
||||||
import com.example.research.common.progress.renderTitle
|
import com.example.research.common.progress.DictionaryPipelineCoordinator
|
||||||
|
import com.example.research.common.progress.DictionaryProgressPresenter
|
||||||
import com.example.research.common.util.NotificationHelper
|
import com.example.research.common.util.NotificationHelper
|
||||||
import com.example.research.core.util.OperationResult
|
|
||||||
import com.example.research.data.repository.LocalDictionaryRepository
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.feature.download.DownloadManager
|
import com.example.research.feature.download.DownloadManager
|
||||||
import com.example.research.feature.download.model.DownloadState
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlinx.coroutines.cancel
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlinx.coroutines.cancelChildren
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
class DictionaryForegroundService : Service() {
|
class DictionaryForegroundService : Service() {
|
||||||
|
|
||||||
@@ -23,21 +23,21 @@ class DictionaryForegroundService : Service() {
|
|||||||
const val ACTION_START = "com.example.research.START_DOWNLOAD"
|
const val ACTION_START = "com.example.research.START_DOWNLOAD"
|
||||||
const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD"
|
const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD"
|
||||||
const val ACTION_IMPORT = "com.example.research.START_IMPORT"
|
const val ACTION_IMPORT = "com.example.research.START_IMPORT"
|
||||||
|
const val ACTION_FINISH = "com.example.research.FINISH_PIPELINE"
|
||||||
private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID
|
private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var downloadManager: DownloadManager
|
private lateinit var downloadManager: DownloadManager
|
||||||
private lateinit var localDictionaryRepository: LocalDictionaryRepository
|
private lateinit var localDictionaryRepository: LocalDictionaryRepository
|
||||||
private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager
|
private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager
|
||||||
private lateinit var progressStateHolder: com.example.research.common.progress.DictionaryProgressStateHolder
|
private lateinit var pipelineCoordinator: DictionaryPipelineCoordinator
|
||||||
|
private lateinit var presenter: DictionaryProgressPresenter
|
||||||
private val notificationHelper by lazy {
|
private val notificationHelper by lazy {
|
||||||
NotificationHelper(this)
|
NotificationHelper(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
@Volatile
|
@Volatile
|
||||||
private var isHandlingSuccess = false
|
|
||||||
@Volatile
|
|
||||||
private var latestStartId = 0
|
private var latestStartId = 0
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -47,164 +47,8 @@ class DictionaryForegroundService : Service() {
|
|||||||
downloadManager = app.downloadManager
|
downloadManager = app.downloadManager
|
||||||
localDictionaryRepository = app.localDictionaryRepository
|
localDictionaryRepository = app.localDictionaryRepository
|
||||||
dictionaryImportManager = app.dictionaryImportManager
|
dictionaryImportManager = app.dictionaryImportManager
|
||||||
progressStateHolder = app.dictionaryProgressStateHolder
|
pipelineCoordinator = app.dictionaryPipelineCoordinator
|
||||||
observeDownloadState()
|
presenter = app.dictionaryProgressPresenter
|
||||||
observeImportState()
|
|
||||||
observeUnifiedProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeDownloadState() {
|
|
||||||
serviceScope.launch {
|
|
||||||
downloadManager.downloadState.collect { state ->
|
|
||||||
when (state) {
|
|
||||||
is DownloadState.Success -> handleSuccess()
|
|
||||||
is DownloadState.Error -> {
|
|
||||||
notificationHelper.showErrorNotification()
|
|
||||||
stopForegroundService(removeNotification = false)
|
|
||||||
}
|
|
||||||
is DownloadState.Cancelled -> {
|
|
||||||
notificationHelper.cancelNotification()
|
|
||||||
stopForegroundService()
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeImportState() {
|
|
||||||
serviceScope.launch {
|
|
||||||
dictionaryImportManager.importState.collect { state ->
|
|
||||||
when (state) {
|
|
||||||
is com.example.research.ui.settings.ImportState.Success -> handleSuccess()
|
|
||||||
is com.example.research.ui.settings.ImportState.Error -> {
|
|
||||||
notificationHelper.showErrorNotification()
|
|
||||||
stopForegroundService(removeNotification = false)
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeUnifiedProgress() {
|
|
||||||
serviceScope.launch {
|
|
||||||
progressStateHolder.progressSnapshot
|
|
||||||
.collect { snapshot ->
|
|
||||||
if (snapshot != null) {
|
|
||||||
notificationHelper.showUnifiedProgressNotification(
|
|
||||||
title = snapshot.renderTitle(this@DictionaryForegroundService),
|
|
||||||
contentText = "${snapshot.percent}%",
|
|
||||||
progressPercent = snapshot.percent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun triggerReindexing(isImportFlow: Boolean) = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
waitForIndexingCompletion()
|
|
||||||
delay(1.seconds)
|
|
||||||
|
|
||||||
val app = application as? ReSearchApplication
|
|
||||||
?: return@withContext
|
|
||||||
val path = app.preferencesManager.dictionaryPath
|
|
||||||
if (isImportFlow) {
|
|
||||||
dictionaryImportManager.updateExtractionProgress(0f)
|
|
||||||
}
|
|
||||||
app.downloadDictionaryRepository.extractArchives(onProgress = { progress ->
|
|
||||||
if (isImportFlow) {
|
|
||||||
dictionaryImportManager.updateExtractionProgress(progress)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
val result = localDictionaryRepository.scanDirectory(path)
|
|
||||||
if (result is OperationResult.Success && result.data > 0) {
|
|
||||||
localDictionaryRepository.warmupIndexes()
|
|
||||||
app.downloadDictionaryRepository.performAllCleanup()
|
|
||||||
}
|
|
||||||
if (isImportFlow) {
|
|
||||||
dictionaryImportManager.markImportPipelineSuccess()
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("DictionaryForegroundService", "triggerReindexing() failed: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun waitForIndexingCompletion(): Boolean {
|
|
||||||
val isIndexing = localDictionaryRepository.indexingProgress.first().isIndexing
|
|
||||||
|
|
||||||
if (!isIndexing) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
var waitCount = 0
|
|
||||||
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < 100) {
|
|
||||||
delay(200.milliseconds)
|
|
||||||
waitCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
return waitCount < 100
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSuccess() {
|
|
||||||
if (isHandlingSuccess) return
|
|
||||||
isHandlingSuccess = true
|
|
||||||
serviceScope.launch {
|
|
||||||
try {
|
|
||||||
val isImportFlow = dictionaryImportManager.importState.value !is com.example.research.ui.settings.ImportState.Idle
|
|
||||||
|
|
||||||
if (isImportFlow) {
|
|
||||||
val app = application as? ReSearchApplication
|
|
||||||
val path = app?.preferencesManager?.dictionaryPath
|
|
||||||
val dictionariesDir = path?.let { java.io.File(it) }
|
|
||||||
val filesBeforeReindex = dictionariesDir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
|
|
||||||
|
|
||||||
try {
|
|
||||||
triggerReindexing(true)
|
|
||||||
dictionaryImportManager.getAndClearImportedFiles()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
val filesToCleanup = dictionaryImportManager.getAndClearImportedFiles()
|
|
||||||
filesToCleanup.forEach { file ->
|
|
||||||
try { if (file.exists()) file.delete() } catch (_: Exception) { /* Ignore cleanup failure */ }
|
|
||||||
}
|
|
||||||
if (dictionariesDir != null) {
|
|
||||||
cleanupNewFiles(dictionariesDir, filesBeforeReindex)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
triggerReindexing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isImportFlow) {
|
|
||||||
notificationHelper.showImportSuccessNotification()
|
|
||||||
dictionaryImportManager.clearImportState()
|
|
||||||
} else {
|
|
||||||
notificationHelper.showSuccessNotification()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e !is CancellationException) {
|
|
||||||
Log.e("DownloadForegroundService", "handleSuccess() failed: ${e.message}", e)
|
|
||||||
notificationHelper.showErrorNotification()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isHandlingSuccess = false
|
|
||||||
stopForegroundService(removeNotification = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cleanupNewFiles(dir: java.io.File, filesBeforeSnapshot: Set<String>) {
|
|
||||||
try {
|
|
||||||
dir.listFiles()?.forEach { file ->
|
|
||||||
if (file.name !in filesBeforeSnapshot) {
|
|
||||||
try { file.delete() } catch (_: Exception) { /* Ignore delete failure */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { /* Ignore directory listing failure */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
@@ -244,20 +88,31 @@ class DictionaryForegroundService : Service() {
|
|||||||
stopForTimeout(startId)
|
stopForTimeout(startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
cancelAllWork()
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
stopForTimeout(startId)
|
stopForTimeout(startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopForTimeout(startId: Int) {
|
private fun stopForTimeout(startId: Int) {
|
||||||
|
cancelAllWork()
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelfResult(startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelAllWork() {
|
||||||
serviceScope.coroutineContext[Job]?.cancelChildren()
|
serviceScope.coroutineContext[Job]?.cancelChildren()
|
||||||
|
pipelineCoordinator.cancelPipeline()
|
||||||
if (localDictionaryRepository.isIndexingInProgress()) {
|
if (localDictionaryRepository.isIndexingInProgress()) {
|
||||||
localDictionaryRepository.cancelIndexing()
|
localDictionaryRepository.cancelIndexing()
|
||||||
}
|
}
|
||||||
downloadManager.cancelDownload()
|
downloadManager.cancelDownload()
|
||||||
dictionaryImportManager.cancelImport()
|
dictionaryImportManager.cancelImport()
|
||||||
notificationHelper.cancelNotification()
|
presenter.cancelProgress()
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
|
||||||
stopSelfResult(startId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -275,21 +130,16 @@ class DictionaryForegroundService : Service() {
|
|||||||
ACTION_IMPORT -> {
|
ACTION_IMPORT -> {
|
||||||
startForegroundService()
|
startForegroundService()
|
||||||
}
|
}
|
||||||
|
ACTION_FINISH -> {
|
||||||
|
stopForegroundService(removeNotification = true)
|
||||||
|
}
|
||||||
ACTION_STOP -> {
|
ACTION_STOP -> {
|
||||||
try {
|
try {
|
||||||
startForegroundService()
|
startForegroundService()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Service may already be stopped
|
// Service may already be stopped
|
||||||
}
|
}
|
||||||
serviceScope.coroutineContext[Job]?.cancelChildren()
|
cancelAllWork()
|
||||||
|
|
||||||
if (localDictionaryRepository.isIndexingInProgress()) {
|
|
||||||
localDictionaryRepository.cancelIndexing()
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadManager.cancelDownload()
|
|
||||||
dictionaryImportManager.cancelImport()
|
|
||||||
notificationHelper.cancelNotification()
|
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.example.research.feature.import
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.progress.ImportFlowOperations
|
||||||
import com.example.research.common.util.SafeFileName
|
import com.example.research.common.util.SafeFileName
|
||||||
import com.example.research.core.performance.ReSearchTrace
|
import com.example.research.core.performance.ReSearchTrace
|
||||||
import com.example.research.ui.settings.ImportState
|
import com.example.research.ui.settings.ImportState
|
||||||
@@ -14,16 +15,19 @@ import java.util.Collections
|
|||||||
|
|
||||||
class DictionaryImportManager(
|
class DictionaryImportManager(
|
||||||
private val application: Application
|
private val application: Application
|
||||||
) {
|
) : ImportFlowOperations {
|
||||||
private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle)
|
private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle)
|
||||||
val importState: StateFlow<ImportState> = mutableImportState.asStateFlow()
|
val importState: StateFlow<ImportState> = mutableImportState.asStateFlow()
|
||||||
|
|
||||||
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var importJob: Job? = null
|
private var importJob: Job? = null
|
||||||
private val importedFiles = Collections.synchronizedList(mutableListOf<File>())
|
private val importedFiles = Collections.synchronizedList(mutableListOf<File>())
|
||||||
|
var onFlowStarted: (() -> Unit)? = null
|
||||||
|
var onTerminal: ((ImportState) -> Unit)? = null
|
||||||
|
|
||||||
fun importDictionaries(uris: List<Uri>) {
|
fun importDictionaries(uris: List<Uri>) {
|
||||||
importJob?.cancel()
|
importJob?.cancel()
|
||||||
|
onFlowStarted?.invoke()
|
||||||
importJob = managerScope.launch {
|
importJob = managerScope.launch {
|
||||||
mutableImportState.value = ImportState.Idle
|
mutableImportState.value = ImportState.Idle
|
||||||
performImport(uris)
|
performImport(uris)
|
||||||
@@ -36,6 +40,8 @@ class DictionaryImportManager(
|
|||||||
val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries")
|
val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries")
|
||||||
val totalFiles = uris.size.coerceAtLeast(1)
|
val totalFiles = uris.size.coerceAtLeast(1)
|
||||||
importedFiles.clear()
|
importedFiles.clear()
|
||||||
|
val skippedNames = mutableListOf<String>()
|
||||||
|
val deferredErrors = mutableListOf<String>()
|
||||||
|
|
||||||
if (!dictionariesDir.exists()) {
|
if (!dictionariesDir.exists()) {
|
||||||
dictionariesDir.mkdirs()
|
dictionariesDir.mkdirs()
|
||||||
@@ -46,9 +52,7 @@ class DictionaryImportManager(
|
|||||||
currentCoroutineContext().ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
val fileName = getFileName(uri) ?: continue
|
val fileName = getFileName(uri) ?: continue
|
||||||
if (SafeFileName.validate(fileName) == null) {
|
if (SafeFileName.validate(fileName) == null) {
|
||||||
mutableImportState.value = ImportState.Error(
|
deferredErrors += context.getString(R.string.import_invalid_file_name, fileName)
|
||||||
context.getString(R.string.import_invalid_file_name, fileName)
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val lowerFileName = fileName.lowercase()
|
val lowerFileName = fileName.lowercase()
|
||||||
@@ -65,9 +69,7 @@ class DictionaryImportManager(
|
|||||||
val destFile = File(dictionariesDir, fileName)
|
val destFile = File(dictionariesDir, fileName)
|
||||||
|
|
||||||
if (destFile.exists()) {
|
if (destFile.exists()) {
|
||||||
mutableImportState.value = ImportState.Error(
|
skippedNames += fileName
|
||||||
context.getString(R.string.import_file_exists, fileName)
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,16 +117,30 @@ class DictionaryImportManager(
|
|||||||
|
|
||||||
currentCoroutineContext().ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
|
|
||||||
mutableImportState.value = ImportState.Success
|
val terminalState = resolveImportOutcome(
|
||||||
|
importedCount = importedFiles.size,
|
||||||
|
skippedNames = skippedNames,
|
||||||
|
deferredErrors = deferredErrors,
|
||||||
|
).toImportState(
|
||||||
|
existsMessage = { names ->
|
||||||
|
context.getString(R.string.import_file_exists, names.joinToString(separator = ", "))
|
||||||
|
},
|
||||||
|
invalidMessage = { message -> message },
|
||||||
|
nothingImportedMessage = { context.getString(R.string.import_nothing_imported) },
|
||||||
|
)
|
||||||
|
mutableImportState.value = terminalState
|
||||||
|
onTerminal?.invoke(terminalState)
|
||||||
|
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
cleanupImportedFiles()
|
cleanupImportedFiles()
|
||||||
mutableImportState.value = ImportState.Idle
|
mutableImportState.value = ImportState.Idle
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
mutableImportState.value = ImportState.Error(
|
val errorState = ImportState.Error(
|
||||||
context.getString(R.string.import_error, e.message ?: "Unknown error")
|
context.getString(R.string.import_error, e.message ?: "Unknown error")
|
||||||
)
|
)
|
||||||
|
mutableImportState.value = errorState
|
||||||
|
onTerminal?.invoke(errorState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,18 +170,18 @@ class DictionaryImportManager(
|
|||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearImportState() {
|
override fun clearImportState() {
|
||||||
mutableImportState.value = ImportState.Idle
|
mutableImportState.value = ImportState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateExtractionProgress(progress: Float) {
|
override fun updateExtractionProgress(progress: Float) {
|
||||||
mutableImportState.update { current ->
|
mutableImportState.update { current ->
|
||||||
if (current is ImportState.Idle || current is ImportState.Error) current
|
if (current is ImportState.Idle || current is ImportState.Error) current
|
||||||
else ImportState.Extracting(progress.coerceIn(0f, 1f))
|
else ImportState.Extracting(progress.coerceIn(0f, 1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markImportPipelineSuccess() {
|
override fun markImportPipelineSuccess() {
|
||||||
mutableImportState.update { current ->
|
mutableImportState.update { current ->
|
||||||
if (current is ImportState.Idle || current is ImportState.Error) current
|
if (current is ImportState.Idle || current is ImportState.Error) current
|
||||||
else ImportState.Success
|
else ImportState.Success
|
||||||
@@ -179,7 +195,7 @@ class DictionaryImportManager(
|
|||||||
mutableImportState.value = ImportState.Idle
|
mutableImportState.value = ImportState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAndClearImportedFiles(): List<File> {
|
override fun getAndClearImportedFiles(): List<File> {
|
||||||
val files = importedFiles.toList()
|
val files = importedFiles.toList()
|
||||||
importedFiles.clear()
|
importedFiles.clear()
|
||||||
return files
|
return files
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.research.feature.import
|
||||||
|
|
||||||
|
import com.example.research.ui.settings.ImportState
|
||||||
|
|
||||||
|
internal sealed interface ImportOutcome {
|
||||||
|
data object Imported : ImportOutcome
|
||||||
|
data class AlreadyExists(val names: List<String>) : ImportOutcome
|
||||||
|
data class InvalidFile(val message: String) : ImportOutcome
|
||||||
|
data object NothingImported : ImportOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resolveImportOutcome(
|
||||||
|
importedCount: Int,
|
||||||
|
skippedNames: List<String>,
|
||||||
|
deferredErrors: List<String>,
|
||||||
|
): ImportOutcome = when {
|
||||||
|
importedCount > 0 -> ImportOutcome.Imported
|
||||||
|
skippedNames.isNotEmpty() -> ImportOutcome.AlreadyExists(skippedNames)
|
||||||
|
deferredErrors.isNotEmpty() -> ImportOutcome.InvalidFile(deferredErrors.first())
|
||||||
|
else -> ImportOutcome.NothingImported
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ImportOutcome.toImportState(
|
||||||
|
existsMessage: (List<String>) -> String,
|
||||||
|
invalidMessage: (String) -> String,
|
||||||
|
nothingImportedMessage: () -> String,
|
||||||
|
): ImportState = when (this) {
|
||||||
|
ImportOutcome.Imported -> ImportState.Success
|
||||||
|
is ImportOutcome.AlreadyExists -> ImportState.Error(existsMessage(names))
|
||||||
|
is ImportOutcome.InvalidFile -> ImportState.Error(invalidMessage(message))
|
||||||
|
ImportOutcome.NothingImported -> ImportState.Error(nothingImportedMessage())
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.example.research.core.util.onError
|
|||||||
import com.example.research.core.util.onSuccess
|
import com.example.research.core.util.onSuccess
|
||||||
import com.example.research.core.util.sanitizeQuery
|
import com.example.research.core.util.sanitizeQuery
|
||||||
import com.example.research.data.paging.IndexEntryPagingSource
|
import com.example.research.data.paging.IndexEntryPagingSource
|
||||||
|
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
|
||||||
@@ -28,12 +29,12 @@ sealed interface SearchAction {
|
|||||||
data class ArticleClicked(val entry: IndexEntry) : SearchAction
|
data class ArticleClicked(val entry: IndexEntry) : SearchAction
|
||||||
data object ClearArticle : SearchAction
|
data object ClearArticle : SearchAction
|
||||||
data class SearchAndLoadArticle(val word: String) : SearchAction
|
data class SearchAndLoadArticle(val word: String) : SearchAction
|
||||||
data class HandleExternalSearch(val text: String) : SearchAction
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SearchViewModel(
|
class SearchViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val repository: LocalDictionaryRepository,
|
private val repository: LocalDictionaryRepository,
|
||||||
|
private val preferencesManager: PreferencesManager,
|
||||||
private val savedStateHandle: SavedStateHandle
|
private val savedStateHandle: SavedStateHandle
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
@@ -51,6 +52,13 @@ class SearchViewModel(
|
|||||||
private val mutableErrorMessage = MutableStateFlow<String?>(null)
|
private val mutableErrorMessage = MutableStateFlow<String?>(null)
|
||||||
val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow()
|
val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow()
|
||||||
|
|
||||||
|
val searchHistory: StateFlow<List<String>> = preferencesManager.searchHistory
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
private val indexSearcher by lazy { repository.getIndexSearcher() }
|
private val indexSearcher by lazy { repository.getIndexSearcher() }
|
||||||
private var indexWarmupJob: Job? = null
|
private var indexWarmupJob: Job? = null
|
||||||
private var debounceSaveQueryJob: Job? = null
|
private var debounceSaveQueryJob: Job? = null
|
||||||
@@ -61,10 +69,12 @@ class SearchViewModel(
|
|||||||
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
|
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
|
||||||
repository.dictionaries.map { dictList ->
|
repository.dictionaries.map { dictList ->
|
||||||
dictList.filter { it.isActive }
|
dictList.filter { it.isActive }
|
||||||
|
}.distinctUntilChanged { previous, current ->
|
||||||
|
previous.map { it.indexPath } == current.map { it.indexPath }
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = emptyList()
|
initialValue = repository.dictionaries.value.filter { it.isActive }
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -85,7 +95,12 @@ class SearchViewModel(
|
|||||||
|
|
||||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
val searchResults: Flow<PagingData<IndexEntry>> = combine(
|
val searchResults: Flow<PagingData<IndexEntry>> = combine(
|
||||||
searchQuery.debounce(300.milliseconds).distinctUntilChanged(),
|
searchQuery
|
||||||
|
.map { it to it.isBlank() }
|
||||||
|
.distinctUntilChangedBy { it.first }
|
||||||
|
.flatMapLatest { (query, isBlank) ->
|
||||||
|
if (isBlank) flowOf(query) else flowOf(query).debounce(300.milliseconds)
|
||||||
|
},
|
||||||
activeDictionaries
|
activeDictionaries
|
||||||
) { query, dictionaries ->
|
) { query, dictionaries ->
|
||||||
query to dictionaries
|
query to dictionaries
|
||||||
@@ -98,8 +113,7 @@ class SearchViewModel(
|
|||||||
pageSize = 12,
|
pageSize = 12,
|
||||||
prefetchDistance = 1,
|
prefetchDistance = 1,
|
||||||
enablePlaceholders = false,
|
enablePlaceholders = false,
|
||||||
initialLoadSize = 12,
|
initialLoadSize = 12
|
||||||
jumpThreshold = 1
|
|
||||||
),
|
),
|
||||||
pagingSourceFactory = {
|
pagingSourceFactory = {
|
||||||
IndexEntryPagingSource(
|
IndexEntryPagingSource(
|
||||||
@@ -118,20 +132,24 @@ class SearchViewModel(
|
|||||||
SearchAction.ClearQuery -> {
|
SearchAction.ClearQuery -> {
|
||||||
mutableSearchQuery.value = ""
|
mutableSearchQuery.value = ""
|
||||||
savedStateHandle[KEY_SEARCH_QUERY] = ""
|
savedStateHandle[KEY_SEARCH_QUERY] = ""
|
||||||
|
clearArticle()
|
||||||
}
|
}
|
||||||
is SearchAction.ArticleClicked -> {
|
is SearchAction.ArticleClicked -> {
|
||||||
articleHistory.clear()
|
articleHistory.clear()
|
||||||
|
recordSearchHistory(action.entry.originalWord)
|
||||||
loadArticle(action.entry)
|
loadArticle(action.entry)
|
||||||
}
|
}
|
||||||
SearchAction.ClearArticle -> clearArticle()
|
SearchAction.ClearArticle -> clearArticle()
|
||||||
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
|
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
|
||||||
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSearchQueryChanged(query: String) {
|
fun onSearchQueryChanged(query: String) {
|
||||||
val sanitized = query.sanitizeQuery().trim()
|
val sanitized = query.sanitizeQuery().trim()
|
||||||
mutableSearchQuery.value = sanitized
|
mutableSearchQuery.value = sanitized
|
||||||
|
if (sanitized.isBlank()) {
|
||||||
|
clearArticle()
|
||||||
|
}
|
||||||
// Debounce SavedStateHandle writes to reduce disk I/O
|
// Debounce SavedStateHandle writes to reduce disk I/O
|
||||||
debounceSaveQueryJob?.cancel()
|
debounceSaveQueryJob?.cancel()
|
||||||
debounceSaveQueryJob = viewModelScope.launch {
|
debounceSaveQueryJob = viewModelScope.launch {
|
||||||
@@ -151,6 +169,27 @@ class SearchViewModel(
|
|||||||
mutableErrorMessage.value = null
|
mutableErrorMessage.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun removeSearchHistoryEntry(query: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesManager.removeSearchHistoryEntry(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSearchHistory() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesManager.clearSearchHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordSearchHistory(query: String) {
|
||||||
|
val trimmed = query.trim()
|
||||||
|
if (trimmed.isBlank()) return
|
||||||
|
if (searchHistory.value.firstOrNull() == trimmed) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesManager.addSearchHistoryEntry(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveScrollPosition(index: Int, offset: Int) {
|
fun saveScrollPosition(index: Int, offset: Int) {
|
||||||
mutableScrollPosition.value = index to offset
|
mutableScrollPosition.value = index to offset
|
||||||
}
|
}
|
||||||
@@ -273,6 +312,7 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
entry?.let {
|
entry?.let {
|
||||||
currentArticle?.let(articleHistory::addLast)
|
currentArticle?.let(articleHistory::addLast)
|
||||||
|
recordSearchHistory(sanitizedWord)
|
||||||
loadArticle(it)
|
loadArticle(it)
|
||||||
}
|
}
|
||||||
}.onError { message, exception ->
|
}.onError { message, exception ->
|
||||||
@@ -281,14 +321,6 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleExternalSearch(text: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val sanitizedText = text.sanitizeQuery()
|
|
||||||
mutableSearchQuery.value = sanitizedText
|
|
||||||
searchAndLoadArticle(sanitizedText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SearchViewModel"
|
private const val TAG = "SearchViewModel"
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package com.example.research.ui.about
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.RawRes
|
import androidx.annotation.RawRes
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class AboutLibrariesData(
|
internal data class AboutLibrariesData(
|
||||||
@@ -74,10 +76,8 @@ internal object AboutLibrariesParser {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData =
|
fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData =
|
||||||
context.resources.openRawResource(resourceId)
|
context.resources.openRawResource(resourceId)
|
||||||
.bufferedReader()
|
.use { stream -> json.decodeFromStream(stream) }
|
||||||
.use { reader -> json.decodeFromString(reader.readText()) }
|
|
||||||
|
|
||||||
fun decode(source: String): AboutLibrariesData = json.decodeFromString(source)
|
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
package com.example.research.ui.about
|
package com.example.research.ui.about
|
||||||
|
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialogDefaults
|
||||||
|
import androidx.compose.material3.BasicAlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
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
|
||||||
@@ -25,26 +28,35 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
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.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.AppBackButton
|
||||||
import com.example.research.common.ui.components.OutlinedChoiceButton
|
import com.example.research.common.ui.components.OutlinedChoiceButton
|
||||||
import com.example.research.ui.theme.AppWindowInsets
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AboutScreen(
|
fun AboutScreen(
|
||||||
onNavigateBack: () -> Unit
|
onNavigateBack: () -> Unit
|
||||||
@@ -57,23 +69,32 @@ fun AboutScreen(
|
|||||||
}
|
}
|
||||||
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
|
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val librariesData = remember(context) {
|
val librariesData by produceState<AboutLibrariesData?>(initialValue = null, context) {
|
||||||
AboutLibrariesParser.read(context, R.raw.aboutlibraries)
|
value = withContext(Dispatchers.IO) {
|
||||||
|
AboutLibrariesParser.read(context, R.raw.aboutlibraries)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
|
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
|
||||||
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
|
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
|
||||||
|
val onToggleLibrary = remember {
|
||||||
|
{ uniqueId: String ->
|
||||||
|
expandedLibraryId = if (expandedLibraryId == uniqueId) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
uniqueId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.about_app_title)) },
|
title = { Text(stringResource(R.string.about_app_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
AppBackButton(
|
||||||
Icon(
|
onClick = onNavigateBack,
|
||||||
painter = painterResource(R.drawable.ic_arrow_back),
|
modifier = Modifier.padding(start = 6.dp)
|
||||||
contentDescription = stringResource(R.string.action_back)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
@@ -89,24 +110,20 @@ fun AboutScreen(
|
|||||||
.semantics { testTagsAsResourceId = true }
|
.semantics { testTagsAsResourceId = true }
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
item(key = "about_header") {
|
item(key = "about_header", contentType = "about_header") {
|
||||||
AboutHeader(versionName = versionName)
|
AboutHeader(versionName = versionName)
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = librariesData.libraries,
|
items = librariesData?.libraries.orEmpty(),
|
||||||
key = AboutLibrary::uniqueId,
|
key = AboutLibrary::uniqueId,
|
||||||
|
contentType = { "about_library" },
|
||||||
) { library ->
|
) { library ->
|
||||||
AboutLibraryRow(
|
AboutLibraryRow(
|
||||||
|
modifier = Modifier.animateItem(fadeInSpec = tween(150)),
|
||||||
library = library,
|
library = library,
|
||||||
licenses = librariesData.licenses,
|
licenses = librariesData?.licenses.orEmpty(),
|
||||||
expanded = expandedLibraryId == library.uniqueId,
|
expanded = expandedLibraryId == library.uniqueId,
|
||||||
onToggle = {
|
onToggle = onToggleLibrary,
|
||||||
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
library.uniqueId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLicenseClick = { dialogLicense = it },
|
onLicenseClick = { dialogLicense = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,13 +156,14 @@ private fun AboutHeader(versionName: String) {
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.app_name),
|
text = stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.alignByBaseline()
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.version_format, versionName),
|
text = versionName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(bottom = 2.dp)
|
modifier = Modifier.alignByBaseline()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,35 +182,39 @@ private fun AboutHeader(versionName: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutLibraryRow(
|
private fun AboutLibraryRow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
library: AboutLibrary,
|
library: AboutLibrary,
|
||||||
licenses: Map<String, AboutLicense>,
|
licenses: Map<String, AboutLicense>,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onToggle: () -> Unit,
|
onToggle: (String) -> Unit,
|
||||||
onLicenseClick: (AboutLicense) -> Unit,
|
onLicenseClick: (AboutLicense) -> Unit,
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val website = library.website?.takeIf(String::isNotBlank)
|
val website = library.website?.takeIf(String::isNotBlank)
|
||||||
val source = library.scm?.url?.takeIf(String::isNotBlank)
|
val source = library.scm?.url?.takeIf(String::isNotBlank)
|
||||||
|
|
||||||
Surface(
|
val background by animateColorAsState(
|
||||||
modifier = Modifier
|
targetValue = if (expanded) {
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag("about_library_row")
|
|
||||||
.semantics { testTagsAsResourceId = true },
|
|
||||||
color = if (expanded) {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surface
|
MaterialTheme.colorScheme.surface
|
||||||
},
|
},
|
||||||
onClick = onToggle
|
label = "aboutLibraryRowBackground",
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("about_library_row")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
|
color = background,
|
||||||
|
onClick = { onToggle(library.uniqueId) }
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
.animateContentSize(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -288,32 +310,81 @@ private fun AboutLibraryRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LicenseDialog(
|
private fun LicenseDialog(
|
||||||
license: AboutLicense,
|
license: AboutLicense,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onOpenWebsite: (String) -> Unit,
|
onOpenWebsite: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
var open by remember { mutableStateOf(false) }
|
||||||
onDismissRequest = onDismiss,
|
var wasOpen by remember { mutableStateOf(false) }
|
||||||
title = { Text(license.name) },
|
LaunchedEffect(Unit) { open = true }
|
||||||
text = {
|
val scale by animateFloatAsState(
|
||||||
Text(
|
targetValue = if (open) 1f else 0.92f,
|
||||||
text = license.content ?: stringResource(R.string.license_text_unavailable),
|
animationSpec = tween(180),
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
label = "licenseDialogScale",
|
||||||
)
|
)
|
||||||
},
|
val alpha by animateFloatAsState(
|
||||||
confirmButton = {
|
targetValue = if (open) 1f else 0f,
|
||||||
TextButton(onClick = onDismiss) {
|
animationSpec = tween(180),
|
||||||
Text(stringResource(R.string.action_close))
|
label = "licenseDialogAlpha",
|
||||||
}
|
)
|
||||||
},
|
LaunchedEffect(open) {
|
||||||
dismissButton = {
|
if (open) {
|
||||||
license.url?.takeIf(String::isNotBlank)?.let { url ->
|
wasOpen = true
|
||||||
TextButton(onClick = { onOpenWebsite(url) }) {
|
} else if (wasOpen) {
|
||||||
Text(stringResource(R.string.website))
|
snapshotFlow { alpha }.first { it == 0f }
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicAlertDialog(
|
||||||
|
onDismissRequest = { open = false },
|
||||||
|
modifier = Modifier.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
this.alpha = alpha
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val maxHeight = with(LocalDensity.current) {
|
||||||
|
LocalWindowInfo.current.containerSize.height.toDp()
|
||||||
|
} - 96.dp
|
||||||
|
Surface(
|
||||||
|
shape = AlertDialogDefaults.shape,
|
||||||
|
color = AlertDialogDefaults.containerColor,
|
||||||
|
tonalElevation = AlertDialogDefaults.TonalElevation,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(24.dp).heightIn(max = maxHeight)) {
|
||||||
|
Text(
|
||||||
|
text = license.name,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = license.content ?: stringResource(R.string.license_text_unavailable),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
license.url?.takeIf(String::isNotBlank)?.let { url ->
|
||||||
|
TextButton(onClick = { onOpenWebsite(url) }) {
|
||||||
|
Text(stringResource(R.string.website))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = { open = false }) {
|
||||||
|
Text(stringResource(R.string.action_close))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
@@ -196,9 +196,7 @@ private suspend fun PointerInputScope.detectReferenceTaps(
|
|||||||
if (change == null) {
|
if (change == null) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (change.isConsumed ||
|
if ((change.position - downPosition).getDistance() > viewConfiguration.touchSlop) {
|
||||||
(change.position - downPosition).getDistance() > viewConfiguration.touchSlop
|
|
||||||
) {
|
|
||||||
canceled = true
|
canceled = true
|
||||||
}
|
}
|
||||||
if (!change.pressed) {
|
if (!change.pressed) {
|
||||||
|
|||||||
@@ -1,50 +1,106 @@
|
|||||||
package com.example.research.ui.article
|
package com.example.research.ui.article
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
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.unit.dp
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
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
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
import com.example.research.feature.search.SearchAction
|
import com.example.research.common.ui.AppBackButton
|
||||||
import com.example.research.ui.theme.Spacing
|
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.feature.search.SearchViewModel
|
import com.example.research.ui.theme.Spacing
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
|
|
||||||
@Composable
|
private const val MERGED_BLOCK_MAX_CHARS = 1500
|
||||||
fun ArticleRoute(
|
private const val MERGED_BLOCK_MAX_ITEMS = 12
|
||||||
searchViewModel: SearchViewModel,
|
|
||||||
onNavigateBack: () -> Unit
|
|
||||||
) {
|
|
||||||
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
ArticleScreen(
|
private fun mergeDslBlocks(blocks: List<DslBlock>): List<DslBlock> {
|
||||||
articleState = articleState,
|
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
|
||||||
onNavigateBack = onNavigateBack,
|
val merged = ArrayList<DslBlock>(blocks.size / 4)
|
||||||
onRefClick = { word ->
|
var i = 0
|
||||||
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
|
while (i < blocks.size) {
|
||||||
},
|
val builder = AnnotatedString.Builder()
|
||||||
onScrollPositionChanged = { index, offset ->
|
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
|
||||||
searchViewModel.updateArticleScrollPosition(index, offset)
|
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
|
||||||
@@ -61,9 +117,17 @@ fun ArticleContent(
|
|||||||
}
|
}
|
||||||
is ArticleState.Success -> {
|
is ArticleState.Success -> {
|
||||||
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
|
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
|
||||||
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
val typography = MaterialTheme.typography
|
||||||
)
|
val baseTextStyle = remember(colorScheme, typography) {
|
||||||
|
typography.bodyLarge.copy(
|
||||||
|
color = colorScheme.onSurface,
|
||||||
|
lineHeightStyle = LineHeightStyle(
|
||||||
|
alignment = LineHeightStyle.Alignment.Proportional,
|
||||||
|
trim = LineHeightStyle.Trim.Both,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
val indentStep = 16.dp
|
val indentStep = 16.dp
|
||||||
|
|
||||||
// Use scroll position from ArticleState if available (for back navigation)
|
// Use scroll position from ArticleState if available (for back navigation)
|
||||||
@@ -122,35 +186,42 @@ fun ArticleContent(
|
|||||||
val isHeavyArticle =
|
val isHeavyArticle =
|
||||||
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
|
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
|
||||||
|
|
||||||
LazyColumn(
|
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||||
state = listState,
|
val displayBlocks = remember(
|
||||||
modifier = modifier
|
articleState.dslContent,
|
||||||
.fillMaxSize()
|
baseTextStyle,
|
||||||
.onSizeChanged { viewportSize = it }
|
darkTheme
|
||||||
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
|
||||||
.padding(end = 16.dp)
|
|
||||||
.testTag("article_content")
|
|
||||||
.semantics {
|
|
||||||
testTagsAsResourceId = true
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
val merged =
|
||||||
items = articleState.dslContent,
|
mergeDslBlocks(articleState.dslContent)
|
||||||
key = { index, _ ->
|
if (darkTheme) {
|
||||||
// Use stable key combining article save key and index
|
merged.map {
|
||||||
"$articleSaveKey:$index"
|
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
|
||||||
},
|
}
|
||||||
contentType = { _, _ -> "article_block" }
|
} else {
|
||||||
) { index, block ->
|
merged
|
||||||
val followedByContinuation =
|
}
|
||||||
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
|
}
|
||||||
val textContent = @Composable {
|
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(top = Spacing.listItemVertical),
|
||||||
|
modifier = listModifier
|
||||||
|
.testTag("article_content")
|
||||||
|
.semantics {
|
||||||
|
testTagsAsResourceId = true
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = displayBlocks,
|
||||||
|
key = { index, _ ->
|
||||||
|
"$articleSaveKey:$index"
|
||||||
|
},
|
||||||
|
contentType = { _, _ -> "article_block" }
|
||||||
|
) { index, block ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(
|
.padding(start = Spacing.contentStart + indentStep * block.indent)
|
||||||
start = Spacing.iconStart + indentStep * block.indent
|
|
||||||
)
|
|
||||||
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
|
|
||||||
) {
|
) {
|
||||||
ArticleLinkText(
|
ArticleLinkText(
|
||||||
block = block,
|
block = block,
|
||||||
@@ -167,13 +238,18 @@ fun ArticleContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isHeavyArticle) {
|
|
||||||
textContent()
|
|
||||||
} else {
|
|
||||||
SelectionContainer { textContent() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val articleListModifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onSizeChanged { viewportSize = it }
|
||||||
|
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
if (isHeavyArticle) {
|
||||||
|
articleList(articleListModifier)
|
||||||
|
} else {
|
||||||
|
SelectionContainer { articleList(articleListModifier) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is ArticleState.Error -> {
|
is ArticleState.Error -> {
|
||||||
Column(
|
Column(
|
||||||
@@ -195,16 +271,7 @@ fun ArticleContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
null -> {
|
null -> {
|
||||||
Column(
|
Box(modifier = modifier.fillMaxSize())
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.article_no_selected),
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,65 +281,73 @@ fun ArticleTitleBar(
|
|||||||
articleState: ArticleState?,
|
articleState: ArticleState?,
|
||||||
showBackButton: Boolean,
|
showBackButton: Boolean,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
compact: Boolean = false,
|
||||||
|
selectionReset: Int = 0,
|
||||||
) {
|
) {
|
||||||
val title = when (articleState) {
|
val title = when (articleState) {
|
||||||
is ArticleState.Loading -> articleState.title
|
is ArticleState.Loading -> articleState.title
|
||||||
is ArticleState.Success -> articleState.title
|
is ArticleState.Success -> articleState.title
|
||||||
is ArticleState.Error -> articleState.title
|
is ArticleState.Error -> articleState.title
|
||||||
null -> stringResource(R.string.article_no_selected)
|
null -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val backIcon = painterResource(R.drawable.ic_arrow_back)
|
val backButtonOffset = if (compact) {
|
||||||
|
val titleLineHeight = with(LocalDensity.current) {
|
||||||
|
MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
Modifier.offset(y = (titleLineHeight - 48.dp) / 2)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Box(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
|
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
|
||||||
.padding(
|
.then(
|
||||||
top = Spacing.topBarPadding,
|
if (compact) {
|
||||||
bottom = Spacing.topBarPadding
|
Modifier.padding(vertical = Spacing.listItemVertical)
|
||||||
)
|
} else {
|
||||||
.height(Spacing.topBarHeight)
|
Modifier
|
||||||
|
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
|
||||||
|
.height(Spacing.topBarHeight)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
verticalAlignment = if (compact) Alignment.Top else Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (showBackButton) {
|
if (showBackButton) {
|
||||||
Box(
|
AppBackButton(
|
||||||
|
onClick = onNavigateBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterStart)
|
.padding(start = 10.dp)
|
||||||
.padding(start = Spacing.iconStart)
|
.then(backButtonOffset)
|
||||||
.size(24.dp)
|
)
|
||||||
.clickable(onClick = onNavigateBack),
|
}
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
key(selectionReset) {
|
||||||
Icon(
|
SelectionContainer {
|
||||||
painter = backIcon,
|
Text(
|
||||||
contentDescription = stringResource(R.string.action_back),
|
text = title,
|
||||||
modifier = Modifier.size(24.dp)
|
style = MaterialTheme.typography.dictionaryTitleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = if (showBackButton) 0.dp else 16.dp)
|
||||||
|
.testTag("article_title")
|
||||||
|
.semantics { testTagsAsResourceId = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterStart)
|
|
||||||
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
|
|
||||||
.testTag("article_title")
|
|
||||||
.semantics { testTagsAsResourceId = true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ArticleScreen(
|
internal fun ArticleScreen(
|
||||||
articleState: ArticleState?,
|
articleState: ArticleState?,
|
||||||
@@ -280,22 +355,31 @@ internal fun ArticleScreen(
|
|||||||
onRefClick: ((String) -> Unit)? = null,
|
onRefClick: ((String) -> Unit)? = null,
|
||||||
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
|
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
val titleSelectionReset = remember { mutableIntStateOf(0) }
|
||||||
topBar = {
|
Box(
|
||||||
ArticleTitleBar(
|
modifier = Modifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { titleSelectionReset.intValue++ }
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
ArticleTitleBar(
|
||||||
|
articleState = articleState,
|
||||||
|
showBackButton = true,
|
||||||
|
onNavigateBack = onNavigateBack,
|
||||||
|
selectionReset = titleSelectionReset.intValue
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentWindowInsets = AppWindowInsets.contentWithNavigation
|
||||||
|
) { padding ->
|
||||||
|
ArticleContent(
|
||||||
articleState = articleState,
|
articleState = articleState,
|
||||||
showBackButton = true,
|
onNavigateBack = onNavigateBack,
|
||||||
onNavigateBack = onNavigateBack
|
onRefClick = onRefClick,
|
||||||
|
onScrollPositionChanged = onScrollPositionChanged,
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
contentWindowInsets = AppWindowInsets.contentWithNavigation
|
|
||||||
) { padding ->
|
|
||||||
ArticleContent(
|
|
||||||
articleState = articleState,
|
|
||||||
onNavigateBack = onNavigateBack,
|
|
||||||
onRefClick = onRefClick,
|
|
||||||
onScrollPositionChanged = onScrollPositionChanged,
|
|
||||||
modifier = Modifier.padding(padding)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,40 @@ object DslAnnotatedParser {
|
|||||||
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
|
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
|
||||||
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
|
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
|
||||||
)
|
)
|
||||||
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
|
|
||||||
|
private val DSL_NAMED_COLORS = mapOf(
|
||||||
|
"black" to Color(0xFF000000),
|
||||||
|
"silver" to Color(0xFFC0C0C0),
|
||||||
|
"gray" to Color(0xFF808080),
|
||||||
|
"grey" to Color(0xFF808080),
|
||||||
|
"lightgray" to Color(0xFFD3D3D3),
|
||||||
|
"lightgrey" to Color(0xFFD3D3D3),
|
||||||
|
"darkgray" to Color(0xFFA9A9A9),
|
||||||
|
"darkgrey" to Color(0xFFA9A9A9),
|
||||||
|
"white" to Color(0xFFFFFFFF),
|
||||||
|
"maroon" to Color(0xFF800000),
|
||||||
|
"red" to Color(0xFFFF0000),
|
||||||
|
"darkred" to Color(0xFF8B0000),
|
||||||
|
"crimson" to Color(0xFFDC143C),
|
||||||
|
"brown" to Color(0xFFA52A2A),
|
||||||
|
"orange" to Color(0xFFFFA500),
|
||||||
|
"pink" to Color(0xFFFFC0CB),
|
||||||
|
"purple" to Color(0xFF800080),
|
||||||
|
"violet" to Color(0xFFEE82EE),
|
||||||
|
"fuchsia" to Color(0xFFFF00FF),
|
||||||
|
"magenta" to Color(0xFFFF00FF),
|
||||||
|
"olive" to Color(0xFF808000),
|
||||||
|
"yellow" to Color(0xFFFFFF00),
|
||||||
|
"green" to Color(0xFF008000),
|
||||||
|
"darkgreen" to Color(0xFF006400),
|
||||||
|
"lime" to Color(0xFF00FF00),
|
||||||
|
"teal" to Color(0xFF008080),
|
||||||
|
"aqua" to Color(0xFF00FFFF),
|
||||||
|
"cyan" to Color(0xFF00FFFF),
|
||||||
|
"navy" to Color(0xFF000080),
|
||||||
|
"blue" to Color(0xFF0000FF),
|
||||||
|
"darkblue" to Color(0xFF00008B),
|
||||||
|
)
|
||||||
|
|
||||||
data class ColorScheme(
|
data class ColorScheme(
|
||||||
val secondaryText: Color,
|
val secondaryText: Color,
|
||||||
@@ -37,11 +70,6 @@ object DslAnnotatedParser {
|
|||||||
val endIndex: Int
|
val endIndex: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache for applyLazyRefs results to avoid recreating AnnotatedString with links
|
|
||||||
// Size: 32 blocks - covers typical visible + prefetch range in LazyColumn
|
|
||||||
// Memory estimate: ~20KB per block average (AnnotatedString with LinkAnnotations)
|
|
||||||
// Total: ~640KB worst case
|
|
||||||
// LRU eviction automatic via LruCache
|
|
||||||
private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) {
|
private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) {
|
||||||
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
|
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
|
||||||
}
|
}
|
||||||
@@ -55,9 +83,9 @@ object DslAnnotatedParser {
|
|||||||
val stack = ArrayDeque<SpanStyle>()
|
val stack = ArrayDeque<SpanStyle>()
|
||||||
val refStack = ArrayDeque<Pair<Int, StringBuilder>>()
|
val refStack = ArrayDeque<Pair<Int, StringBuilder>>()
|
||||||
val lazyRefs = mutableListOf<LazyRef>()
|
val lazyRefs = mutableListOf<LazyRef>()
|
||||||
var lastChar: Char? = null
|
|
||||||
var accentNext = false
|
var accentNext = false
|
||||||
var currentIndent = 0
|
var currentIndent = 0
|
||||||
|
var lastAppended: Char? = null
|
||||||
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
|
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
|
||||||
val labelStyle = SpanStyle(color = colorScheme.labelText)
|
val labelStyle = SpanStyle(color = colorScheme.labelText)
|
||||||
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
|
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
|
||||||
@@ -75,16 +103,38 @@ object DslAnnotatedParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun flushBlock() {
|
fun flushBlock() {
|
||||||
if (builder.length > 0) {
|
val text = builder.toAnnotatedString()
|
||||||
|
if (text.any { !it.isWhitespace() }) {
|
||||||
result.add(DslBlock(
|
result.add(DslBlock(
|
||||||
text = builder.toAnnotatedString(),
|
text = text,
|
||||||
indent = currentIndent,
|
indent = currentIndent,
|
||||||
lazyRefs = lazyRefs.toList()
|
lazyRefs = lazyRefs.toList()
|
||||||
))
|
))
|
||||||
builder = AnnotatedString.Builder()
|
|
||||||
stack.forEach { builder.pushStyle(it) }
|
|
||||||
lazyRefs.clear()
|
|
||||||
}
|
}
|
||||||
|
builder = AnnotatedString.Builder()
|
||||||
|
stack.forEach { builder.pushStyle(it) }
|
||||||
|
lazyRefs.clear()
|
||||||
|
lastAppended = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendChar(char: Char, hard: Boolean = false) {
|
||||||
|
if (char == ' ' && !hard) {
|
||||||
|
if (lastAppended == null || lastAppended == ' ') return
|
||||||
|
}
|
||||||
|
lastAppended = char
|
||||||
|
builder.append(char)
|
||||||
|
if (refStack.isNotEmpty()) {
|
||||||
|
refStack.last().second.append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendText(value: String) {
|
||||||
|
if (value.isEmpty()) return
|
||||||
|
builder.append(value)
|
||||||
|
if (refStack.isNotEmpty()) {
|
||||||
|
refStack.last().second.append(value)
|
||||||
|
}
|
||||||
|
lastAppended = value.last()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun marginIndent(tagName: String): Int {
|
fun marginIndent(tagName: String): Int {
|
||||||
@@ -100,26 +150,15 @@ object DslAnnotatedParser {
|
|||||||
when (val char = dsl[i]) {
|
when (val char = dsl[i]) {
|
||||||
'\\' -> {
|
'\\' -> {
|
||||||
if (i + 1 < length) {
|
if (i + 1 < length) {
|
||||||
val next = dsl[i + 1]
|
appendChar(dsl[i + 1], hard = true)
|
||||||
if (next in ESCAPED_CHARS) {
|
i += 2
|
||||||
builder.append(next)
|
continue
|
||||||
if (refStack.isNotEmpty()) {
|
|
||||||
refStack.last().second.append(next)
|
|
||||||
}
|
|
||||||
lastChar = next
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
builder.append(char)
|
appendChar(char)
|
||||||
if (refStack.isNotEmpty()) {
|
|
||||||
refStack.last().second.append(char)
|
|
||||||
}
|
|
||||||
lastChar = char
|
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
'[' -> {
|
'[' -> {
|
||||||
val end = dsl.indexOf(']', i + 1)
|
val end = tagCloseIndex(dsl, i + 1)
|
||||||
if (end != -1) {
|
if (end != -1) {
|
||||||
val tagStart = i + 1
|
val tagStart = i + 1
|
||||||
var tagEnd = end
|
var tagEnd = end
|
||||||
@@ -152,6 +191,10 @@ object DslAnnotatedParser {
|
|||||||
accentNext = false
|
accentNext = false
|
||||||
}
|
}
|
||||||
pop()
|
pop()
|
||||||
|
if (tagName.startsWith("m")) {
|
||||||
|
flushBlock()
|
||||||
|
currentIndent = 0
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
when (tagName) {
|
when (tagName) {
|
||||||
"'" -> {
|
"'" -> {
|
||||||
@@ -164,17 +207,15 @@ object DslAnnotatedParser {
|
|||||||
} else {
|
} else {
|
||||||
builder.append("\n")
|
builder.append("\n")
|
||||||
}
|
}
|
||||||
lastChar = '\n'
|
|
||||||
}
|
}
|
||||||
"p" -> {
|
"p" -> {
|
||||||
push(labelStyle)
|
push(labelStyle.copy(fontStyle = FontStyle.Italic))
|
||||||
}
|
}
|
||||||
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
|
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
|
||||||
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
|
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
|
||||||
if (lastChar != '\n' && lastChar != null) {
|
if (lastAppended != null && !lastAppended!!.isWhitespace()) {
|
||||||
if (refStack.isEmpty()) flushBlock() else builder.append("\n")
|
flushBlock()
|
||||||
}
|
}
|
||||||
lastChar = '\n'
|
|
||||||
currentIndent = marginIndent(tagName)
|
currentIndent = marginIndent(tagName)
|
||||||
push(exampleStyle)
|
push(exampleStyle)
|
||||||
}
|
}
|
||||||
@@ -184,7 +225,18 @@ object DslAnnotatedParser {
|
|||||||
refStack.addLast(builder.length to StringBuilder())
|
refStack.addLast(builder.length to StringBuilder())
|
||||||
}
|
}
|
||||||
"ex", "e" -> push(exampleStyle)
|
"ex", "e" -> push(exampleStyle)
|
||||||
"c", "t", "trn", "lang", "sup" -> push(SpanStyle())
|
"c" -> {
|
||||||
|
val colorName = if (spaceIdx >= 0) {
|
||||||
|
tagWithParams.substring(spaceIdx + 1).trim().lowercase()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
push(when {
|
||||||
|
colorName == null -> labelStyle
|
||||||
|
else -> DSL_NAMED_COLORS[colorName]?.let { SpanStyle(color = it) } ?: SpanStyle()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"t", "trn", "lang", "sup" -> push(SpanStyle())
|
||||||
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
|
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,18 +244,23 @@ object DslAnnotatedParser {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder.append(char)
|
appendChar(char)
|
||||||
if (refStack.isNotEmpty()) {
|
i++
|
||||||
refStack.last().second.append(char)
|
}
|
||||||
|
'\n' -> {
|
||||||
|
if (refStack.isEmpty()) {
|
||||||
|
flushBlock()
|
||||||
|
} else {
|
||||||
|
appendChar(char)
|
||||||
}
|
}
|
||||||
lastChar = char
|
i++
|
||||||
|
}
|
||||||
|
'\t' -> {
|
||||||
|
appendText("\u2002")
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
builder.append(char)
|
appendChar(char)
|
||||||
if (refStack.isNotEmpty()) {
|
|
||||||
refStack.last().second.append(char)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accentNext) {
|
if (accentNext) {
|
||||||
builder.append('\u0301')
|
builder.append('\u0301')
|
||||||
@@ -213,7 +270,6 @@ object DslAnnotatedParser {
|
|||||||
accentNext = false
|
accentNext = false
|
||||||
}
|
}
|
||||||
|
|
||||||
lastChar = char
|
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +284,19 @@ object DslAnnotatedParser {
|
|||||||
splitOversizedBlocks(result)
|
splitOversizedBlocks(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun tagCloseIndex(value: String, startIndex: Int): Int {
|
||||||
|
var index = startIndex
|
||||||
|
while (index < value.length) {
|
||||||
|
when {
|
||||||
|
value[index] == '\\' && index + 1 < value.length -> index += 2
|
||||||
|
value[index] == '[' -> return -1
|
||||||
|
value[index] == ']' -> return index
|
||||||
|
else -> index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
|
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
|
||||||
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
|
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
|
||||||
return blocks.flatMap { block ->
|
return blocks.flatMap { block ->
|
||||||
@@ -342,6 +411,35 @@ object DslAnnotatedParser {
|
|||||||
linkText = Color(0xFF1E88E5),
|
linkText = Color(0xFF1E88E5),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val DARK_THEME_COLORS: Map<Color, Color> = mapOf(
|
||||||
|
Color(0xFF000000) to Color(0xFFE8E6E3),
|
||||||
|
Color(0xFF000080) to Color(0xFF8FA8E0),
|
||||||
|
Color(0xFF00008B) to Color(0xFF8FA8E0),
|
||||||
|
Color(0xFF0000FF) to Color(0xFF7C9CE8),
|
||||||
|
Color(0xFF006400) to Color(0xFF66BB6A),
|
||||||
|
Color(0xFF008000) to Color(0xFF66BB6A),
|
||||||
|
Color(0xFF8B0000) to Color(0xFFE57373),
|
||||||
|
Color(0xFF800000) to Color(0xFFE57373),
|
||||||
|
Color(0xFF800080) to Color(0xFFBA68C8),
|
||||||
|
Color(0xFFA52A2A) to Color(0xFFBCAAA4),
|
||||||
|
Color(0xFF008080) to Color(0xFF4DB6AC),
|
||||||
|
Color(0xFF808000) to Color(0xFFC0CA33),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun remapForDarkTheme(text: AnnotatedString): AnnotatedString {
|
||||||
|
if (text.spanStyles.none { it.item.color in DARK_THEME_COLORS }) return text
|
||||||
|
val builder = AnnotatedString.Builder(text.text)
|
||||||
|
text.spanStyles.forEach { range ->
|
||||||
|
val style = DARK_THEME_COLORS[range.item.color]
|
||||||
|
?.let { range.item.copy(color = it) } ?: range.item
|
||||||
|
builder.addStyle(style, range.start, range.end)
|
||||||
|
}
|
||||||
|
text.paragraphStyles.forEach { range ->
|
||||||
|
builder.addStyle(range.item, range.start, range.end)
|
||||||
|
}
|
||||||
|
return builder.toAnnotatedString()
|
||||||
|
}
|
||||||
|
|
||||||
// Allow external cache clearing (e.g., when dictionaries change)
|
// Allow external cache clearing (e.g., when dictionaries change)
|
||||||
fun clearRefCache() {
|
fun clearRefCache() {
|
||||||
refCache.evictAll()
|
refCache.evictAll()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.example.research.ui.main
|
package com.example.research.ui.main
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -12,7 +14,8 @@ import com.example.research.core.domain.model.IndexEntry
|
|||||||
import com.example.research.ui.article.ArticleContent
|
import com.example.research.ui.article.ArticleContent
|
||||||
import com.example.research.ui.article.ArticleTitleBar
|
import com.example.research.ui.article.ArticleTitleBar
|
||||||
import com.example.research.ui.article.ArticleState
|
import com.example.research.ui.article.ArticleState
|
||||||
import com.example.research.ui.main.components.MainTopBar
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
|
import com.example.research.ui.theme.Spacing
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -20,9 +23,11 @@ fun AdaptiveMainScreen(
|
|||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
canSearch: Boolean,
|
canSearch: Boolean,
|
||||||
isLandscape: Boolean,
|
isLandscape: Boolean,
|
||||||
|
isSearchBarAtBottom: Boolean,
|
||||||
results: LazyPagingItems<IndexEntry>,
|
results: LazyPagingItems<IndexEntry>,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
showDictionaryName: Boolean,
|
showDictionaryName: Boolean,
|
||||||
|
searchHistory: List<String>,
|
||||||
articleState: ArticleState?,
|
articleState: ArticleState?,
|
||||||
scrollPosition: Pair<Int, Int>,
|
scrollPosition: Pair<Int, Int>,
|
||||||
onAction: (MainAction) -> Unit,
|
onAction: (MainAction) -> Unit,
|
||||||
@@ -43,21 +48,37 @@ fun AdaptiveMainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isQueryEmpty = searchState.text.isEmpty()
|
|
||||||
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
|
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
|
||||||
val isTwoPane =
|
val isTwoPane =
|
||||||
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
|
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
|
||||||
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
|
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
|
||||||
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
|
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
val searchBar: @Composable () -> Unit = {
|
||||||
MainTopBar(
|
MainSearchBar(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
enabled = canSearch,
|
canSearch = canSearch,
|
||||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
searchHistory = searchHistory,
|
||||||
onNavigateToSettings = onNavigateToSettings
|
onAction = onAction,
|
||||||
|
onNavigateToSettings = onNavigateToSettings,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (!isSearchBarAtBottom) {
|
||||||
|
searchBar()
|
||||||
|
}
|
||||||
|
|
||||||
ListDetailPaneScaffold(
|
ListDetailPaneScaffold(
|
||||||
directive = PaneScaffoldDirective.Default.copy(
|
directive = PaneScaffoldDirective.Default.copy(
|
||||||
@@ -84,20 +105,27 @@ fun AdaptiveMainScreen(
|
|||||||
.windowInsetsPadding(
|
.windowInsetsPadding(
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
||||||
)
|
)
|
||||||
.padding(start = 32.dp, end = listEndPadding),
|
.padding(start = Spacing.contentStart, end = listEndPadding),
|
||||||
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
|
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
detailPane = {
|
detailPane = {
|
||||||
|
val titleSelectionReset = remember { mutableIntStateOf(0) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { titleSelectionReset.intValue++ }
|
||||||
.padding(bottom = 16.dp)
|
.padding(bottom = 16.dp)
|
||||||
) {
|
) {
|
||||||
ArticleTitleBar(
|
ArticleTitleBar(
|
||||||
articleState = articleState,
|
articleState = articleState,
|
||||||
showBackButton = isShowingDetail,
|
showBackButton = isShowingDetail && articleState != null,
|
||||||
onNavigateBack = onNavigateBackFromArticle,
|
onNavigateBack = onNavigateBackFromArticle,
|
||||||
|
compact = true,
|
||||||
|
selectionReset = titleSelectionReset.intValue,
|
||||||
modifier = Modifier.windowInsetsPadding(
|
modifier = Modifier.windowInsetsPadding(
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
|
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
|
||||||
)
|
)
|
||||||
@@ -110,21 +138,21 @@ fun AdaptiveMainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
searchBar()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(articleState, isQueryEmpty) {
|
LaunchedEffect(articleState) {
|
||||||
val targetValue = when {
|
val targetValue = if (articleState == null) {
|
||||||
articleState == null -> ThreePaneScaffoldValue(
|
ThreePaneScaffoldValue(
|
||||||
primary = PaneAdaptedValue.Hidden,
|
primary = PaneAdaptedValue.Hidden,
|
||||||
secondary = PaneAdaptedValue.Expanded,
|
secondary = PaneAdaptedValue.Expanded,
|
||||||
tertiary = PaneAdaptedValue.Hidden,
|
tertiary = PaneAdaptedValue.Hidden,
|
||||||
)
|
)
|
||||||
isQueryEmpty -> ThreePaneScaffoldValue(
|
} else {
|
||||||
primary = PaneAdaptedValue.Expanded,
|
ThreePaneScaffoldValue(
|
||||||
secondary = PaneAdaptedValue.Hidden,
|
|
||||||
tertiary = PaneAdaptedValue.Hidden,
|
|
||||||
)
|
|
||||||
else -> ThreePaneScaffoldValue(
|
|
||||||
primary = PaneAdaptedValue.Expanded,
|
primary = PaneAdaptedValue.Expanded,
|
||||||
secondary = PaneAdaptedValue.Expanded,
|
secondary = PaneAdaptedValue.Expanded,
|
||||||
tertiary = PaneAdaptedValue.Hidden,
|
tertiary = PaneAdaptedValue.Hidden,
|
||||||
|
|||||||
@@ -5,4 +5,7 @@ sealed interface MainAction {
|
|||||||
data object ClearQuery : MainAction
|
data object ClearQuery : MainAction
|
||||||
data object ClearError : MainAction
|
data object ClearError : MainAction
|
||||||
data class SaveScrollPosition(val index: Int, val offset: Int) : MainAction
|
data class SaveScrollPosition(val index: Int, val offset: Int) : MainAction
|
||||||
|
data class HistoryEntryClicked(val query: String) : MainAction
|
||||||
|
data class RemoveHistoryEntry(val query: String) : MainAction
|
||||||
|
data object ClearHistory : MainAction
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.example.research.ui.main
|
package com.example.research.ui.main
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
@@ -14,11 +16,13 @@ import androidx.compose.foundation.text.input.TextFieldState
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.ui.theme.AppWindowInsets
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
|
import com.example.research.ui.theme.Spacing
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.example.research.core.domain.model.IndexEntry
|
import com.example.research.core.domain.model.IndexEntry
|
||||||
import com.example.research.feature.download.config.DictionaryConfig
|
import com.example.research.feature.download.config.DictionaryConfig
|
||||||
|
import com.example.research.core.domain.model.SearchBarPosition
|
||||||
import com.example.research.feature.download.model.DownloadState
|
import com.example.research.feature.download.model.DownloadState
|
||||||
import com.example.research.feature.search.SearchAction
|
import com.example.research.feature.search.SearchAction
|
||||||
import com.example.research.feature.search.SearchViewModel
|
import com.example.research.feature.search.SearchViewModel
|
||||||
@@ -36,9 +40,11 @@ fun MainRoute(
|
|||||||
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
|
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
|
||||||
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
|
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
|
||||||
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
||||||
|
val searchHistory by searchViewModel.searchHistory.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
|
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
|
||||||
|
val isSearchBarAtBottom = settingsState.searchBarPosition == SearchBarPosition.BOTTOM
|
||||||
|
|
||||||
val scrollPosition = remember(searchViewModel) {
|
val scrollPosition = remember(searchViewModel) {
|
||||||
searchViewModel.scrollPosition.value
|
searchViewModel.scrollPosition.value
|
||||||
@@ -46,17 +52,6 @@ fun MainRoute(
|
|||||||
val searchState = remember(searchViewModel) {
|
val searchState = remember(searchViewModel) {
|
||||||
TextFieldState(initialText = searchViewModel.searchQuery.value)
|
TextFieldState(initialText = searchViewModel.searchQuery.value)
|
||||||
}
|
}
|
||||||
val onMainAction = remember(searchViewModel) {
|
|
||||||
{ action: MainAction ->
|
|
||||||
when (action) {
|
|
||||||
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
|
|
||||||
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
|
|
||||||
MainAction.ClearError -> searchViewModel.clearError()
|
|
||||||
is MainAction.SaveScrollPosition ->
|
|
||||||
searchViewModel.saveScrollPosition(action.index, action.offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(searchState, searchQuery) {
|
LaunchedEffect(searchState, searchQuery) {
|
||||||
if (searchState.text.toString() != searchQuery) {
|
if (searchState.text.toString() != searchQuery) {
|
||||||
@@ -78,13 +73,9 @@ fun MainRoute(
|
|||||||
|
|
||||||
val showDictionaryName = remember(settingsState) {
|
val showDictionaryName = remember(settingsState) {
|
||||||
val activeDictionaries = settingsState.dictionaries.filter { it.isActive }
|
val activeDictionaries = settingsState.dictionaries.filter { it.isActive }
|
||||||
if (activeDictionaries.isEmpty()) {
|
activeDictionaries.any { dict ->
|
||||||
false
|
val fileName = dict.path.substringAfterLast("/")
|
||||||
} else {
|
!DictionaryConfig.isDabkrsDictionary(fileName)
|
||||||
activeDictionaries.any { dict ->
|
|
||||||
val fileName = dict.path.substringAfterLast("/")
|
|
||||||
!DictionaryConfig.isDabkrsDictionary(fileName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,14 +83,37 @@ fun MainRoute(
|
|||||||
val isWide = maxWidth >= 600.dp
|
val isWide = maxWidth >= 600.dp
|
||||||
val isLandscape = maxWidth > maxHeight
|
val isLandscape = maxWidth > maxHeight
|
||||||
|
|
||||||
|
val onMainAction = remember(searchViewModel, isWide) {
|
||||||
|
{ action: MainAction ->
|
||||||
|
when (action) {
|
||||||
|
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
|
||||||
|
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
|
||||||
|
MainAction.ClearError -> searchViewModel.clearError()
|
||||||
|
is MainAction.SaveScrollPosition ->
|
||||||
|
searchViewModel.saveScrollPosition(action.index, action.offset)
|
||||||
|
is MainAction.HistoryEntryClicked ->
|
||||||
|
if (isWide) {
|
||||||
|
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(action.query))
|
||||||
|
} else {
|
||||||
|
searchViewModel.onSearchQueryChanged(action.query)
|
||||||
|
}
|
||||||
|
is MainAction.RemoveHistoryEntry ->
|
||||||
|
searchViewModel.removeSearchHistoryEntry(action.query)
|
||||||
|
MainAction.ClearHistory -> searchViewModel.clearSearchHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
AdaptiveMainScreen(
|
AdaptiveMainScreen(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
canSearch = canSearch,
|
canSearch = canSearch,
|
||||||
isLandscape = isLandscape,
|
isLandscape = isLandscape,
|
||||||
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
results = results,
|
results = results,
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
showDictionaryName = showDictionaryName,
|
showDictionaryName = showDictionaryName,
|
||||||
|
searchHistory = searchHistory,
|
||||||
articleState = articleState,
|
articleState = articleState,
|
||||||
scrollPosition = scrollPosition,
|
scrollPosition = scrollPosition,
|
||||||
onAction = onMainAction,
|
onAction = onMainAction,
|
||||||
@@ -120,9 +134,11 @@ fun MainRoute(
|
|||||||
MainScreen(
|
MainScreen(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
canSearch = canSearch,
|
canSearch = canSearch,
|
||||||
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
results = results,
|
results = results,
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
showDictionaryName = showDictionaryName,
|
showDictionaryName = showDictionaryName,
|
||||||
|
searchHistory = searchHistory,
|
||||||
scrollPosition = scrollPosition,
|
scrollPosition = scrollPosition,
|
||||||
onAction = onMainAction,
|
onAction = onMainAction,
|
||||||
onArticleClick = onArticleClick,
|
onArticleClick = onArticleClick,
|
||||||
@@ -132,28 +148,81 @@ fun MainRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MainSearchBar(
|
||||||
|
searchState: TextFieldState,
|
||||||
|
canSearch: Boolean,
|
||||||
|
isSearchBarAtBottom: Boolean,
|
||||||
|
searchHistory: List<String>,
|
||||||
|
onAction: (MainAction) -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
MainTopBar(
|
||||||
|
searchState = searchState,
|
||||||
|
enabled = canSearch,
|
||||||
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
|
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
||||||
|
onClearQuery = { onAction(MainAction.ClearQuery) },
|
||||||
|
searchHistory = searchHistory,
|
||||||
|
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
|
||||||
|
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
|
||||||
|
onClearHistory = { onAction(MainAction.ClearHistory) },
|
||||||
|
onNavigateToSettings = onNavigateToSettings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MainScreen(
|
internal fun MainScreen(
|
||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
canSearch: Boolean,
|
canSearch: Boolean,
|
||||||
|
isSearchBarAtBottom: Boolean,
|
||||||
results: LazyPagingItems<IndexEntry>,
|
results: LazyPagingItems<IndexEntry>,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
showDictionaryName: Boolean,
|
showDictionaryName: Boolean,
|
||||||
|
searchHistory: List<String>,
|
||||||
scrollPosition: Pair<Int, Int>,
|
scrollPosition: Pair<Int, Int>,
|
||||||
onAction: (MainAction) -> Unit,
|
onAction: (MainAction) -> Unit,
|
||||||
onArticleClick: (IndexEntry) -> Unit,
|
onArticleClick: (IndexEntry) -> Unit,
|
||||||
onNavigateToSettings: () -> Unit
|
onNavigateToSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
val searchBar: @Composable () -> Unit = {
|
||||||
topBar = {
|
MainSearchBar(
|
||||||
MainTopBar(
|
searchState = searchState,
|
||||||
|
canSearch = canSearch,
|
||||||
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
|
searchHistory = searchHistory,
|
||||||
|
onAction = onAction,
|
||||||
|
onNavigateToSettings = onNavigateToSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
|
||||||
|
) {
|
||||||
|
SearchResultsPane(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
enabled = canSearch,
|
results = results,
|
||||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
errorMessage = errorMessage,
|
||||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
showDictionaryName = showDictionaryName,
|
||||||
onNavigateToSettings = onNavigateToSettings
|
scrollPosition = scrollPosition,
|
||||||
|
onAction = onAction,
|
||||||
|
onArticleClick = onArticleClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
||||||
|
.padding(start = Spacing.contentStart, end = 16.dp),
|
||||||
)
|
)
|
||||||
},
|
searchBar()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = { searchBar() },
|
||||||
contentWindowInsets = AppWindowInsets.navigationOnly
|
contentWindowInsets = AppWindowInsets.navigationOnly
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
SearchResultsPane(
|
SearchResultsPane(
|
||||||
@@ -169,7 +238,7 @@ internal fun MainScreen(
|
|||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.consumeWindowInsets(paddingValues)
|
.consumeWindowInsets(paddingValues)
|
||||||
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
||||||
.padding(start = 32.dp, end = 16.dp),
|
.padding(start = Spacing.contentStart, end = 16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.ime
|
|||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -50,6 +51,7 @@ internal fun SearchResultsPane(
|
|||||||
var restoredScrollPosition by remember { mutableStateOf(false) }
|
var restoredScrollPosition by remember { mutableStateOf(false) }
|
||||||
var pendingScrollToTop by remember { mutableStateOf(false) }
|
var pendingScrollToTop by remember { mutableStateOf(false) }
|
||||||
var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
|
var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
|
||||||
|
var staleEntries by remember { mutableStateOf(emptyList<IndexEntry>()) }
|
||||||
val imeBottomPadding = with(LocalDensity.current) {
|
val imeBottomPadding = with(LocalDensity.current) {
|
||||||
WindowInsets.ime.getBottom(this).toDp()
|
WindowInsets.ime.getBottom(this).toDp()
|
||||||
}
|
}
|
||||||
@@ -72,9 +74,22 @@ internal fun SearchResultsPane(
|
|||||||
restoredScrollPosition = true
|
restoredScrollPosition = true
|
||||||
pendingScrollToTop = query.isNotBlank()
|
pendingScrollToTop = query.isNotBlank()
|
||||||
sawRefreshForPendingScroll = false
|
sawRefreshForPendingScroll = false
|
||||||
|
if (query.isBlank()) {
|
||||||
|
staleEntries = emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SideEffect {
|
||||||
|
val loaded = results.itemSnapshotList.items
|
||||||
|
if (results.loadState.refresh is LoadState.NotLoading && loaded.isNotEmpty()) {
|
||||||
|
staleEntries = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pendingReload = results.itemCount == 0 &&
|
||||||
|
results.loadState.refresh is LoadState.Loading
|
||||||
|
val shownStale = if (pendingReload && searchState.text.isNotBlank()) staleEntries else emptyList()
|
||||||
|
|
||||||
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
|
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
|
||||||
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
|
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
|
||||||
sawRefreshForPendingScroll = true
|
sawRefreshForPendingScroll = true
|
||||||
@@ -125,6 +140,7 @@ internal fun SearchResultsPane(
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
showDictionaryName = showDictionaryName,
|
showDictionaryName = showDictionaryName,
|
||||||
contentPadding = PaddingValues(bottom = imeBottomPadding),
|
contentPadding = PaddingValues(bottom = imeBottomPadding),
|
||||||
|
staleEntries = shownStale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,67 +1,127 @@
|
|||||||
package com.example.research.ui.main.components
|
package com.example.research.ui.main.components
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
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.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
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
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import com.example.research.ui.theme.AppWindowInsets
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.appTestTag
|
||||||
|
import com.example.research.common.ui.iconClickable
|
||||||
|
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
|
import com.example.research.ui.theme.Spacing
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private val TopBarHeight = 56.dp
|
private val TopBarHeight = Spacing.topBarHeight
|
||||||
private val TopBarShape = RoundedCornerShape(28.dp)
|
private val TopBarShape = RoundedCornerShape(28.dp)
|
||||||
|
private val HistorySheetChrome = 64.dp
|
||||||
|
private val HistorySheetMinHeight = 300.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainTopBar(
|
fun MainTopBar(
|
||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
|
isSearchBarAtBottom: Boolean = false,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClearQuery: () -> Unit,
|
onClearQuery: () -> Unit,
|
||||||
|
searchHistory: List<String>,
|
||||||
|
onHistoryEntryClick: (String) -> Unit,
|
||||||
|
onHistoryEntryRemove: (String) -> Unit,
|
||||||
|
onClearHistory: () -> Unit,
|
||||||
onNavigateToSettings: () -> Unit
|
onNavigateToSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
val searchIcon = painterResource(R.drawable.ic_search)
|
|
||||||
val closeIcon = painterResource(R.drawable.ic_close)
|
val closeIcon = painterResource(R.drawable.ic_close)
|
||||||
|
val historyIcon = painterResource(R.drawable.ic_manage_search)
|
||||||
val settingsIcon = painterResource(R.drawable.ic_settings)
|
val settingsIcon = painterResource(R.drawable.ic_settings)
|
||||||
val clearLabel = stringResource(R.string.clear_search)
|
val clearLabel = stringResource(R.string.clear_search)
|
||||||
|
val historyLabel = stringResource(R.string.recent_searches)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
var showHistorySheet by remember { mutableStateOf(false) }
|
||||||
|
var topBarBottomPx by remember { mutableIntStateOf(0) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val imeInsets = WindowInsets.ime
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onGloballyPositioned { topBarBottomPx = it.boundsInWindow().bottom.roundToInt() }
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
|
.windowInsetsPadding(
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
AppWindowInsets.horizontalSafeDrawing
|
||||||
|
} else {
|
||||||
|
AppWindowInsets.topAndHorizontalSafeDrawing
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
Modifier.windowInsetsPadding(AppWindowInsets.bottomNavigation)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
|
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -71,9 +131,21 @@ fun MainTopBar(
|
|||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
onQueryChange = onQueryChange,
|
onQueryChange = onQueryChange,
|
||||||
onClearQuery = onClearQuery,
|
onClearQuery = onClearQuery,
|
||||||
searchIcon = searchIcon,
|
|
||||||
closeIcon = closeIcon,
|
closeIcon = closeIcon,
|
||||||
clearLabel = clearLabel,
|
clearLabel = clearLabel,
|
||||||
|
historyIcon = historyIcon,
|
||||||
|
historyLabel = historyLabel,
|
||||||
|
historyEnabled = searchHistory.isNotEmpty(),
|
||||||
|
onShowHistory = {
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
coroutineScope.launch {
|
||||||
|
withTimeoutOrNull(350.milliseconds) {
|
||||||
|
snapshotFlow { imeInsets.getBottom(density) > 0 }
|
||||||
|
.first { visible -> !visible }
|
||||||
|
}
|
||||||
|
showHistorySheet = !showHistorySheet
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,6 +168,25 @@ fun MainTopBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showHistorySheet && searchHistory.isNotEmpty()) {
|
||||||
|
SearchHistorySheet(
|
||||||
|
history = searchHistory,
|
||||||
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
|
topBarBottomPx = topBarBottomPx,
|
||||||
|
onDismiss = { showHistorySheet = false },
|
||||||
|
onEntryClick = { entry ->
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
showHistorySheet = false
|
||||||
|
onHistoryEntryClick(entry)
|
||||||
|
},
|
||||||
|
onEntryRemove = onHistoryEntryRemove,
|
||||||
|
onClearAll = {
|
||||||
|
showHistorySheet = false
|
||||||
|
onClearHistory()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -104,14 +195,15 @@ private fun SearchField(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClearQuery: () -> Unit,
|
onClearQuery: () -> Unit,
|
||||||
searchIcon: androidx.compose.ui.graphics.painter.Painter,
|
closeIcon: Painter,
|
||||||
closeIcon: androidx.compose.ui.graphics.painter.Painter,
|
|
||||||
clearLabel: String,
|
clearLabel: String,
|
||||||
|
historyIcon: Painter,
|
||||||
|
historyLabel: String,
|
||||||
|
historyEnabled: Boolean,
|
||||||
|
onShowHistory: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
val contentColor = MaterialTheme.colorScheme.onSurface
|
|
||||||
val placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
|
|
||||||
val currentOnQueryChange by rememberUpdatedState(onQueryChange)
|
val currentOnQueryChange by rememberUpdatedState(onQueryChange)
|
||||||
|
|
||||||
@@ -123,57 +215,109 @@ private fun SearchField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
TextField(
|
||||||
modifier = modifier.height(TopBarHeight),
|
state = searchState,
|
||||||
color = containerColor,
|
enabled = enabled,
|
||||||
shape = TopBarShape
|
textStyle = MaterialTheme.typography.dictionaryTitleLarge,
|
||||||
) {
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
Row(
|
lineLimits = TextFieldLineLimits.SingleLine,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
leadingIcon = {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painter = searchIcon,
|
painter = historyIcon,
|
||||||
contentDescription = null,
|
contentDescription = historyLabel,
|
||||||
tint = placeholderColor,
|
tint = LocalContentColor.current.copy(
|
||||||
modifier = Modifier.size(24.dp)
|
alpha = if (historyEnabled) 1f else 0.38f
|
||||||
)
|
),
|
||||||
|
|
||||||
BasicTextField(
|
|
||||||
state = searchState,
|
|
||||||
enabled = enabled,
|
|
||||||
textStyle = MaterialTheme.typography.titleLarge.copy(color = contentColor),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
|
||||||
lineLimits = TextFieldLineLimits.SingleLine,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.offset(x = 4.dp)
|
||||||
.padding(horizontal = 12.dp)
|
.size(28.dp)
|
||||||
.testTag("search_field")
|
.appTestTag("history_button")
|
||||||
.semantics { testTagsAsResourceId = true }
|
.iconClickable(enabled = historyEnabled, onClick = onShowHistory)
|
||||||
)
|
)
|
||||||
|
},
|
||||||
if (searchState.text.isNotEmpty()) {
|
trailingIcon = if (searchState.text.isNotEmpty()) {
|
||||||
|
{
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
searchState.edit { replace(0, length, "") }
|
searchState.edit { replace(0, length, "") }
|
||||||
onClearQuery()
|
onClearQuery()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.padding(end = 12.dp)
|
||||||
.testTag("clear_search")
|
.testTag("clear_search")
|
||||||
.semantics { testTagsAsResourceId = true }
|
.semantics { testTagsAsResourceId = true }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = closeIcon,
|
painter = closeIcon,
|
||||||
contentDescription = clearLabel,
|
contentDescription = clearLabel,
|
||||||
tint = placeholderColor,
|
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
shape = TopBarShape,
|
||||||
|
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 8.dp
|
||||||
|
),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = containerColor,
|
||||||
|
unfocusedContainerColor = containerColor,
|
||||||
|
disabledContainerColor = containerColor,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.primary,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
.height(TopBarHeight)
|
||||||
|
.testTag("search_field")
|
||||||
|
.semantics { testTagsAsResourceId = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchHistorySheet(
|
||||||
|
history: List<String>,
|
||||||
|
isSearchBarAtBottom: Boolean,
|
||||||
|
topBarBottomPx: Int,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onEntryClick: (String) -> Unit,
|
||||||
|
onEntryRemove: (String) -> Unit,
|
||||||
|
onClearAll: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
||||||
|
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
|
||||||
|
val maxContentHeight = remember {
|
||||||
|
with(density) {
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
|
||||||
} else {
|
} else {
|
||||||
Box(modifier = Modifier.size(40.dp))
|
screenHeight - topBarBottomPx.toDp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
|
||||||
|
modifier = Modifier.testTag("search_history_sheet")
|
||||||
|
) {
|
||||||
|
SearchHistoryList(
|
||||||
|
history = history,
|
||||||
|
onEntryClick = onEntryClick,
|
||||||
|
onEntryRemove = onEntryRemove,
|
||||||
|
onClearAll = onClearAll,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.example.research.ui.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.appTestTag
|
||||||
|
import com.example.research.common.ui.iconClickable
|
||||||
|
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
||||||
|
import com.example.research.ui.theme.Spacing
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchHistoryList(
|
||||||
|
history: List<String>,
|
||||||
|
onEntryClick: (String) -> Unit,
|
||||||
|
onEntryRemove: (String) -> Unit,
|
||||||
|
onClearAll: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.appTestTag("search_history"),
|
||||||
|
state = listState,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
item(key = "history_header", contentType = "history_header") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = Spacing.listItemVertical),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.recent_searches),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.clear_search_history),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
softWrap = false,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 18.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
|
.appTestTag("clear_history")
|
||||||
|
.clickable { onClearAll() }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(
|
||||||
|
items = history,
|
||||||
|
key = { it },
|
||||||
|
contentType = { "history_entry" }
|
||||||
|
) { entry ->
|
||||||
|
SearchHistoryItem(
|
||||||
|
entry = entry,
|
||||||
|
onEntryClick = onEntryClick,
|
||||||
|
onEntryRemove = onEntryRemove
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchHistoryItem(
|
||||||
|
entry: String,
|
||||||
|
onEntryClick: (String) -> Unit,
|
||||||
|
onEntryRemove: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.appTestTag("search_history_entry")
|
||||||
|
.clickable { onEntryClick(entry) }
|
||||||
|
.padding(vertical = Spacing.listItemVertical),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_history),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = entry,
|
||||||
|
style = MaterialTheme.typography.dictionaryTitleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.width(Spacing.topBarHeight)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_close),
|
||||||
|
contentDescription = stringResource(R.string.remove_search_history_entry),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.appTestTag("remove_history_entry")
|
||||||
|
.iconClickable { onEntryRemove(entry) }
|
||||||
|
.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.compose.*
|
import androidx.paging.compose.*
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
||||||
import com.example.research.core.domain.model.IndexEntry
|
import com.example.research.core.domain.model.IndexEntry
|
||||||
import com.example.research.core.util.removeDictionarySuffixes
|
import com.example.research.core.util.removeDictionarySuffixes
|
||||||
import com.example.research.ui.theme.Spacing
|
import com.example.research.ui.theme.Spacing
|
||||||
@@ -28,8 +29,11 @@ fun SearchResultsList(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
showDictionaryName: Boolean = true,
|
showDictionaryName: Boolean = true,
|
||||||
contentPadding: PaddingValues = PaddingValues()
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
|
staleEntries: List<IndexEntry> = emptyList(),
|
||||||
) {
|
) {
|
||||||
|
val showStale = staleEntries.isNotEmpty() && results.itemCount == 0
|
||||||
|
val rowCount = if (showStale) staleEntries.size else results.itemCount
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.testTag("search_results")
|
.testTag("search_results")
|
||||||
@@ -39,11 +43,18 @@ fun SearchResultsList(
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
count = results.itemCount,
|
count = rowCount,
|
||||||
key = results.itemKey { entry -> "${entry.dictionaryPath}_${entry.offset.value}_${entry.word}" },
|
key = { index ->
|
||||||
contentType = results.itemContentType { "search_result" }
|
val entry = if (showStale) staleEntries.getOrNull(index)
|
||||||
|
else if (index in 0 until results.itemCount) results.peek(index) else null
|
||||||
|
entry?.let { "${it.dictionaryPath}:${it.offset.value}:${it.word}" }
|
||||||
|
?: "search_result_$index"
|
||||||
|
},
|
||||||
|
contentType = { "search_result" }
|
||||||
) { index ->
|
) { index ->
|
||||||
results[index]?.let { entry ->
|
val entry = if (showStale) staleEntries.getOrNull(index)
|
||||||
|
else if (index in 0 until results.itemCount) results.peek(index) else null
|
||||||
|
if (entry != null) {
|
||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
resultIndex = index,
|
resultIndex = index,
|
||||||
@@ -83,7 +94,7 @@ private fun SearchResultItem(
|
|||||||
showDictionaryName: Boolean = true
|
showDictionaryName: Boolean = true
|
||||||
) {
|
) {
|
||||||
// Cache text styles to avoid recreation on every composition
|
// Cache text styles to avoid recreation on every composition
|
||||||
val titleStyle = MaterialTheme.typography.titleLarge
|
val titleStyle = MaterialTheme.typography.dictionaryTitleLarge
|
||||||
val subtitleStyle = MaterialTheme.typography.bodySmall
|
val subtitleStyle = MaterialTheme.typography.bodySmall
|
||||||
val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
|
val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
package com.example.research.ui.navigation
|
package com.example.research.ui.navigation
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.*
|
import androidx.compose.runtime.saveable.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
|
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
|
||||||
import com.example.research.feature.search.SearchAction
|
import com.example.research.feature.search.SearchAction
|
||||||
import com.example.research.feature.search.SearchViewModel
|
import com.example.research.feature.search.SearchViewModel
|
||||||
import com.example.research.feature.download.model.DownloadState
|
|
||||||
import com.example.research.ui.about.AboutScreen
|
import com.example.research.ui.about.AboutScreen
|
||||||
import com.example.research.ui.article.ArticleRoute
|
import com.example.research.ui.article.ArticleScreen
|
||||||
|
import com.example.research.ui.article.ArticleState
|
||||||
import com.example.research.ui.main.MainRoute
|
import com.example.research.ui.main.MainRoute
|
||||||
import com.example.research.ui.settings.ImportState
|
|
||||||
import com.example.research.ui.settings.SettingsRoute
|
import com.example.research.ui.settings.SettingsRoute
|
||||||
import com.example.research.ui.settings.SettingsViewModel
|
import com.example.research.ui.settings.SettingsViewModel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
private data class NavigationState(
|
private data class NavigationState(
|
||||||
val shouldShowSettings: Boolean,
|
val shouldShowSettings: Boolean,
|
||||||
@@ -29,11 +38,11 @@ enum class Screen {
|
|||||||
Home, Settings, Article, About
|
Home, Settings, Article, About
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
searchViewModel: SearchViewModel,
|
searchViewModel: SearchViewModel,
|
||||||
settingsViewModel: SettingsViewModel
|
settingsViewModel: SettingsViewModel,
|
||||||
|
seedNoDictionaries: Boolean = false
|
||||||
) {
|
) {
|
||||||
val screenStack = rememberSaveable(
|
val screenStack = rememberSaveable(
|
||||||
saver = listSaver(
|
saver = listSaver(
|
||||||
@@ -53,39 +62,15 @@ fun AppNavigation(
|
|||||||
val currentScreen = screenStack.lastOrNull() ?: Screen.Home
|
val currentScreen = screenStack.lastOrNull() ?: Screen.Home
|
||||||
|
|
||||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val progressSnapshot by settingsViewModel.progressSnapshot.collectAsStateWithLifecycle()
|
|
||||||
val isPipelineActiveByState = remember(
|
|
||||||
progressSnapshot,
|
|
||||||
settingsState.indexingProgress.isIndexing,
|
|
||||||
settingsState.downloadState,
|
|
||||||
settingsState.importState,
|
|
||||||
) {
|
|
||||||
progressSnapshot != null ||
|
|
||||||
settingsState.indexingProgress.isIndexing ||
|
|
||||||
settingsState.downloadState is DownloadState.Loading ||
|
|
||||||
settingsState.downloadState is DownloadState.Extracting ||
|
|
||||||
settingsState.downloadState is DownloadState.Success ||
|
|
||||||
settingsState.importState is ImportState.Importing ||
|
|
||||||
settingsState.importState is ImportState.Extracting ||
|
|
||||||
settingsState.importState is ImportState.Success
|
|
||||||
}
|
|
||||||
var stickyPipelineActive by rememberSaveable { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(isPipelineActiveByState) {
|
|
||||||
if (isPipelineActiveByState) {
|
|
||||||
stickyPipelineActive = true
|
|
||||||
} else {
|
|
||||||
delay(1500.milliseconds)
|
|
||||||
stickyPipelineActive = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val navState by remember {
|
val navState by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val isOperationActive = stickyPipelineActive
|
val isOperationActive = !settingsState.pipelineIdle
|
||||||
val hasConfirmedNoDictionaries =
|
val hasConfirmedNoDictionaries =
|
||||||
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
|
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
|
||||||
|
val hasSeededNoDictionaries = seedNoDictionaries && !settingsState.hasCompletedStartupScan
|
||||||
NavigationState(
|
NavigationState(
|
||||||
shouldShowSettings = hasConfirmedNoDictionaries || isOperationActive,
|
shouldShowSettings = hasConfirmedNoDictionaries || hasSeededNoDictionaries || isOperationActive,
|
||||||
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
|
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
|
||||||
isOperationActive = isOperationActive,
|
isOperationActive = isOperationActive,
|
||||||
)
|
)
|
||||||
@@ -95,10 +80,16 @@ fun AppNavigation(
|
|||||||
val shouldShowSettings = navState.shouldShowSettings
|
val shouldShowSettings = navState.shouldShowSettings
|
||||||
val showBackButtonInSettings = navState.showBackButtonInSettings
|
val showBackButtonInSettings = navState.showBackButtonInSettings
|
||||||
val isOperationActive = navState.isOperationActive
|
val isOperationActive = navState.isOperationActive
|
||||||
val isWideScreen = currentWindowAdaptiveInfo()
|
val isWideScreen = currentWindowAdaptiveInfoV2()
|
||||||
.windowSizeClass
|
.windowSizeClass
|
||||||
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
|
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
|
||||||
|
|
||||||
|
LaunchedEffect(isWideScreen) {
|
||||||
|
if (isWideScreen) {
|
||||||
|
screenStack.removeAll { it == Screen.Article }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) {
|
BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) {
|
||||||
if (currentScreen == Screen.Article) {
|
if (currentScreen == Screen.Article) {
|
||||||
if (searchViewModel.navigateBackFromArticle()) {
|
if (searchViewModel.navigateBackFromArticle()) {
|
||||||
@@ -112,14 +103,33 @@ fun AppNavigation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
||||||
|
var lastShownArticle by remember { mutableStateOf<ArticleState?>(null) }
|
||||||
|
LaunchedEffect(articleState) {
|
||||||
|
if (articleState != null) lastShownArticle = articleState
|
||||||
|
}
|
||||||
|
|
||||||
val effectiveScreen = when {
|
val effectiveScreen = when {
|
||||||
currentScreen == Screen.About -> Screen.About
|
currentScreen == Screen.About -> Screen.About
|
||||||
currentScreen == Screen.Article && articleState == null -> if (shouldShowSettings) Screen.Settings else Screen.Home
|
|
||||||
shouldShowSettings -> Screen.Settings
|
shouldShowSettings -> Screen.Settings
|
||||||
else -> currentScreen
|
currentScreen == Screen.Settings -> Screen.Settings
|
||||||
|
else -> Screen.Home
|
||||||
}
|
}
|
||||||
when (effectiveScreen) {
|
Box {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = effectiveScreen,
|
||||||
|
transitionSpec = {
|
||||||
|
val forward = initialState.ordinal < targetState.ordinal
|
||||||
|
(
|
||||||
|
fadeIn(tween(180)) +
|
||||||
|
slideInHorizontally(tween(180)) { if (forward) it / 3 else -it / 3 }
|
||||||
|
).togetherWith(
|
||||||
|
fadeOut(tween(180)) +
|
||||||
|
slideOutHorizontally(tween(180)) { if (forward) -it / 3 else it / 3 }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = "screenTransition",
|
||||||
|
) { screen ->
|
||||||
|
when (screen) {
|
||||||
Screen.Home -> {
|
Screen.Home -> {
|
||||||
MainRoute(
|
MainRoute(
|
||||||
searchViewModel = searchViewModel,
|
searchViewModel = searchViewModel,
|
||||||
@@ -150,18 +160,6 @@ fun AppNavigation(
|
|||||||
onNavigateToAbout = { screenStack.add(Screen.About) }
|
onNavigateToAbout = { screenStack.add(Screen.About) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Screen.Article -> {
|
|
||||||
ArticleRoute(
|
|
||||||
searchViewModel = searchViewModel,
|
|
||||||
onNavigateBack = {
|
|
||||||
if (searchViewModel.navigateBackFromArticle()) {
|
|
||||||
return@ArticleRoute
|
|
||||||
}
|
|
||||||
searchViewModel.onAction(SearchAction.ClearArticle)
|
|
||||||
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Screen.About -> {
|
Screen.About -> {
|
||||||
AboutScreen(
|
AboutScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
@@ -169,5 +167,36 @@ fun AppNavigation(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Screen.Article -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = currentScreen == Screen.Article && articleState != null,
|
||||||
|
enter = fadeIn(tween(180)) + slideInHorizontally(tween(180)) { it / 3 },
|
||||||
|
exit = fadeOut(tween(180)) + slideOutHorizontally(tween(180)) { it / 3 },
|
||||||
|
label = "articleOverlay",
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
ArticleScreen(
|
||||||
|
articleState = articleState ?: lastShownArticle,
|
||||||
|
onNavigateBack = {
|
||||||
|
if (!searchViewModel.navigateBackFromArticle()) {
|
||||||
|
searchViewModel.onAction(SearchAction.ClearArticle)
|
||||||
|
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRefClick = { word ->
|
||||||
|
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
|
||||||
|
},
|
||||||
|
onScrollPositionChanged = { index, offset ->
|
||||||
|
searchViewModel.updateArticleScrollPosition(index, offset)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.example.research.ui.settings
|
package com.example.research.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface ImportState {
|
sealed interface ImportState {
|
||||||
data object Idle : ImportState
|
data object Idle : ImportState
|
||||||
data class Importing(val progress: Float) : ImportState
|
data class Importing(val progress: Float) : ImportState
|
||||||
@@ -7,3 +10,6 @@ sealed interface ImportState {
|
|||||||
data object Success : ImportState
|
data object Success : ImportState
|
||||||
data class Error(val message: String) : ImportState
|
data class Error(val message: String) : ImportState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val ImportState.isActive: Boolean
|
||||||
|
get() = this is ImportState.Importing || this is ImportState.Extracting || this is ImportState.Success
|
||||||
@@ -3,6 +3,11 @@ package com.example.research.ui.settings
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -16,7 +21,6 @@ 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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -41,17 +45,18 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.AppBackButton
|
||||||
import com.example.research.common.ui.appTestTag
|
import com.example.research.common.ui.appTestTag
|
||||||
import com.example.research.common.ui.components.CollapsibleHeader
|
import com.example.research.common.ui.components.CollapsibleHeader
|
||||||
import com.example.research.common.ui.components.SectionCard
|
import com.example.research.common.ui.components.SectionCard
|
||||||
import com.example.research.ui.settings.components.DictionaryManagement
|
import com.example.research.ui.settings.components.DictionaryManagement
|
||||||
import com.example.research.ui.settings.components.LanguageSelectionSection
|
import com.example.research.ui.settings.components.LanguageSelectionSection
|
||||||
|
import com.example.research.ui.settings.components.SearchBarPositionSection
|
||||||
import com.example.research.ui.settings.components.ThemeSelectionSection
|
import com.example.research.ui.settings.components.ThemeSelectionSection
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
@@ -121,12 +126,10 @@ fun SettingsScreen(
|
|||||||
title = { Text(stringResource(R.string.settings_title)) },
|
title = { Text(stringResource(R.string.settings_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (showBackButton) {
|
if (showBackButton) {
|
||||||
IconButton(onClick = onNavigateBack) {
|
AppBackButton(
|
||||||
Icon(
|
onClick = onNavigateBack,
|
||||||
painter = painterResource(R.drawable.ic_arrow_back),
|
modifier = Modifier.padding(start = 6.dp)
|
||||||
contentDescription = stringResource(R.string.action_back)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -235,6 +238,24 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item(key = "search_bar", contentType = "settings-section") {
|
||||||
|
CollapsibleSettingsSection(
|
||||||
|
title = stringResource(R.string.search_bar_position),
|
||||||
|
iconRes = R.drawable.ic_search,
|
||||||
|
testTag = "search_bar_section",
|
||||||
|
isExpanded = uiState.isSearchBarExpanded,
|
||||||
|
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("search_bar", !uiState.isSearchBarExpanded)) },
|
||||||
|
) {
|
||||||
|
SearchBarPositionSection(
|
||||||
|
currentPosition = uiState.searchBarPosition,
|
||||||
|
onPositionChanged = { position ->
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
onEvent(SettingsUiEvent.SearchBarPositionChanged(position))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item(key = "bottom-spacer", contentType = "spacer") {
|
item(key = "bottom-spacer", contentType = "spacer") {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
@@ -266,7 +287,11 @@ private fun CollapsibleSettingsSection(
|
|||||||
testTag = testTag
|
testTag = testTag
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isExpanded) {
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
package com.example.research.ui.settings
|
package com.example.research.ui.settings
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import com.example.research.core.domain.model.AppTheme
|
import com.example.research.core.domain.model.AppTheme
|
||||||
|
import com.example.research.core.domain.model.SearchBarPosition
|
||||||
import com.example.research.core.domain.model.Dictionary
|
import com.example.research.core.domain.model.Dictionary
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
import com.example.research.core.domain.model.IndexingProgress
|
import com.example.research.core.domain.model.IndexingProgress
|
||||||
import com.example.research.DictionaryStatus
|
import com.example.research.DictionaryStatus
|
||||||
import com.example.research.feature.download.model.DownloadState
|
import com.example.research.feature.download.model.DownloadState
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val theme: AppTheme = AppTheme.SYSTEM,
|
val theme: AppTheme = AppTheme.SYSTEM,
|
||||||
val language: String = "system",
|
val language: String = "system",
|
||||||
|
val searchBarPosition: SearchBarPosition = SearchBarPosition.TOP,
|
||||||
val isThemeExpanded: Boolean = false,
|
val isThemeExpanded: Boolean = false,
|
||||||
val isLanguageExpanded: Boolean = false,
|
val isLanguageExpanded: Boolean = false,
|
||||||
|
val isSearchBarExpanded: Boolean = false,
|
||||||
val isDictionariesExpanded: Boolean = true,
|
val isDictionariesExpanded: Boolean = true,
|
||||||
val dictionaries: List<Dictionary> = emptyList(),
|
val dictionaries: List<Dictionary> = emptyList(),
|
||||||
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
|
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
|
||||||
@@ -21,12 +26,14 @@ data class SettingsUiState(
|
|||||||
val importState: ImportState = ImportState.Idle,
|
val importState: ImportState = ImportState.Idle,
|
||||||
val dictionarySources: List<DictionarySource> = emptyList(),
|
val dictionarySources: List<DictionarySource> = emptyList(),
|
||||||
val hasCompletedStartupScan: Boolean = false,
|
val hasCompletedStartupScan: Boolean = false,
|
||||||
|
val pipelineIdle: Boolean = true,
|
||||||
val appVersion: String = "1.0",
|
val appVersion: String = "1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsUiEvent {
|
sealed interface SettingsUiEvent {
|
||||||
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
|
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
|
||||||
data class LanguageChanged(val language: String) : SettingsUiEvent
|
data class LanguageChanged(val language: String) : SettingsUiEvent
|
||||||
|
data class SearchBarPositionChanged(val position: SearchBarPosition) : SettingsUiEvent
|
||||||
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
|
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
|
||||||
data class DictionaryToggled(val path: String) : SettingsUiEvent
|
data class DictionaryToggled(val path: String) : SettingsUiEvent
|
||||||
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
|
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
|
||||||
|
|||||||
@@ -7,17 +7,22 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.example.research.DictionaryStatus
|
import com.example.research.DictionaryStatus
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
import com.example.research.common.progress.DictionaryProgressStateHolder
|
import com.example.research.common.progress.DictionaryProgressStateHolder
|
||||||
|
import com.example.research.common.util.DateUtils
|
||||||
import com.example.research.core.domain.model.AppTheme
|
import com.example.research.core.domain.model.AppTheme
|
||||||
|
import com.example.research.core.domain.model.SearchBarPosition
|
||||||
import com.example.research.core.domain.model.Dictionary
|
import com.example.research.core.domain.model.Dictionary
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
import com.example.research.core.domain.model.IndexingProgress
|
import com.example.research.core.domain.model.IndexingProgress
|
||||||
|
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
|
||||||
import com.example.research.core.domain.usecase.DictionarySourceValidator
|
import com.example.research.core.domain.usecase.DictionarySourceValidator
|
||||||
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
|
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
|
||||||
import com.example.research.core.util.OperationResult
|
import com.example.research.core.util.OperationResult
|
||||||
import com.example.research.data.local.preferences.PreferencesManager
|
import com.example.research.data.local.preferences.PreferencesManager
|
||||||
|
import com.example.research.data.local.preferences.SectionExpansionStates
|
||||||
import com.example.research.data.repository.LocalDictionaryRepository
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.feature.download.DownloadManager
|
import com.example.research.feature.download.DownloadManager
|
||||||
import com.example.research.feature.download.model.DownloadState
|
import com.example.research.feature.download.model.DownloadState
|
||||||
|
import com.example.research.feature.download.model.isActive
|
||||||
import com.example.research.feature.download.repository.DictionaryRepository
|
import com.example.research.feature.download.repository.DictionaryRepository
|
||||||
import com.example.research.feature.import.DictionaryImportManager
|
import com.example.research.feature.import.DictionaryImportManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -30,7 +35,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
private data class DictionaryStateInputs(
|
private data class DictionaryStateInputs(
|
||||||
val theme: AppTheme,
|
val theme: AppTheme,
|
||||||
@@ -42,11 +47,13 @@ private data class DictionaryStateInputs(
|
|||||||
private data class OperationStateInputs(
|
private data class OperationStateInputs(
|
||||||
val downloadState: DownloadState,
|
val downloadState: DownloadState,
|
||||||
val language: String,
|
val language: String,
|
||||||
|
val searchBarPosition: SearchBarPosition,
|
||||||
val importState: ImportState,
|
val importState: ImportState,
|
||||||
val dictionaryStatus: DictionaryStatus,
|
val dictionaryStatus: DictionaryStatus,
|
||||||
val hasCompletedStartupScan: Boolean,
|
val hasCompletedStartupScan: Boolean,
|
||||||
val isThemeExpanded: Boolean,
|
val isThemeExpanded: Boolean,
|
||||||
val isLanguageExpanded: Boolean,
|
val isLanguageExpanded: Boolean,
|
||||||
|
val isSearchBarExpanded: Boolean,
|
||||||
val isDictionariesExpanded: Boolean,
|
val isDictionariesExpanded: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +66,8 @@ class SettingsViewModel(
|
|||||||
private val dictionaryImportManager: DictionaryImportManager,
|
private val dictionaryImportManager: DictionaryImportManager,
|
||||||
progressStateHolder: DictionaryProgressStateHolder,
|
progressStateHolder: DictionaryProgressStateHolder,
|
||||||
appVersion: String,
|
appVersion: String,
|
||||||
|
initialSectionExpansion: SectionExpansionStates,
|
||||||
|
initialSearchBarPosition: SearchBarPosition,
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
|
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
|
||||||
@@ -66,7 +75,16 @@ class SettingsViewModel(
|
|||||||
validator = DictionarySourceValidator(),
|
validator = DictionarySourceValidator(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion))
|
private val _uiState = MutableStateFlow(
|
||||||
|
SettingsUiState(
|
||||||
|
appVersion = appVersion,
|
||||||
|
isThemeExpanded = initialSectionExpansion.isThemeExpanded,
|
||||||
|
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
|
||||||
|
isSearchBarExpanded = initialSectionExpansion.isSearchBarExpanded,
|
||||||
|
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
|
||||||
|
searchBarPosition = initialSearchBarPosition,
|
||||||
|
)
|
||||||
|
)
|
||||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
val progressSnapshot = progressStateHolder.progressSnapshot
|
val progressSnapshot = progressStateHolder.progressSnapshot
|
||||||
@@ -79,18 +97,49 @@ class SettingsViewModel(
|
|||||||
private val language = MutableStateFlow("system")
|
private val language = MutableStateFlow("system")
|
||||||
private val hasCompletedStartupScan = MutableStateFlow(false)
|
private val hasCompletedStartupScan = MutableStateFlow(false)
|
||||||
|
|
||||||
private val pendingSourceUrls = mutableSetOf<String>()
|
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
|
||||||
private var isDownloadInProgress = false
|
private var isDownloadInProgress = false
|
||||||
|
|
||||||
|
private var cancelRefreshPending = false
|
||||||
|
private var errorStateHandled = false
|
||||||
|
private var successStateHandled = false
|
||||||
|
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupStateObservation()
|
setupStateObservation()
|
||||||
|
observeDictionariesForSeedFlag()
|
||||||
|
persistDictionaryStatus()
|
||||||
initialize()
|
initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun persistDictionaryStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
dictionaryStatus.collect { status ->
|
||||||
|
val persistedName = when (status) {
|
||||||
|
DictionaryStatus.UpToDate -> "UpToDate"
|
||||||
|
DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
|
||||||
|
DictionaryStatus.Empty -> "Empty"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (persistedName != null) {
|
||||||
|
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeDictionariesForSeedFlag() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
localDictionaryRepository.dictionaries.collect { dictionaries ->
|
||||||
|
if (hasCompletedStartupScan.value) {
|
||||||
|
preferencesManager.setHadNoDictionaries(dictionaries.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupStateObservation() {
|
private fun setupStateObservation() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
var wasIndexing = false
|
|
||||||
|
|
||||||
val dictionaryStateInputs = combine(
|
val dictionaryStateInputs = combine(
|
||||||
preferencesManager.themeMode.map { mode ->
|
preferencesManager.themeMode.map { mode ->
|
||||||
try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM }
|
try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM }
|
||||||
@@ -109,80 +158,80 @@ class SettingsViewModel(
|
|||||||
val operationStateInputs = combine(
|
val operationStateInputs = combine(
|
||||||
downloadManager.downloadState,
|
downloadManager.downloadState,
|
||||||
language,
|
language,
|
||||||
|
preferencesManager.searchBarPosition,
|
||||||
dictionaryImportManager.importState,
|
dictionaryImportManager.importState,
|
||||||
dictionaryStatus,
|
dictionaryStatus,
|
||||||
hasCompletedStartupScan,
|
hasCompletedStartupScan,
|
||||||
preferencesManager.isThemeExpanded,
|
preferencesManager.isThemeExpanded,
|
||||||
preferencesManager.isLanguageExpanded,
|
preferencesManager.isLanguageExpanded,
|
||||||
|
preferencesManager.isSearchBarExpanded,
|
||||||
preferencesManager.isDictionariesExpanded,
|
preferencesManager.isDictionariesExpanded,
|
||||||
) { params: Array<Any> ->
|
) { params: Array<Any> ->
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
OperationStateInputs(
|
OperationStateInputs(
|
||||||
downloadState = params[0] as DownloadState,
|
downloadState = params[0] as DownloadState,
|
||||||
language = params[1] as String,
|
language = params[1] as String,
|
||||||
importState = params[2] as ImportState,
|
searchBarPosition = params[2] as SearchBarPosition,
|
||||||
dictionaryStatus = params[3] as DictionaryStatus,
|
importState = params[3] as ImportState,
|
||||||
hasCompletedStartupScan = params[4] as Boolean,
|
dictionaryStatus = params[4] as DictionaryStatus,
|
||||||
isThemeExpanded = params[5] as Boolean,
|
hasCompletedStartupScan = params[5] as Boolean,
|
||||||
isLanguageExpanded = params[6] as Boolean,
|
isThemeExpanded = params[6] as Boolean,
|
||||||
isDictionariesExpanded = params[7] as Boolean,
|
isLanguageExpanded = params[7] as Boolean,
|
||||||
|
isSearchBarExpanded = params[8] as Boolean,
|
||||||
|
isDictionariesExpanded = params[9] as Boolean,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState ->
|
combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState ->
|
||||||
// Handle indexing completion side effect
|
|
||||||
val isIndexing = dictionaryState.indexingProgress.isIndexing
|
val isIndexing = dictionaryState.indexingProgress.isIndexing
|
||||||
if (wasIndexing && !isIndexing) {
|
|
||||||
if (isDownloadInProgress) {
|
|
||||||
isDownloadInProgress = false
|
|
||||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wasIndexing = isIndexing
|
|
||||||
|
|
||||||
when (operationState.downloadState) {
|
when (operationState.downloadState) {
|
||||||
is DownloadState.Loading, is DownloadState.Extracting -> {
|
is DownloadState.Loading, is DownloadState.Extracting -> {
|
||||||
isDownloadInProgress = true
|
isDownloadInProgress = true
|
||||||
|
cancelRefreshPending = false
|
||||||
|
errorStateHandled = false
|
||||||
|
successStateHandled = false
|
||||||
}
|
}
|
||||||
is DownloadState.Success -> {
|
is DownloadState.Success -> {
|
||||||
pendingSourceUrls.clear()
|
pendingSourceUrls.clear()
|
||||||
|
isDownloadInProgress = false
|
||||||
|
if (!successStateHandled) {
|
||||||
|
successStateHandled = true
|
||||||
|
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||||
|
evaluateDictionaryStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is DownloadState.Error -> {
|
is DownloadState.Error -> {
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
if (!errorStateHandled) {
|
||||||
DictionaryStatus.Empty
|
errorStateHandled = true
|
||||||
} else {
|
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
||||||
DictionaryStatus.UpToDate
|
DictionaryStatus.Empty
|
||||||
}
|
} else when (statusBeforeDownload) {
|
||||||
if (pendingSourceUrls.isNotEmpty()) {
|
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
|
||||||
|
else -> statusBeforeDownload
|
||||||
pendingSourceUrls.forEach { urlTemplate ->
|
}
|
||||||
val source = dictionaryState.dictionarySources.find {
|
if (pendingSourceUrls.isNotEmpty()) {
|
||||||
it.urlTemplate == urlTemplate
|
pendingSourceUrls.forEach { urlTemplate ->
|
||||||
}
|
val source = dictionaryState.dictionarySources.find {
|
||||||
if (source != null) {
|
it.urlTemplate == urlTemplate
|
||||||
val hasDictionary = dictionaryState.dictionaries.any { dict ->
|
|
||||||
DictionarySource.matchesDictionaryFile(
|
|
||||||
urlTemplate,
|
|
||||||
File(dict.path).name
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (source != null) {
|
||||||
if (!hasDictionary) {
|
val hasDictionary = dictionaryState.dictionaries.any { dictionary ->
|
||||||
manageDictionarySourcesUseCase.removeSource(source.id)
|
DictionarySourceFileMatcher.matches(source, dictionary)
|
||||||
|
}
|
||||||
|
if (!hasDictionary) {
|
||||||
|
manageDictionarySourcesUseCase.removeSource(source.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pendingSourceUrls.clear()
|
||||||
}
|
}
|
||||||
pendingSourceUrls.clear()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DownloadState.Cancelled -> {
|
is DownloadState.Cancelled -> {
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
cancelRefreshPending = true
|
||||||
DictionaryStatus.Empty
|
|
||||||
} else {
|
|
||||||
DictionaryStatus.UpToDate
|
|
||||||
}
|
|
||||||
if (pendingSourceUrls.isNotEmpty()) {
|
if (pendingSourceUrls.isNotEmpty()) {
|
||||||
pendingSourceUrls.forEach { urlTemplate ->
|
pendingSourceUrls.forEach { urlTemplate ->
|
||||||
val source = dictionaryState.dictionarySources.find {
|
val source = dictionaryState.dictionarySources.find {
|
||||||
@@ -193,7 +242,12 @@ class SettingsViewModel(
|
|||||||
pendingSourceUrls.clear()
|
pendingSourceUrls.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
is DownloadState.Idle -> {
|
||||||
|
if (cancelRefreshPending) {
|
||||||
|
cancelRefreshPending = false
|
||||||
|
refreshDictionaryStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsUiState(
|
SettingsUiState(
|
||||||
@@ -203,11 +257,16 @@ class SettingsViewModel(
|
|||||||
indexingProgress = IndexingProgress(isIndexing = isIndexing),
|
indexingProgress = IndexingProgress(isIndexing = isIndexing),
|
||||||
downloadState = operationState.downloadState,
|
downloadState = operationState.downloadState,
|
||||||
language = operationState.language,
|
language = operationState.language,
|
||||||
|
searchBarPosition = operationState.searchBarPosition,
|
||||||
importState = operationState.importState,
|
importState = operationState.importState,
|
||||||
dictionaryStatus = operationState.dictionaryStatus,
|
dictionaryStatus = operationState.dictionaryStatus,
|
||||||
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
|
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
|
||||||
|
pipelineIdle = !isIndexing &&
|
||||||
|
!operationState.downloadState.isActive &&
|
||||||
|
!operationState.importState.isActive,
|
||||||
isThemeExpanded = operationState.isThemeExpanded,
|
isThemeExpanded = operationState.isThemeExpanded,
|
||||||
isLanguageExpanded = operationState.isLanguageExpanded,
|
isLanguageExpanded = operationState.isLanguageExpanded,
|
||||||
|
isSearchBarExpanded = operationState.isSearchBarExpanded,
|
||||||
isDictionariesExpanded = operationState.isDictionariesExpanded,
|
isDictionariesExpanded = operationState.isDictionariesExpanded,
|
||||||
appVersion = _uiState.value.appVersion,
|
appVersion = _uiState.value.appVersion,
|
||||||
)
|
)
|
||||||
@@ -218,9 +277,15 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initialize() {
|
private fun initialize() {
|
||||||
|
var scanCompleted = false
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
scanDirectoryInternal(preferencesManager.dictionaryPath)
|
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
|
||||||
|
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
|
||||||
|
} else {
|
||||||
|
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||||
|
false
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("SettingsViewModel", "Initial scan failed", e)
|
android.util.Log.e("SettingsViewModel", "Initial scan failed", e)
|
||||||
dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
|
dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
|
||||||
@@ -237,6 +302,11 @@ class SettingsViewModel(
|
|||||||
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
|
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
|
||||||
}
|
}
|
||||||
hasCompletedStartupScan.value = true
|
hasCompletedStartupScan.value = true
|
||||||
|
if (scanCompleted) {
|
||||||
|
preferencesManager.setHadNoDictionaries(
|
||||||
|
localDictionaryRepository.dictionaries.value.isEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +315,7 @@ class SettingsViewModel(
|
|||||||
when (event) {
|
when (event) {
|
||||||
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
|
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
|
||||||
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
|
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
|
||||||
|
is SettingsUiEvent.SearchBarPositionChanged -> saveSearchBarPosition(event.position)
|
||||||
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
|
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
|
||||||
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
|
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
|
||||||
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
|
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
|
||||||
@@ -259,26 +330,35 @@ class SettingsViewModel(
|
|||||||
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
|
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
|
||||||
return try {
|
return try {
|
||||||
val actualDictionaries = localDictionaryRepository.dictionaries.first()
|
val actualDictionaries = localDictionaryRepository.dictionaries.first()
|
||||||
val hasDictionaries = actualDictionaries.isNotEmpty()
|
var sources = preferencesManager.dictionarySources.first()
|
||||||
|
if (pendingSourceUrls.isEmpty() &&
|
||||||
|
downloadManager.downloadState.value !is DownloadState.Loading &&
|
||||||
|
downloadManager.downloadState.value !is DownloadState.Extracting
|
||||||
|
) {
|
||||||
|
val dictionaryFileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
|
||||||
|
preferencesManager.dictionaryPath
|
||||||
|
)
|
||||||
|
if (dictionaryFileNames != null) {
|
||||||
|
val installedSources = DictionarySourceFileMatcher.installedSourcesForFileNames(
|
||||||
|
sources,
|
||||||
|
dictionaryFileNames,
|
||||||
|
)
|
||||||
|
val installedSourceIds = installedSources
|
||||||
|
.mapTo(mutableSetOf(), DictionarySource::id)
|
||||||
|
val staleSourceIds = sources
|
||||||
|
.filterNot { it.id in installedSourceIds }
|
||||||
|
.map(DictionarySource::id)
|
||||||
|
if (staleSourceIds.isNotEmpty()) {
|
||||||
|
preferencesManager.removeDictionarySources(staleSourceIds)
|
||||||
|
sources = installedSources
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasDictionaries) {
|
if (actualDictionaries.isEmpty()) {
|
||||||
return DictionaryStatus.Empty
|
return DictionaryStatus.Empty
|
||||||
}
|
}
|
||||||
|
|
||||||
var sources = preferencesManager.dictionarySources.first()
|
|
||||||
if (downloadManager.downloadState.value !is DownloadState.Loading &&
|
|
||||||
downloadManager.downloadState.value !is DownloadState.Extracting
|
|
||||||
) {
|
|
||||||
val installedSources = installedSources(sources, actualDictionaries)
|
|
||||||
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id)
|
|
||||||
val staleSourceIds = sources
|
|
||||||
.filterNot { it.id in installedSourceIds }
|
|
||||||
.map(DictionarySource::id)
|
|
||||||
if (staleSourceIds.isNotEmpty()) {
|
|
||||||
preferencesManager.removeDictionarySources(staleSourceIds)
|
|
||||||
sources = installedSources
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val enabledSources = installedEnabledSources(sources, actualDictionaries)
|
val enabledSources = installedEnabledSources(sources, actualDictionaries)
|
||||||
|
|
||||||
if (enabledSources.isEmpty()) {
|
if (enabledSources.isEmpty()) {
|
||||||
@@ -308,6 +388,12 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveSearchBarPosition(position: SearchBarPosition) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesManager.saveSearchBarPosition(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveLanguage(languageCode: String) {
|
private fun saveLanguage(languageCode: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesManager.saveLanguage(languageCode)
|
preferencesManager.saveLanguage(languageCode)
|
||||||
@@ -326,50 +412,69 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun toggleDictionaryActive(dictionaryPath: String) {
|
private fun toggleDictionaryActive(dictionaryPath: String) {
|
||||||
localDictionaryRepository.toggleDictionaryActive(dictionaryPath)
|
viewModelScope.launch {
|
||||||
|
localDictionaryRepository.toggleDictionaryActive(dictionaryPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteDictionary(dictionary: Dictionary) {
|
private fun deleteDictionary(dictionary: Dictionary) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localDictionaryRepository.deleteDictionary(dictionary)
|
when (localDictionaryRepository.deleteDictionary(dictionary)) {
|
||||||
|
is OperationResult.Success -> {
|
||||||
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary)
|
val remainingDictionaryFileNames =
|
||||||
|
localDictionaryRepository.listDictionaryPayloadFileNames(
|
||||||
val remainingDictionaries = localDictionaryRepository.dictionaries.first()
|
preferencesManager.dictionaryPath
|
||||||
|
)
|
||||||
if (remainingDictionaries.isEmpty()) {
|
manageDictionarySourcesUseCase.removeSourceForDictionary(
|
||||||
dictionaryStatus.value = DictionaryStatus.Empty
|
dictionary = dictionary,
|
||||||
|
remainingDictionaryFileNames = remainingDictionaryFileNames,
|
||||||
|
)
|
||||||
|
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||||
|
evaluateDictionaryStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is OperationResult.Error -> {
|
||||||
|
effectChannel.send(
|
||||||
|
getApplication<Application>().getString(R.string.error_delete_dictionary)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addDictionarySources(urlTemplates: List<String>) {
|
private fun addDictionarySources(urlTemplates: List<String>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
val validUrls = mutableListOf<String>()
|
||||||
val validUrls = mutableListOf<String>()
|
|
||||||
|
|
||||||
urlTemplates.forEach { urlTemplate ->
|
urlTemplates.forEach { urlTemplate ->
|
||||||
val trimmed = urlTemplate.trim()
|
val trimmed = urlTemplate.trim()
|
||||||
if (trimmed.isEmpty() || trimmed.length > 2048) {
|
if (trimmed.isEmpty() || trimmed.length > 2048) {
|
||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val normalizedUrl = DictionarySource.normalizeTemplate(trimmed)
|
||||||
|
if (!pendingSourceUrls.add(normalizedUrl)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
|
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
|
||||||
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
|
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
|
||||||
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
|
|
||||||
validUrls.add(trimmed)
|
validUrls.add(trimmed)
|
||||||
}
|
}
|
||||||
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
|
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
|
||||||
|
pendingSourceUrls.remove(normalizedUrl)
|
||||||
// Validation failed, skip this source
|
// Validation failed, skip this source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingSourceUrls.remove(normalizedUrl)
|
||||||
|
android.util.Log.e("SettingsViewModel", "Error adding dictionary source", e)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (validUrls.isNotEmpty()) {
|
if (validUrls.isNotEmpty()) {
|
||||||
startDownloadForSources(validUrls)
|
startDownloadForSources(validUrls)
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,6 +488,7 @@ class SettingsViewModel(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusBeforeDownload = dictionaryStatus.value
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
isDownloadInProgress = true
|
isDownloadInProgress = true
|
||||||
|
|
||||||
@@ -406,11 +512,8 @@ class SettingsViewModel(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusBeforeDownload = dictionaryStatus.value
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
|
||||||
dictionaryStatus.value = status
|
|
||||||
|
|
||||||
if (status !is DictionaryStatus.NeedsUpdate) return@launch
|
|
||||||
|
|
||||||
val installedSources = installedEnabledSources(
|
val installedSources = installedEnabledSources(
|
||||||
preferencesManager.dictionarySources.first(),
|
preferencesManager.dictionarySources.first(),
|
||||||
@@ -445,12 +548,25 @@ class SettingsViewModel(
|
|||||||
private fun installedSources(
|
private fun installedSources(
|
||||||
sources: List<DictionarySource>,
|
sources: List<DictionarySource>,
|
||||||
dictionaries: List<Dictionary>
|
dictionaries: List<Dictionary>
|
||||||
): List<DictionarySource> {
|
): List<DictionarySource> =
|
||||||
val installedFileNames = dictionaries.map { File(it.path).name }
|
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
|
||||||
return sources.filter { source ->
|
|
||||||
installedFileNames.any { fileName ->
|
private suspend fun installedDictionariesAreCurrent(): Boolean {
|
||||||
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
|
val sources = preferencesManager.dictionarySources.first()
|
||||||
}
|
val dictionaries = localDictionaryRepository.dictionaries.first()
|
||||||
|
val datedEnabledSources = installedEnabledSources(sources, dictionaries)
|
||||||
|
.filter { DictionarySource.hasDatePlaceholder(it.urlTemplate) }
|
||||||
|
if (datedEnabledSources.isEmpty()) return true
|
||||||
|
val fileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
|
||||||
|
preferencesManager.dictionaryPath
|
||||||
|
) ?: return true
|
||||||
|
val today = DateUtils.getCurrentDateString()
|
||||||
|
return datedEnabledSources.all { source ->
|
||||||
|
val baseName = DictionarySource.extractFileName(source.urlTemplate, today)
|
||||||
|
.removeSuffix(".gz")
|
||||||
|
.removeSuffix(".dsl")
|
||||||
|
.removeSuffix(".dz")
|
||||||
|
fileNames.any { it.startsWith(baseName) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,13 +597,12 @@ class SettingsViewModel(
|
|||||||
getApplication<Application>().stopService(stopIntent)
|
getApplication<Application>().stopService(stopIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshDictionaryStatus() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val dictionaries = localDictionaryRepository.dictionaries.first()
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
dictionaryStatus.value = if (dictionaries.isNotEmpty()) {
|
dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
||||||
DictionaryStatus.UpToDate
|
|
||||||
} else {
|
|
||||||
DictionaryStatus.Empty
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,12 +614,27 @@ class SettingsViewModel(
|
|||||||
is OperationResult.Success -> {
|
is OperationResult.Success -> {
|
||||||
val dictionaries = localDictionaryRepository.dictionaries.first()
|
val dictionaries = localDictionaryRepository.dictionaries.first()
|
||||||
if (dictionaries.isEmpty()) {
|
if (dictionaries.isEmpty()) {
|
||||||
dictionaryStatus.value = DictionaryStatus.Empty
|
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||||
|
evaluateDictionaryStatus()
|
||||||
|
}
|
||||||
|
isDownloadInProgress = false
|
||||||
|
} else if (isDownloadInProgress) {
|
||||||
|
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
} else {
|
} else {
|
||||||
if (isDownloadInProgress) {
|
val savedStatus = preferencesManager.dictionaryStatus()
|
||||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
val savedStatusName = savedStatus?.substringBefore('|')
|
||||||
isDownloadInProgress = false
|
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 {
|
} else {
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
||||||
|
|||||||
+56
-19
@@ -28,8 +28,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
@@ -81,7 +83,8 @@ fun DictionaryListItem(
|
|||||||
dictionary: Dictionary,
|
dictionary: Dictionary,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
isDeleteBlocked: () -> Boolean = { false }
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -93,7 +96,10 @@ fun DictionaryListItem(
|
|||||||
val offsetAnim = remember { Animatable(0f) }
|
val offsetAnim = remember { Animatable(0f) }
|
||||||
|
|
||||||
var rawOffset by remember { mutableFloatStateOf(0f) }
|
var rawOffset by remember { mutableFloatStateOf(0f) }
|
||||||
val isDeleteRevealed by remember { derivedStateOf { rawOffset <= -swipeThresholdPx } }
|
val isDeleteBlockedCurrent by rememberUpdatedState(isDeleteBlocked)
|
||||||
|
val isDeleteRevealed by remember {
|
||||||
|
derivedStateOf { !isDeleteBlockedCurrent() && rawOffset <= -swipeThresholdPx }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(isDeleteRevealed) {
|
LaunchedEffect(isDeleteRevealed) {
|
||||||
if (isDeleteRevealed) {
|
if (isDeleteRevealed) {
|
||||||
@@ -101,6 +107,16 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { isDeleteBlockedCurrent() }
|
||||||
|
.collect { blocked ->
|
||||||
|
if (blocked) {
|
||||||
|
offsetAnim.snapTo(0f)
|
||||||
|
rawOffset = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val currentOnDelete by rememberUpdatedState(onDelete)
|
val currentOnDelete by rememberUpdatedState(onDelete)
|
||||||
val currentOnToggle by rememberUpdatedState(onToggle)
|
val currentOnToggle by rememberUpdatedState(onToggle)
|
||||||
|
|
||||||
@@ -121,6 +137,8 @@ fun DictionaryListItem(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.height(DictionaryItemHeight)
|
||||||
|
.clipToBounds()
|
||||||
.testTag("dictionary_item")
|
.testTag("dictionary_item")
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -132,12 +150,15 @@ fun DictionaryListItem(
|
|||||||
.pointerInput(maxSwipePx, swipeThresholdPx) {
|
.pointerInput(maxSwipePx, swipeThresholdPx) {
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
|
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
|
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
|
||||||
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
|
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
|
||||||
rawOffset = target
|
rawOffset = target
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (isDeleteBlockedCurrent()) return@launch
|
||||||
offsetAnim.animateTo(
|
offsetAnim.animateTo(
|
||||||
target,
|
target,
|
||||||
animationSpec = spring(stiffness = Spring.StiffnessMedium)
|
animationSpec = spring(stiffness = Spring.StiffnessMedium)
|
||||||
@@ -145,9 +166,11 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
|
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
|
||||||
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
|
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
|
||||||
rawOffset = target
|
rawOffset = target
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (isDeleteBlockedCurrent()) return@launch
|
||||||
offsetAnim.animateTo(
|
offsetAnim.animateTo(
|
||||||
target,
|
target,
|
||||||
animationSpec = spring(stiffness = Spring.StiffnessMedium)
|
animationSpec = spring(stiffness = Spring.StiffnessMedium)
|
||||||
@@ -155,9 +178,13 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHorizontalDrag = { _, dragAmount ->
|
onHorizontalDrag = { _, dragAmount ->
|
||||||
|
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
|
||||||
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
|
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
|
||||||
rawOffset = newOffset
|
rawOffset = newOffset
|
||||||
scope.launch { offsetAnim.snapTo(newOffset) }
|
scope.launch {
|
||||||
|
if (isDeleteBlockedCurrent()) return@launch
|
||||||
|
offsetAnim.snapTo(newOffset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -222,25 +249,11 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDeleteRevealed) {
|
if (isDeleteRevealed) {
|
||||||
IconButton(
|
Box(
|
||||||
onClick = {
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
currentOnDelete()
|
|
||||||
scope.launch { offsetAnim.snapTo(0f) }
|
|
||||||
rawOffset = 0f
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(end = ItemHorizontalPadding)
|
.padding(end = ItemHorizontalPadding)
|
||||||
.graphicsLayer { translationX = -offsetAnim.value }
|
|
||||||
.size(IconButtonSize)
|
.size(IconButtonSize)
|
||||||
) {
|
)
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_delete),
|
|
||||||
contentDescription = stringResource(R.string.dictionary_delete),
|
|
||||||
tint = DeleteIconColor,
|
|
||||||
modifier = Modifier.size(IconSize)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Switch(
|
Switch(
|
||||||
checked = dictionary.isActive,
|
checked = dictionary.isActive,
|
||||||
@@ -257,5 +270,29 @@ fun DictionaryListItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDeleteRevealed) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
currentOnDelete()
|
||||||
|
scope.launch {
|
||||||
|
offsetAnim.snapTo(0f)
|
||||||
|
rawOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.padding(end = ItemHorizontalPadding)
|
||||||
|
.size(IconButtonSize)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_delete),
|
||||||
|
contentDescription = stringResource(R.string.dictionary_delete),
|
||||||
|
tint = DeleteIconColor,
|
||||||
|
modifier = Modifier.size(IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+57
-10
@@ -1,7 +1,17 @@
|
|||||||
package com.example.research.ui.settings.components
|
package com.example.research.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
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.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -11,16 +21,21 @@ 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
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -76,6 +91,12 @@ fun DictionaryManagement(
|
|||||||
}
|
}
|
||||||
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
|
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// (e.g. Modifier.alpha(if (isDeleteBlocked()) ...)) would invalidate every row on each flip.
|
||||||
|
val isMutationsBlockedState = rememberUpdatedState(isInProgress)
|
||||||
|
val isDeleteBlocked = remember { { isMutationsBlockedState.value } }
|
||||||
|
|
||||||
SectionCard(
|
SectionCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
@@ -100,7 +121,11 @@ fun DictionaryManagement(
|
|||||||
testTag = "dictionary_header"
|
testTag = "dictionary_header"
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isExpanded) {
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded,
|
||||||
|
enter = expandVertically(animationSpec = tween(250)) + fadeIn(tween(250)),
|
||||||
|
exit = shrinkVertically(animationSpec = tween(200)) + fadeOut(tween(200)),
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -126,7 +151,7 @@ fun DictionaryManagement(
|
|||||||
// Disable accessibility on background content during import to improve performance.
|
// Disable accessibility on background content during import to improve performance.
|
||||||
// The progress dialog remains accessible for cancellation.
|
// The progress dialog remains accessible for cancellation.
|
||||||
val backgroundModifier = if (isInProgress) {
|
val backgroundModifier = if (isInProgress) {
|
||||||
Modifier.semantics(mergeDescendants = true) { }
|
Modifier.semantics { hideFromAccessibility() }
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
@@ -166,7 +191,8 @@ fun DictionaryManagement(
|
|||||||
onDeleteDictionary = onDeleteDictionary,
|
onDeleteDictionary = onDeleteDictionary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(backgroundModifier)
|
.then(backgroundModifier),
|
||||||
|
isDeleteBlocked = isDeleteBlocked
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isInProgress) {
|
if (!isInProgress) {
|
||||||
@@ -194,7 +220,8 @@ fun DictionaryManagement(
|
|||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
setShowUrlBottomSheet(false)
|
setShowUrlBottomSheet(false)
|
||||||
},
|
},
|
||||||
sheetState = sheetState
|
sheetState = sheetState,
|
||||||
|
contentWindowInsets = { WindowInsets.statusBars }
|
||||||
) {
|
) {
|
||||||
DictionaryUrlInputContent(
|
DictionaryUrlInputContent(
|
||||||
existingSources = sources,
|
existingSources = sources,
|
||||||
@@ -246,18 +273,38 @@ private fun DictionaryListSection(
|
|||||||
dictionaries: List<Dictionary>,
|
dictionaries: List<Dictionary>,
|
||||||
onToggleDictionary: (String) -> Unit,
|
onToggleDictionary: (String) -> Unit,
|
||||||
onDeleteDictionary: (Dictionary) -> Unit,
|
onDeleteDictionary: (Dictionary) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
isDeleteBlocked: () -> Boolean = { false }
|
||||||
) {
|
) {
|
||||||
|
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
|
||||||
|
LaunchedEffect(dictionaries) {
|
||||||
|
val visiblePaths = dictionaries.mapTo(mutableSetOf(), Dictionary::path)
|
||||||
|
appearedPaths = appearedPaths intersect visiblePaths
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
dictionaries.forEach { dictionary ->
|
dictionaries.forEach { dictionary ->
|
||||||
DictionaryListItem(
|
key(dictionary.path) {
|
||||||
dictionary = dictionary,
|
val hasAppeared = dictionary.path in appearedPaths
|
||||||
onToggle = { onToggleDictionary(dictionary.path) },
|
val rowAlpha by animateFloatAsState(
|
||||||
onDelete = { onDeleteDictionary(dictionary) }
|
targetValue = if (hasAppeared) 1f else 0f,
|
||||||
)
|
animationSpec = tween(150),
|
||||||
|
label = "dictionaryRowAppear",
|
||||||
|
)
|
||||||
|
LaunchedEffect(dictionary.path) {
|
||||||
|
appearedPaths = appearedPaths + dictionary.path
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.alpha(rowAlpha)) {
|
||||||
|
DictionaryListItem(
|
||||||
|
dictionary = dictionary,
|
||||||
|
onToggle = { onToggleDictionary(dictionary.path) },
|
||||||
|
onDelete = { onDeleteDictionary(dictionary) },
|
||||||
|
isDeleteBlocked = isDeleteBlocked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+26
-7
@@ -1,8 +1,8 @@
|
|||||||
package com.example.research.ui.settings.components
|
package com.example.research.ui.settings.components
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -19,6 +19,10 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
|
||||||
|
private const val MILLIS_PER_PROGRESS_UNIT = 10_000
|
||||||
|
private const val MIN_PROGRESS_MOTION_MS = 50
|
||||||
|
private const val MAX_PROGRESS_MOTION_MS = 650
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DictionaryProgressSection(
|
fun DictionaryProgressSection(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
@@ -41,6 +45,11 @@ fun DictionaryProgressSection(
|
|||||||
var displayedPercent by remember { mutableIntStateOf(percent) }
|
var displayedPercent by remember { mutableIntStateOf(percent) }
|
||||||
var displayedTestTag by remember { mutableStateOf(testTag) }
|
var displayedTestTag by remember { mutableStateOf(testTag) }
|
||||||
|
|
||||||
|
LaunchedEffect(visible) {
|
||||||
|
if (visible) {
|
||||||
|
monotonicTargetProgress = targetProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(visible, targetProgress) {
|
LaunchedEffect(visible, targetProgress) {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
|
monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
|
||||||
@@ -53,11 +62,21 @@ fun DictionaryProgressSection(
|
|||||||
displayedTestTag = testTag
|
displayedTestTag = testTag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedState = remember { Animatable(0f) }
|
||||||
targetValue = monotonicTargetProgress,
|
LaunchedEffect(monotonicTargetProgress) {
|
||||||
animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing),
|
val delta = monotonicTargetProgress - animatedState.value
|
||||||
label = "dictionary-progress"
|
if (delta > 0f) {
|
||||||
)
|
animatedState.animateTo(
|
||||||
|
targetValue = monotonicTargetProgress,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = (delta * MILLIS_PER_PROGRESS_UNIT).toInt()
|
||||||
|
.coerceIn(MIN_PROGRESS_MOTION_MS, MAX_PROGRESS_MOTION_MS),
|
||||||
|
easing = LinearEasing,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val animatedProgress = animatedState.value
|
||||||
|
|
||||||
Column(modifier = Modifier.testTag(displayedTestTag)) {
|
Column(modifier = Modifier.testTag(displayedTestTag)) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
+56
-30
@@ -3,6 +3,9 @@ package com.example.research.ui.settings.components
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.text.input.InputTransformation
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -16,7 +19,6 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
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.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
@@ -64,6 +66,29 @@ private fun validateDictionaryUrl(context: UrlValidationContext): String? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val urlSplitRegex = Regex("[\\s,;]+")
|
||||||
|
|
||||||
|
private fun splitPastedUrls(text: String): List<String>? =
|
||||||
|
text.split(urlSplitRegex).filter { it.isNotEmpty() }.takeIf { tokens ->
|
||||||
|
tokens.size > 1 && tokens.all {
|
||||||
|
DictionarySource.validateTemplate(it) is DictionarySource.ValidationResult.Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val urlInputTransformation = InputTransformation {
|
||||||
|
val text = toString()
|
||||||
|
val filtered = text
|
||||||
|
.take(2048)
|
||||||
|
.filter { char ->
|
||||||
|
!char.isISOControl() &&
|
||||||
|
char != '\u0000' &&
|
||||||
|
char.code !in 128..<160
|
||||||
|
}
|
||||||
|
if (filtered != text) {
|
||||||
|
replace(0, length, filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DictionaryUrlInputContent(
|
fun DictionaryUrlInputContent(
|
||||||
existingSources: List<DictionarySource>,
|
existingSources: List<DictionarySource>,
|
||||||
@@ -71,11 +96,22 @@ fun DictionaryUrlInputContent(
|
|||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val urls = remember { mutableStateListOf(TextFieldValue("")) }
|
val urls = remember { mutableStateListOf(TextFieldState("")) }
|
||||||
val validationErrors = remember { mutableStateMapOf<Int, String>() }
|
val validationErrors = remember { mutableStateMapOf<Int, String>() }
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { urls.map { it.text.toString() } }.collect { texts ->
|
||||||
|
val (index, tokens) = texts.withIndex().firstNotNullOfOrNull { (i, text) ->
|
||||||
|
splitPastedUrls(text)?.let { i to it }
|
||||||
|
} ?: return@collect
|
||||||
|
urls[index].edit { replace(0, length, tokens.first()) }
|
||||||
|
urls.addAll(index + 1, tokens.subList(1, tokens.size).map { TextFieldState(it) })
|
||||||
|
validationErrors.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val errorMessages = ErrorMessages(
|
val errorMessages = ErrorMessages(
|
||||||
empty = stringResource(R.string.dictionary_source_url_empty),
|
empty = stringResource(R.string.dictionary_source_url_empty),
|
||||||
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
|
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
|
||||||
@@ -96,8 +132,8 @@ fun DictionaryUrlInputContent(
|
|||||||
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
|
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
|
||||||
val inputUrlPrefixes = remember {
|
val inputUrlPrefixes = remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
urls.associate { urlValue ->
|
urls.associate { urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.trimmedText()
|
||||||
urlString to if (urlString.isNotEmpty()) {
|
urlString to if (urlString.isNotEmpty()) {
|
||||||
DictionarySource.extractPrefix(urlString)
|
DictionarySource.extractPrefix(urlString)
|
||||||
} else null
|
} else null
|
||||||
@@ -119,10 +155,10 @@ fun DictionaryUrlInputContent(
|
|||||||
style = MaterialTheme.typography.headlineSmall
|
style = MaterialTheme.typography.headlineSmall
|
||||||
)
|
)
|
||||||
|
|
||||||
urls.forEachIndexed { index, urlValue ->
|
urls.forEachIndexed { index, urlState ->
|
||||||
val isLastItem = index == urls.lastIndex
|
val isLastItem = index == urls.lastIndex
|
||||||
|
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.trimmedText()
|
||||||
val cachedPrefix = inputUrlPrefixes.value[urlString]
|
val cachedPrefix = inputUrlPrefixes.value[urlString]
|
||||||
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
|
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
|
||||||
validateDictionaryUrl(
|
validateDictionaryUrl(
|
||||||
@@ -130,7 +166,7 @@ fun DictionaryUrlInputContent(
|
|||||||
url = urlString,
|
url = urlString,
|
||||||
existingSources = existingSources,
|
existingSources = existingSources,
|
||||||
existingPrefixes = existingPrefixes,
|
existingPrefixes = existingPrefixes,
|
||||||
urlsInForm = urls.map { it.text.trim() },
|
urlsInForm = urls.map { it.trimmedText() },
|
||||||
urlPrefix = cachedPrefix,
|
urlPrefix = cachedPrefix,
|
||||||
errorMessages = errorMessages
|
errorMessages = errorMessages
|
||||||
)
|
)
|
||||||
@@ -140,20 +176,8 @@ fun DictionaryUrlInputContent(
|
|||||||
val displayError = validationErrors[index] ?: realtimeError
|
val displayError = validationErrors[index] ?: realtimeError
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = urlValue,
|
state = urlState,
|
||||||
onValueChange = { newValue ->
|
inputTransformation = urlInputTransformation,
|
||||||
val filtered = newValue.copy(
|
|
||||||
text = newValue.text
|
|
||||||
.take(2048)
|
|
||||||
.filter { char ->
|
|
||||||
!char.isISOControl() &&
|
|
||||||
char != '\u0000' &&
|
|
||||||
char.code !in 128..<160
|
|
||||||
}
|
|
||||||
)
|
|
||||||
urls[index] = filtered
|
|
||||||
validationErrors.remove(index)
|
|
||||||
},
|
|
||||||
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
|
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
|
||||||
isError = displayError != null,
|
isError = displayError != null,
|
||||||
supportingText = displayError?.let { { Text(it) } },
|
supportingText = displayError?.let { { Text(it) } },
|
||||||
@@ -168,7 +192,7 @@ fun DictionaryUrlInputContent(
|
|||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
singleLine = true,
|
lineLimits = TextFieldLineLimits.SingleLine,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
|
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
|
||||||
@@ -188,7 +212,7 @@ fun DictionaryUrlInputContent(
|
|||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
urls.add(TextFieldValue(""))
|
urls.add(TextFieldState(""))
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Start)
|
.align(Alignment.Start)
|
||||||
@@ -224,8 +248,8 @@ fun DictionaryUrlInputContent(
|
|||||||
val validUrls = mutableListOf<String>()
|
val validUrls = mutableListOf<String>()
|
||||||
val seenUrls = mutableSetOf<String>()
|
val seenUrls = mutableSetOf<String>()
|
||||||
|
|
||||||
urls.forEachIndexed { index, urlValue ->
|
urls.forEachIndexed { index, urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.trimmedText()
|
||||||
if (urlString.isBlank()) {
|
if (urlString.isBlank()) {
|
||||||
if (urls.size == 1) {
|
if (urls.size == 1) {
|
||||||
validationErrors[index] = errorMessages.empty
|
validationErrors[index] = errorMessages.empty
|
||||||
@@ -263,9 +287,9 @@ fun DictionaryUrlInputContent(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.testTag("add_confirm_button")
|
.testTag("add_confirm_button")
|
||||||
.semantics { testTagsAsResourceId = true },
|
.semantics { testTagsAsResourceId = true },
|
||||||
enabled = urls.any { it.text.trim().isNotBlank() } &&
|
enabled = urls.any { it.trimmedText().isNotBlank() } &&
|
||||||
urls.none { urlValue ->
|
urls.none { urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.trimmedText()
|
||||||
if (urlString.isEmpty()) {
|
if (urlString.isEmpty()) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@@ -275,7 +299,7 @@ fun DictionaryUrlInputContent(
|
|||||||
url = urlString,
|
url = urlString,
|
||||||
existingSources = existingSources,
|
existingSources = existingSources,
|
||||||
existingPrefixes = existingPrefixes,
|
existingPrefixes = existingPrefixes,
|
||||||
urlsInForm = urls.map { it.text.trim() },
|
urlsInForm = urls.map { it.trimmedText() },
|
||||||
urlPrefix = urlPrefix,
|
urlPrefix = urlPrefix,
|
||||||
errorMessages = errorMessages
|
errorMessages = errorMessages
|
||||||
)
|
)
|
||||||
@@ -289,3 +313,5 @@ fun DictionaryUrlInputContent(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun TextFieldState.trimmedText(): String = text.trim().toString()
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package com.example.research.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.components.OutlinedChoiceButton
|
||||||
|
import com.example.research.core.domain.model.SearchBarPosition
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchBarPositionSection(
|
||||||
|
currentPosition: SearchBarPosition,
|
||||||
|
onPositionChanged: (SearchBarPosition) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
SearchBarPosition.entries.forEach { position ->
|
||||||
|
val isSelected = currentPosition == position
|
||||||
|
OutlinedChoiceButton(
|
||||||
|
text = when (position) {
|
||||||
|
SearchBarPosition.TOP -> stringResource(R.string.search_bar_position_top)
|
||||||
|
SearchBarPosition.BOTTOM -> stringResource(R.string.search_bar_position_bottom)
|
||||||
|
},
|
||||||
|
onClick = { onPositionChanged(position) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.testTag("search_bar_${position.name}"),
|
||||||
|
selected = isSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +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 iconStart = 32.dp
|
|
||||||
val searchTextStart = 68.dp
|
|
||||||
val listItemVertical = 8.dp
|
val listItemVertical = 8.dp
|
||||||
|
|
||||||
|
val contentStart = 32.dp
|
||||||
}
|
}
|
||||||
@@ -24,12 +24,20 @@ object AppWindowInsets {
|
|||||||
@Composable
|
@Composable
|
||||||
get() = WindowInsets.navigationBars.union(WindowInsets.ime)
|
get() = WindowInsets.navigationBars.union(WindowInsets.ime)
|
||||||
|
|
||||||
|
val statusBarsAndIme: WindowInsets
|
||||||
|
@Composable
|
||||||
|
get() = WindowInsets.statusBars.union(WindowInsets.ime)
|
||||||
|
|
||||||
val fullScreen: WindowInsets
|
val fullScreen: WindowInsets
|
||||||
@Composable
|
@Composable
|
||||||
get() = WindowInsets.statusBars
|
get() = WindowInsets.statusBars
|
||||||
.union(WindowInsets.navigationBars)
|
.union(WindowInsets.navigationBars)
|
||||||
.union(WindowInsets.displayCutout)
|
.union(WindowInsets.displayCutout)
|
||||||
|
|
||||||
|
val bottomNavigation: WindowInsets
|
||||||
|
@Composable
|
||||||
|
get() = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
|
||||||
|
|
||||||
val navigationOnly: WindowInsets
|
val navigationOnly: WindowInsets
|
||||||
@Composable
|
@Composable
|
||||||
get() = WindowInsets.navigationBars
|
get() = WindowInsets.navigationBars
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,19.99 10.51,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3M12,8V13L16.28,15.54L17,14.33L13.5,12.25V8H12Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M7,9H2V7h5v2zM7,12H2v2h5v-2zM20.59,19l-3.83,-3.83c-0.8,0.52 -1.74,0.83 -2.76,0.83 -2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5c0,1.02 -0.31,1.96 -0.83,2.75L22,17.59 20.59,19zM17,11c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3 3,-1.35 3,-3zM2,19h10v-2H2v2z" />
|
||||||
|
</vector>
|
||||||
@@ -2,18 +2,22 @@
|
|||||||
<string name="app_name">ReSearch</string>
|
<string name="app_name">ReSearch</string>
|
||||||
<string name="settings_title">Настройки</string>
|
<string name="settings_title">Настройки</string>
|
||||||
<string name="action_back">Назад</string>
|
<string name="action_back">Назад</string>
|
||||||
<string name="version_format">v%1$s</string>
|
|
||||||
<string name="version_unknown">неизвестно</string>
|
<string name="version_unknown">неизвестно</string>
|
||||||
<string name="button_cancel_download">Отмена</string>
|
<string name="button_cancel_download">Отмена</string>
|
||||||
<string name="action_ok">ОК</string>
|
<string name="action_ok">ОК</string>
|
||||||
<string name="download_error">Ошибка загрузки словарей</string>
|
<string name="download_error">Ошибка загрузки словарей</string>
|
||||||
|
<string name="download_network_failed">Нет связи с сервером словарей. Проверьте сеть и попробуйте снова</string>
|
||||||
<string name="language">Язык</string>
|
<string name="language">Язык</string>
|
||||||
<string name="language_english">Английский</string>
|
<string name="language_english">Английский</string>
|
||||||
<string name="language_russian">Русский</string>
|
<string name="language_russian">Русский</string>
|
||||||
<string name="language_system">Системный</string>
|
<string name="language_system">Системный</string>
|
||||||
<string name="indexing_label">Индексация словарей</string>
|
<string name="search_bar_position">Строка поиска</string>
|
||||||
|
<string name="search_bar_position_top">Наверху</string>
|
||||||
|
<string name="search_bar_position_bottom">Внизу</string>
|
||||||
<string name="clear_search">Очистить поиск</string>
|
<string name="clear_search">Очистить поиск</string>
|
||||||
<string name="article_no_selected">Статья не выбрана</string>
|
<string name="recent_searches">История поиска</string>
|
||||||
|
<string name="clear_search_history">Очистить</string>
|
||||||
|
<string name="remove_search_history_entry">Удалить из истории</string>
|
||||||
<string name="article_return_to_search">Вернуться к поиску</string>
|
<string name="article_return_to_search">Вернуться к поиску</string>
|
||||||
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
|
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
|
||||||
<string name="error_loading_article">Ошибка загрузки: %1$s</string>
|
<string name="error_loading_article">Ошибка загрузки: %1$s</string>
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
<string name="notification_download_error_title">Ошибка загрузки</string>
|
<string name="notification_download_error_title">Ошибка загрузки</string>
|
||||||
<string name="notification_download_error_text">Не удалось скачать словари</string>
|
<string name="notification_download_error_text">Не удалось скачать словари</string>
|
||||||
<string name="notification_indexing_title">Индексация словарей</string>
|
<string name="notification_indexing_title">Индексация словарей</string>
|
||||||
|
<string name="notification_preparing_indexing_title">Подготовка словарей к индексации</string>
|
||||||
<string name="archive_failed_open_source_file">Не удалось открыть исходный файл</string>
|
<string name="archive_failed_open_source_file">Не удалось открыть исходный файл</string>
|
||||||
<string name="archive_failed_create_output_file">Не удалось создать выходной файл</string>
|
<string name="archive_failed_create_output_file">Не удалось создать выходной файл</string>
|
||||||
<string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string>
|
<string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string>
|
||||||
@@ -67,16 +72,17 @@
|
|||||||
<string name="dictionary_management_title">Словари</string>
|
<string name="dictionary_management_title">Словари</string>
|
||||||
<string name="import_dictionary_button">Выбрать файлы</string>
|
<string name="import_dictionary_button">Выбрать файлы</string>
|
||||||
<string name="import_error">Не удалось импортировать словарь: %1$s</string>
|
<string name="import_error">Не удалось импортировать словарь: %1$s</string>
|
||||||
<string name="import_file_exists">Файл уже существует: %1$s</string>
|
<string name="import_file_exists">Словарь уже добавлен: %1$s</string>
|
||||||
<string name="import_invalid_file_name">Недопустимое имя файла: %1$s</string>
|
<string name="import_invalid_file_name">Недопустимое имя файла: %1$s</string>
|
||||||
|
<string name="import_nothing_imported">В выбранном нет поддерживаемых файлов словарей</string>
|
||||||
<string name="dictionary_source_url_hint">URL</string>
|
<string name="dictionary_source_url_hint">URL</string>
|
||||||
<string name="dictionary_source_add_button">Добавить источник</string>
|
<string name="dictionary_source_add_button">Добавить источник</string>
|
||||||
<string name="dictionary_source_duplicate">Этот URL уже существует</string>
|
<string name="dictionary_source_duplicate">Этот URL уже существует</string>
|
||||||
<string name="dictionary_source_duplicate_in_form">Дублирующийся URL в форме</string>
|
<string name="dictionary_source_duplicate_in_form">Дублирующийся URL в форме</string>
|
||||||
<string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string>
|
<string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string>
|
||||||
<string name="dictionary_source_url_empty">URL не может быть пустым</string>
|
<string name="dictionary_source_url_empty">URL не может быть пустым</string>
|
||||||
<string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string>
|
<string name="dictionary_source_invalid_protocol">Должен начинаться с https://</string>
|
||||||
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dz</string>
|
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dsl.dz</string>
|
||||||
<string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string>
|
<string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string>
|
||||||
<string name="dictionary_source_invalid_url">Неверный формат URL</string>
|
<string name="dictionary_source_invalid_url">Неверный формат URL</string>
|
||||||
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
|
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
|
||||||
@@ -95,6 +101,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="dictionary_not_indexed">• Не индексирован</string>
|
<string name="dictionary_not_indexed">• Не индексирован</string>
|
||||||
<string name="dictionary_delete">Удалить словарь</string>
|
<string name="dictionary_delete">Удалить словарь</string>
|
||||||
|
<string name="error_delete_dictionary">Не удалось удалить словарь</string>
|
||||||
<string name="dictionaries_tap_to_update">Нажмите для обновления</string>
|
<string name="dictionaries_tap_to_update">Нажмите для обновления</string>
|
||||||
<string name="notification_import_title">Импорт словарей</string>
|
<string name="notification_import_title">Импорт словарей</string>
|
||||||
<string name="notification_import_success_title">Импорт завершён</string>
|
<string name="notification_import_success_title">Импорт завершён</string>
|
||||||
|
|||||||
@@ -2,18 +2,22 @@
|
|||||||
<string name="app_name">ReSearch</string>
|
<string name="app_name">ReSearch</string>
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
<string name="action_back">Back</string>
|
<string name="action_back">Back</string>
|
||||||
<string name="version_format">v%1$s</string>
|
|
||||||
<string name="version_unknown">unknown</string>
|
<string name="version_unknown">unknown</string>
|
||||||
<string name="button_cancel_download">Cancel</string>
|
<string name="button_cancel_download">Cancel</string>
|
||||||
<string name="action_ok">OK</string>
|
<string name="action_ok">OK</string>
|
||||||
<string name="download_error">Dictionary download failed</string>
|
<string name="download_error">Dictionary download failed</string>
|
||||||
|
<string name="download_network_failed">No connection to the dictionary server. Check your network and try again</string>
|
||||||
<string name="language">Language</string>
|
<string name="language">Language</string>
|
||||||
<string name="language_english">English</string>
|
<string name="language_english">English</string>
|
||||||
<string name="language_russian">Russian</string>
|
<string name="language_russian">Russian</string>
|
||||||
<string name="language_system">System</string>
|
<string name="language_system">System</string>
|
||||||
<string name="indexing_label">Indexing dictionaries</string>
|
<string name="search_bar_position">Search bar</string>
|
||||||
|
<string name="search_bar_position_top">Top</string>
|
||||||
|
<string name="search_bar_position_bottom">Bottom</string>
|
||||||
<string name="clear_search">Clear search</string>
|
<string name="clear_search">Clear search</string>
|
||||||
<string name="article_no_selected">No article selected</string>
|
<string name="recent_searches">Search history</string>
|
||||||
|
<string name="clear_search_history">Clear</string>
|
||||||
|
<string name="remove_search_history_entry">Remove from history</string>
|
||||||
<string name="article_return_to_search">Return to search</string>
|
<string name="article_return_to_search">Return to search</string>
|
||||||
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
|
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
|
||||||
<string name="error_loading_article">Loading error: %1$s</string>
|
<string name="error_loading_article">Loading error: %1$s</string>
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
<string name="notification_download_error_title">Download error</string>
|
<string name="notification_download_error_title">Download error</string>
|
||||||
<string name="notification_download_error_text">Failed to download dictionaries</string>
|
<string name="notification_download_error_text">Failed to download dictionaries</string>
|
||||||
<string name="notification_indexing_title">Indexing dictionaries</string>
|
<string name="notification_indexing_title">Indexing dictionaries</string>
|
||||||
|
<string name="notification_preparing_indexing_title">Preparing dictionaries for indexing</string>
|
||||||
<string name="archive_failed_open_source_file">Failed to open source file</string>
|
<string name="archive_failed_open_source_file">Failed to open source file</string>
|
||||||
<string name="archive_failed_create_output_file">Failed to create output file</string>
|
<string name="archive_failed_create_output_file">Failed to create output file</string>
|
||||||
<string name="archive_failed_delete_file">Failed to delete file: %1$s</string>
|
<string name="archive_failed_delete_file">Failed to delete file: %1$s</string>
|
||||||
@@ -65,16 +70,17 @@
|
|||||||
<string name="dictionary_management_title">Dictionaries</string>
|
<string name="dictionary_management_title">Dictionaries</string>
|
||||||
<string name="import_dictionary_button">Select files</string>
|
<string name="import_dictionary_button">Select files</string>
|
||||||
<string name="import_error">Failed to import dictionary: %1$s</string>
|
<string name="import_error">Failed to import dictionary: %1$s</string>
|
||||||
<string name="import_file_exists">File already exists: %1$s</string>
|
<string name="import_file_exists">Dictionary already added: %1$s</string>
|
||||||
<string name="import_invalid_file_name">Invalid file name: %1$s</string>
|
<string name="import_invalid_file_name">Invalid file name: %1$s</string>
|
||||||
|
<string name="import_nothing_imported">No supported dictionary files were found in the selection</string>
|
||||||
<string name="dictionary_source_url_hint">URL</string>
|
<string name="dictionary_source_url_hint">URL</string>
|
||||||
<string name="dictionary_source_add_button">Add source</string>
|
<string name="dictionary_source_add_button">Add source</string>
|
||||||
<string name="dictionary_source_duplicate">This URL already exists</string>
|
<string name="dictionary_source_duplicate">This URL already exists</string>
|
||||||
<string name="dictionary_source_duplicate_in_form">Duplicate URL in the form</string>
|
<string name="dictionary_source_duplicate_in_form">Duplicate URL in the form</string>
|
||||||
<string name="dictionary_source_duplicate_prefix">This dictionary already exists</string>
|
<string name="dictionary_source_duplicate_prefix">This dictionary already exists</string>
|
||||||
<string name="dictionary_source_url_empty">URL cannot be empty</string>
|
<string name="dictionary_source_url_empty">URL cannot be empty</string>
|
||||||
<string name="dictionary_source_invalid_protocol">Must start with http:// or https://</string>
|
<string name="dictionary_source_invalid_protocol">Must start with https://</string>
|
||||||
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dz</string>
|
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.dz</string>
|
||||||
<string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string>
|
<string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string>
|
||||||
<string name="dictionary_source_invalid_url">Invalid URL format</string>
|
<string name="dictionary_source_invalid_url">Invalid URL format</string>
|
||||||
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
|
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
|
||||||
@@ -91,6 +97,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="dictionary_not_indexed">• Not indexed</string>
|
<string name="dictionary_not_indexed">• Not indexed</string>
|
||||||
<string name="dictionary_delete">Delete dictionary</string>
|
<string name="dictionary_delete">Delete dictionary</string>
|
||||||
|
<string name="error_delete_dictionary">Failed to delete dictionary</string>
|
||||||
<string name="dictionaries_tap_to_update">Tap to update all dictionaries</string>
|
<string name="dictionaries_tap_to_update">Tap to update all dictionaries</string>
|
||||||
<string name="notification_import_title">Importing dictionaries</string>
|
<string name="notification_import_title">Importing dictionaries</string>
|
||||||
<string name="notification_import_success_title">Import completed</string>
|
<string name="notification_import_success_title">Import completed</string>
|
||||||
|
|||||||
File renamed without changes.
@@ -0,0 +1,9 @@
|
|||||||
|
# Strip android.util.Log warnings/errors from release builds.
|
||||||
|
# Debug builds keep full logging for diagnostics.
|
||||||
|
-assumenosideeffects class android.util.Log {
|
||||||
|
public static int w(java.lang.String, java.lang.String);
|
||||||
|
public static int w(java.lang.String, java.lang.String, java.lang.Throwable);
|
||||||
|
public static int w(java.lang.String, java.lang.Throwable);
|
||||||
|
public static int e(java.lang.String, java.lang.String);
|
||||||
|
public static int e(java.lang.String, java.lang.String, java.lang.Throwable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Fixed the dictionary update card after starting or cancelling a download
|
||||||
|
- Fixed download progress staying at 0% for too long
|
||||||
|
- Rebuilt the search field on Material 3 and aligned dictionary title typography
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Fixed a crash and lost dictionary state around search results
|
||||||
|
- Added diacritic-insensitive search, so accented and plain spellings match each other
|
||||||
|
- Fixed indexing failures on dictionaries with dense non-Latin text
|
||||||
|
- Search results now keep loading as you scroll instead of stopping early
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Fixed the dictionary update card taking too long to reappear after cancelling a download
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Dictionaries can no longer be deleted while an update is running, which used to lose their update source
|
||||||
|
- Fixed the dictionary icon sliding outside the row while swiping to delete
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- The dictionary update card now returns after a failed download
|
||||||
|
- Fixed headwords and articles with escaped characters being displayed incorrectly
|
||||||
|
- Fixed slow rendering of articles containing many bracket characters
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
- The app starts faster
|
||||||
|
- Smooth screen transition animations
|
||||||
|
- Articles render DSL line breaks, indentation and named colors correctly
|
||||||
|
- Tabs and extra spaces inside articles no longer break the layout
|
||||||
|
- The article title can be copied, tapping the article clears the selection
|
||||||
|
- Source URLs accept host:port addresses
|
||||||
|
- Dictionary downloads retry automatically when the network switches
|
||||||
|
- Download progress notifications are more reliable
|
||||||
|
- Search results clear instantly when the query is erased
|
||||||
|
- Leftover files from cancelled downloads are now removed automatically
|
||||||
|
- Improved app stability
|
||||||
|
- Faster dictionary indexing
|
||||||
|
- Updated build tooling and documentation
|
||||||
|
- Long link-heavy articles no longer freeze while scrolling
|
||||||
|
- Large search result lists render faster
|
||||||
|
- Dark article colors are readable in the dark theme
|
||||||
@@ -9,6 +9,6 @@ Features:
|
|||||||
- Automatic indexing with binary index files for instant search
|
- Automatic indexing with binary index files for instant search
|
||||||
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
|
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
|
||||||
- Adaptive three-pane layout for tablets and large screens
|
- Adaptive three-pane layout for tablets and large screens
|
||||||
- Light, dark, and system-follow themes
|
- Material 3 UI with dynamic color, plus light, dark, and system-follow themes
|
||||||
- English and Russian localization
|
- English and Russian localization
|
||||||
- No tracking, no ads, fully open source
|
- No tracking, no ads, fully open source
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
- Исправлена карточка обновления словаря после запуска и отмены загрузки
|
||||||
|
- Исправлено зависание индикатора прогресса загрузки на 0%
|
||||||
|
- Строка поиска переведена на Material 3; выровнена типографика заголовков словаря
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Исправлен сбой и потеря состояния словаря в результатах поиска
|
||||||
|
- Добавлен поиск без учёта диакритических знаков — совпадают варианты с акцентами и без
|
||||||
|
- Исправлена ошибка индексации словарей с плотным нелатинским текстом
|
||||||
|
- Результаты поиска теперь полностью подгружаются при прокрутке, а не обрываются на первых совпадениях
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Исправлена лишняя задержка перед повторным появлением карточки обновления словаря после отмены загрузки
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Словари больше нельзя удалить во время обновления — раньше при этом терялся источник обновления
|
||||||
|
- Исправлен выход иконки словаря за границы строки при свайпе
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Исправлено сопоставление URL-источников с установленными файлами — удалённый словарь теперь можно повторно скачать по тому же URL
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Карточка обновления словарей теперь появляется снова после неудачной загрузки
|
||||||
|
- Исправлено отображение заголовков и статей с экранированными символами
|
||||||
|
- Исправлено медленное отображение статей с большим количеством скобок
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
- Приложение запускается быстрее
|
||||||
|
- Плавные анимации переходов между экранами
|
||||||
|
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
|
||||||
|
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
|
||||||
|
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
|
||||||
|
- Адреса источников принимают формат host:port
|
||||||
|
- Загрузка словарей автоматически повторяется при переключении сети
|
||||||
|
- Уведомления о прогрессе загрузки стали надёжнее
|
||||||
|
- Результаты поиска очищаются мгновенно при стирании запроса
|
||||||
|
- Остаточные файлы после отменённых загрузок удаляются автоматически
|
||||||
|
- Повышена стабильность приложения
|
||||||
|
- Ускорена индексация словарей
|
||||||
|
- Обновлены тулинг сборки и документация
|
||||||
|
- Длинные статьи со ссылками больше не подвисают при прокрутке
|
||||||
|
- Большие списки результатов поиска отображаются быстрее
|
||||||
|
- Тёмные цвета статей читаемы в тёмной теме
|
||||||
@@ -9,6 +9,6 @@ ReSearch — быстрое приложение для чтения слова
|
|||||||
- Автоматическая индексация с бинарными индексными файлами для мгновенного поиска
|
- Автоматическая индексация с бинарными индексными файлами для мгновенного поиска
|
||||||
- Поддержка нескольких кодировок — автоопределение UTF-8, UTF-16 LE/BE
|
- Поддержка нескольких кодировок — автоопределение UTF-8, UTF-16 LE/BE
|
||||||
- Адаптивный трёхпанельный интерфейс для планшетов и больших экранов
|
- Адаптивный трёхпанельный интерфейс для планшетов и больших экранов
|
||||||
- Светлая, тёмная и системная темы
|
- Material 3 с динамическими цветами, светлая, тёмная и системная темы
|
||||||
- Локализация на английский и русский языки
|
- Локализация на английский и русский языки
|
||||||
- Без трекеров, без рекламы, полностью открытый исходный код
|
- Без трекеров, без рекламы, полностью открытый исходный код
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -1,15 +1,15 @@
|
|||||||
[versions]
|
[versions]
|
||||||
aboutlibraries = "15.0.3"
|
aboutlibraries = "15.2.0"
|
||||||
activity_compose = "1.13.0"
|
activity_compose = "1.13.0"
|
||||||
agp = "9.2.1"
|
agp = "9.4.1"
|
||||||
compose_bom = "2026.06.01"
|
compose_bom = "2026.09.00"
|
||||||
core = "1.19.0"
|
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.0"
|
kotlin = "2.4.20"
|
||||||
lifecycle_runtime_ktx = "2.11.0"
|
lifecycle_runtime_ktx = "2.11.0"
|
||||||
okhttp = "5.4.0"
|
okhttp = "5.5.0"
|
||||||
paging = "3.5.0"
|
paging = "3.5.1"
|
||||||
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"
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -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.6.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
retries=0
|
retries=0
|
||||||
retryBackOffMs=500
|
retryBackOffMs=500
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
#
|
#
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
#
|
#
|
||||||
# Important for running:
|
# Important for running:
|
||||||
#
|
#
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
# bash, then to run this script, type that shell name before the whole
|
# bash, then to run this script, type that shell name before the whole
|
||||||
# command line, like:
|
# command line, like:
|
||||||
#
|
#
|
||||||
# ksh Gradle
|
# ksh gradlew
|
||||||
#
|
#
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
# requires all of these POSIX shell features:
|
# requires all of these POSIX shell features:
|
||||||
|
|||||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user