10 Commits
Author SHA1 Message Date
OneWay cf64986371 fix: clean up orphaned and partial files after cancelled downloads
Treat inactive download contexts as cancellation, delete the partial file, and
rethrow the cancellation so the pipeline does not retain an incomplete payload.
Also remove orphaned .dsl/.dz files left behind by cancelled downloads.
Run the partial/orphan deletions on NonCancellable: the cleanup is invoked from
already-cancelled coroutines, where withContext(Dispatchers.IO) aborts before
executing the block and silently leaves the files behind.
2026-09-14 07:50:30 +08:00
OneWay 4d577a6b13 fix: restore boundary progress publishes and animate the bar at constant velocity 2026-09-11 16:37:23 +03:00
OneWay d15781a176 chore: drop the unused indexing_label string 2026-09-11 16:28:23 +03:00
OneWay ee40748b00 refactor: slim IndexingProgress to a producer-supplied [0,1] progress
perFileProgress was never populated and its progress branch was
unreachable; currentFile, currentIndex, currentFileProgress and label
were write-only (no consumer read them); progressPercent had no
callers; the -1f aggregate sentinel is gone along with the fallback
branches that only fired when the tracker was absent.
2026-09-11 16:28:23 +03:00
OneWay 380e4ca916 fix: latch terminal notification state and post pipeline progress on one thread
A late ImportState Success snapshot collected after the success
notification overwrote it with an ongoing progress record. Post
progress on Main behind a volatile terminal latch; notifications go
through NotificationPort, the pipeline lives in the coordinator, and
the service finish is injected.
2026-09-06 15:32:35 +08:00
OneWay 47b1197a92 fix: defer import errors until the copy loop finishes
Errors and skips are collected while the copy loop runs and reported
once it ends. The final import state is exhaustive - success, already
added, invalid name, or nothing supported found - so every import run
reaches a terminal state and the pipeline notification always resolves
instead of lingering as an indeterminate progress bar.
2026-09-05 21:55:01 +08:00
OneWay c1563b8172 fix: tie back arrow to pipeline idle state
Computed from operation states in SettingsViewModel instead of a fixed
delay; cancel and error release the arrow immediately.
2026-09-05 05:55:56 +08:00
OneWay 4a00d8adff fix: key search result rows by dictionary, offset and word
Paging result rows had no keys and were matched by position: appended
pages and query diffs could not reuse row state. Keys derived from
dictionaryPath+offset+word are stable per row, and the paging source
collapses duplicate entries for one dictionary card: case and diacritic
alias variants of a headword share the normalized word, so entries are
deduplicated by dictionaryPath+word+offset before emitting.
2026-09-05 03:00:44 +08:00
OneWay 944d2e6634 perf(about): parse aboutlibraries.json off the main thread
Bump AboutLibraries plugin to 15.0.4 -> 15.2.0; parse
aboutlibraries.json on Dispatchers.IO via produceState (header renders
immediately, rows fill in); drop animateContentSize from rows.
2026-09-05 00:37:41 +08:00
OneWay d7de09a152 fix: open Settings immediately on cold start when no dictionaries
Seed and synchronously read hadNoDictionaries before setContent so
Settings shows from the first frame until the startup scan confirms.
2026-09-04 19:09:19 +08:00
91 changed files with 1048 additions and 2377 deletions

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
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.
+31 -22
View File
@@ -1,10 +1,11 @@
<h1 align="center">ReSearch</h1> <h1 align="center">ReSearch</h1>
<h4 align="center">An Android dictionary reader for DSL-format dictionaries.</h4> <h4 align="center">A fast Android dictionary reader for DSL-format dictionaries.</h4>
<p align="center"> <p align="center">
<a href="#screenshots">Screenshots</a> &bull; <a href="#screenshots">Screenshots</a> &bull;
<a href="#features">Features</a> &bull; <a href="#features">Features</a> &bull;
<a href="#architecture">Architecture</a> &bull;
<a href="#dictionary-pipeline">Pipeline</a> &bull; <a href="#dictionary-pipeline">Pipeline</a> &bull;
<a href="#build">Build</a> <a href="#build">Build</a>
</p> </p>
@@ -15,30 +16,38 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
## Screenshots ## Screenshots
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search results screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png) [<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch search history sheet">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png) [<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch article screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="160" alt="ReSearch settings screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/3.png)
## Features ## Features
- Fast headword search across multiple DSL dictionaries. - Fast headword search across multiple DSL dictionaries.
- Search history in a bottom sheet: recent queries with per-entry removal and clear-all.
- Look up selected text from other apps via the selection menu.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files. - Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases. - Download dictionaries from configured URLs.
- Multiple dictionary source URLs can be added in one list.
- Automatic extraction, DictZip compression, and indexing. - Automatic extraction, DictZip compression, and indexing.
- Faithful article rendering with DSL indentation, margins, and named colors.
- Offline search after dictionaries are indexed. - Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries. - Random-access article loading from DictZip dictionaries.
- Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification. - Background processing with progress shown in Settings and notifications.
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe. - Dictionary management with enable, disable, update, and delete actions.
- Search bar placement at the top or bottom of the screen.
- Adaptive two-pane layout for tablets and large screens.
- Material 3 interface with dynamic color, light, dark, and system themes. - Material 3 interface with dynamic color, light, dark, and system themes.
- English and Russian localization. - English and Russian localization.
- About screen with open source license information. - About screen with open source license information.
## 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.
@@ -85,16 +94,16 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack ## Tech Stack
- Kotlin 2.4.21 - Kotlin 2.4.10
- Jetpack Compose (BOM 2026.09.00) - Jetpack Compose (BOM 2026.08.00)
- Material 3 1.5.0-beta01 - 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.2 - Paging 3.5.1
- OkHttp 5.5.0 - OkHttp 5.4.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.11.0
- AboutLibraries 15.2.0 metadata generation - AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.4.1 - Android Gradle Plugin 9.3.1
## Build ## Build
@@ -102,8 +111,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`. The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
## License ## License
[MIT](LICENSE) MIT
+11 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research" applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt() minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 12 versionCode = 8
versionName = "1.8.0" versionName = "1.5.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -164,17 +164,26 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.documentfile) implementation(libs.androidx.documentfile)
val composeBom = platform(libs.androidx.compose.bom) val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom) implementation(composeBom)
implementation(libs.bundles.compose) implementation(libs.bundles.compose)
implementation(libs.androidx.compose.material3.adaptive.layout) implementation(libs.androidx.compose.material3.adaptive.layout)
debugImplementation(libs.androidx.compose.runtime.tracing) debugImplementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp) implementation(libs.okhttp)
// Coroutines with Android-optimized dispatchers
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
} }
@@ -13,6 +13,9 @@ object ReSearchTrace {
const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract" const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract"
const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index" const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index"
const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import" const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import"
const val FLOW_UPDATE_CARD_APPEAR = "ReSearch/Flow/UpdateCardAppear"
const val FLOW_UPDATING = "ReSearch/Flow/Updating"
const val FLOW_PROGRESS_DISAPPEAR = "ReSearch/Flow/ProgressDisappear"
private val nextCookie = AtomicInteger() private val nextCookie = AtomicInteger()
+44 -36
View File
@@ -1,48 +1,54 @@
HSPLcom/example/research/ReSearchApplication**->**(**)** # Seeded from the measured ReSearch startup, search, scrolling, and article-opening hot paths.
HSPLcom/example/research/MainActivity**->**(**)** # Keep this profile checked in: release builders, including F-Droid, only compile it.
HSPLcom/example/research/InitialLaunch**->**(**)** # Updated 2026-06-25: Added optimized cache paths from performance optimization stages 1-4
HSPLcom/example/research/DictionaryStatus**->**(**)**
HSPLcom/example/research/core/**->**(**)**
HSPLcom/example/research/common/ui/**->**(**)**
HSPLcom/example/research/ui/theme/**->**(**)**
HSPLcom/example/research/ui/navigation/**->**(**)**
HSPLcom/example/research/data/local/preferences/**->**(**)**
HSPLcom/example/research/data/parser/ResourceManager**->**(**)**
HSPLcom/example/research/common/util/FileStorageManager**->**(**)**
HSPLcom/example/research/common/util/DateUtils**->**(**)**
HSPLcom/example/research/ui/main/**->**(**)** HSPLcom/example/research/ReSearchApplication;->**(**)**
HSPLandroidx/paging/**->**(**)** HSPLcom/example/research/MainActivity;->**(**)**
HSPLcom/example/research/feature/search/**->**(**)**
HSPLcom/example/research/data/paging/**->**(**)** HSPLcom/example/research/common/ui/AppLocaleProviderKt**->**(**)**
HSPLcom/example/research/common/ui/theme/ThemeKt**->**(**)**
HSPLcom/example/research/ui/navigation/AppNavigationKt**->**(**)**
HSPLcom/example/research/ui/main/MainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/AdaptiveMainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/components/MainTopBarKt**->**(**)**
HSPLcom/example/research/ui/main/components/SearchResultsListKt**->**(**)**
HSPLcom/example/research/feature/search/SearchViewModel**->**(**)**
HSPLcom/example/research/data/paging/IndexEntryPagingSource**->**(**)**
HSPLcom/example/research/data/parser/IndexSearcher**->**(**)** HSPLcom/example/research/data/parser/IndexSearcher**->**(**)**
HSPLcom/example/research/data/parser/FileSource**->**(**)** HSPLcom/example/research/data/parser/SearchRankingKt**->**(**)**
HSPLcom/example/research/data/parser/ResultsLruCache**->**(**)**
HSPLcom/example/research/data/parser/MetadataLruCache**->**(**)**
HSPLcom/example/research/data/parser/SearchRanking**->**(**)**
HSPLcom/example/research/data/parser/SortKey**->**(**)**
HSPLcom/example/research/data/repository/**->**(**)** HSPLcom/example/research/data/repository/LocalDictionaryRepository**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipInputStream**->**(**)** HSPLcom/example/research/data/repository/KotlinDictionaryEngine**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipRandomAccessFile**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipHeader**->**(**)**
HSPLcom/example/research/data/parser/DslArticleReader**->**(**)** HSPLcom/example/research/data/parser/DslArticleReader**->**(**)**
HSPLcom/example/research/data/parser/LruCharsetCache**->**(**)** HSPLcom/example/research/ui/article/DslAnnotatedParser**->**(**)**
HSPLcom/example/research/ui/article/**->**(**)** HSPLcom/example/research/ui/article/ArticleScreenKt**->**(**)**
HSPLcom/example/research/data/parser/Dsl**->**(**)** # Hot paths for optimized article rendering (Stage 1)
HSPLcom/example/research/data/parser/LineKind**->**(**)** HSPLcom/example/research/ui/article/ArticleLinkText**->**(**)**
HSPLcom/example/research/data/parser/ByteTrackingLineReader**->**(**)** HSPLcom/example/research/ui/article/ArticleState**->**(**)**
HSPLcom/example/research/data/parser/Utf8Writer**->**(**)**
HSPLcom/example/research/ui/settings/**->**(**)**
HSPLcom/example/research/common/progress/**->**(**)**
HSPLcom/example/research/feature/download/**->**(**)**
# About screen (bonus optimization)
HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)** HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)**
# Updated 2026-07-11: cover the settings/dictionary flow and the Compose framework.
# Settings + dictionary management flow
HSPLcom/example/research/ui/settings/**->**(**)**
Lcom/example/research/ui/settings/**
HSPLcom/example/research/common/ui/components/**->**(**)**
Lcom/example/research/common/ui/components/**
HSPLcom/example/research/common/progress/**->**(**)**
Lcom/example/research/common/progress/**
HSPLcom/example/research/feature/download/**->**(**)**
Lcom/example/research/feature/download/**
# Pre-verify the app classes already covered by method rules above
Lcom/example/research/** Lcom/example/research/**
# Compose framework: the alpha AARs ship near-empty baseline profiles, so first
# compositions run interpreted without these rules.
HSPLandroidx/compose/runtime/**->**(**)** HSPLandroidx/compose/runtime/**->**(**)**
Landroidx/compose/runtime/** Landroidx/compose/runtime/**
HSPLandroidx/compose/ui/**->**(**)** HSPLandroidx/compose/ui/**->**(**)**
@@ -55,10 +61,12 @@ HSPLandroidx/compose/animation/**->**(**)**
Landroidx/compose/animation/** Landroidx/compose/animation/**
HSPLandroidx/compose/material/**->**(**)** HSPLandroidx/compose/material/**->**(**)**
Landroidx/compose/material/** Landroidx/compose/material/**
# Support libraries on the same cold path
HSPLandroidx/lifecycle/**->**(**)** HSPLandroidx/lifecycle/**->**(**)**
Landroidx/lifecycle/** Landroidx/lifecycle/**
HSPLandroidx/activity/compose/**->**(**)** HSPLandroidx/activity/compose/**->**(**)**
Landroidx/activity/compose/** Landroidx/activity/compose/**
HSPLkotlinx/coroutines/**->**(**)** HSPLkotlinx/coroutines/**->**(**)**
Lkotlinx/coroutines/** Lkotlinx/coroutines/**
Landroidx/paging/**
@@ -7,7 +7,7 @@ sealed interface DictionaryStatus {
data object Unknown : DictionaryStatus data object Unknown : DictionaryStatus
data object Checking : DictionaryStatus data object Checking : DictionaryStatus
data object UpToDate : DictionaryStatus data object UpToDate : DictionaryStatus
data class NeedsUpdate(val sourceIds: Set<String>) : DictionaryStatus data object NeedsUpdate : DictionaryStatus
data object Empty : DictionaryStatus data object Empty : DictionaryStatus
data class Error(val message: String) : DictionaryStatus data class Error(val message: String) : 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, app.preferencesManager, createSavedStateHandle()) SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle())
} }
} }
} }
@@ -69,8 +69,6 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager, app.dictionaryImportManager,
app.dictionaryProgressStateHolder, app.dictionaryProgressStateHolder,
versionName, versionName,
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
) )
} }
} }
@@ -81,16 +79,18 @@ 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)
val initialHadNoDictionaries = loadInitialHadNoDictionaries(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 } ?: launch.theme val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: initialTheme
val language = settingsState.language.takeIf { it != "system" } ?: launch.language val language = settingsState.language.takeIf { it != "system" } ?: initialLanguage
val darkTheme = when (theme) { val darkTheme = when (theme) {
AppTheme.LIGHT -> false AppTheme.LIGHT -> false
AppTheme.DARK -> true AppTheme.DARK -> true
@@ -106,7 +106,7 @@ class MainActivity : ComponentActivity() {
AppNavigation( AppNavigation(
searchViewModel = searchViewModel, searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel, settingsViewModel = settingsViewModel,
seedNoDictionaries = launch.seedNoDictionaries seedNoDictionaries = initialHadNoDictionaries
) )
} }
} }
@@ -129,6 +129,54 @@ 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"
}
}
private fun loadInitialHadNoDictionaries(app: ReSearchApplication): Boolean {
return try {
runBlocking {
app.preferencesManager.hadNoDictionaries.first()
}
} catch (e: Exception) {
true
}
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) handleIntent(intent)
@@ -144,7 +192,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.QueryChanged(it)) searchViewModel.onAction(SearchAction.HandleExternalSearch(it))
} }
} }
} }
@@ -3,10 +3,7 @@ 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.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
@@ -15,26 +12,10 @@ 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
@@ -62,9 +43,6 @@ class ReSearchApplication : Application() {
private set 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()
@@ -75,9 +53,6 @@ class ReSearchApplication : Application() {
applicationScope.launch(Dispatchers.IO) { applicationScope.launch(Dispatchers.IO) {
preferencesManager.sanitizeDictionarySources() preferencesManager.sanitizeDictionarySources()
} }
applicationScope.launch(Dispatchers.IO) {
_initialLaunch.value = loadInitialLaunch()
}
localDictionaryRepository = LocalDictionaryRepository(this, preferencesManager = preferencesManager) localDictionaryRepository = LocalDictionaryRepository(this, preferencesManager = preferencesManager)
okHttpClient = OkHttpClient.Builder() okHttpClient = OkHttpClient.Builder()
@@ -146,20 +121,6 @@ class ReSearchApplication : Application() {
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal 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) {
super.onTrimMemory(level) super.onTrimMemory(level)
if (::localDictionaryRepository.isInitialized) { if (::localDictionaryRepository.isInitialized) {
@@ -62,7 +62,7 @@ class DictionaryPipelineCoordinator(
pipelineJob?.cancel() pipelineJob?.cancel()
pipelineJob = scope.launch(launchDispatcher) { pipelineJob = scope.launch(launchDispatcher) {
val dir = File(dictionaryPath()) val dir = File(dictionaryPath())
val filesBeforeReindex = dir.listFiles()?.map { it.name }?.toSet() ?: emptySet() val filesBeforeReindex = dir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
try { try {
triggerReindexing(isImport) triggerReindexing(isImport)
if (isImport) { if (isImport) {
@@ -85,7 +85,7 @@ class DictionaryPipelineCoordinator(
} }
cleanupOrphanedFiles(dir) cleanupOrphanedFiles(dir)
throw e throw e
} catch (_: Exception) { } catch (e: Exception) {
ui.showError() ui.showError()
} finally { } finally {
finishService() finishService()
@@ -21,16 +21,14 @@ object DictionaryProgressModel {
val progress: Float, val progress: Float,
val percent: Int, val percent: Int,
@param:StringRes val titleRes: Int, @param:StringRes val titleRes: Int,
val indeterminate: Boolean = false,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is Snapshot) return false if (other !is Snapshot) return false
return percent == other.percent && titleRes == other.titleRes && return percent == other.percent && titleRes == other.titleRes
indeterminate == other.indeterminate
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return 31 * (31 * percent + titleRes) + indeterminate.hashCode() return 31 * percent + titleRes
} }
} }
fun computeProgressSnapshot( fun computeProgressSnapshot(
@@ -94,7 +92,6 @@ object DictionaryProgressModel {
progress = p, progress = p,
percent = pct, percent = pct,
titleRes = R.string.notification_download_title, titleRes = R.string.notification_download_title,
indeterminate = dl <= 0f,
) )
} }
@@ -112,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_preparing_indexing_title, titleRes = R.string.notification_indexing_title,
) )
} }
@@ -123,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, 99) val pct = (p * 100f).toInt().coerceIn(0, 100)
return Snapshot( return Snapshot(
progress = p, progress = p,
@@ -1,87 +1,37 @@
package com.example.research.common.progress package com.example.research.common.progress
import android.os.SystemClock
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
class DictionaryProgressPresenter( class DictionaryProgressPresenter(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>, private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
private val notifications: NotificationPort, private val notifications: NotificationPort,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : PipelineTerminalUi { ) : PipelineTerminalUi {
private companion object {
val MIN_NOTIFICATION_INTERVAL = 500.milliseconds
}
@Volatile @Volatile
private var terminalActive = false private var terminalActive = false
private var lastShownTitleRes: Int = 0
private var lastEmitMs: Long = 0L
private var pendingSnapshot: DictionaryProgressModel.Snapshot? = null
private var flushJob: Job? = null
init { init {
scope.launch(mainDispatcher) { scope.launch(mainDispatcher) {
progressSnapshot.collect { snapshot -> progressSnapshot.collect { snapshot ->
when { when {
snapshot == null -> { snapshot == null -> {
resetThrottle()
if (!terminalActive && notifications.hasActiveProgressNotification()) { if (!terminalActive && notifications.hasActiveProgressNotification()) {
notifications.cancelProgressNotification() notifications.cancelProgressNotification()
} }
} }
!terminalActive -> emitThrottled(snapshot) !terminalActive -> notifications.showProgress(snapshot.percent, snapshot.titleRes)
} }
} }
} }
} }
private fun emitThrottled(snapshot: DictionaryProgressModel.Snapshot) {
val now = SystemClock.elapsedRealtime()
val stageChanged = snapshot.titleRes != lastShownTitleRes
if (stageChanged || (now - lastEmitMs).milliseconds >= MIN_NOTIFICATION_INTERVAL) {
flushJob?.cancel()
pendingSnapshot = null
publish(snapshot, now)
} else {
pendingSnapshot = snapshot
if (flushJob?.isActive != true) {
flushJob = scope.launch(mainDispatcher) {
delay(MIN_NOTIFICATION_INTERVAL - (now - lastEmitMs).milliseconds)
pendingSnapshot?.let { publish(it, SystemClock.elapsedRealtime()) }
pendingSnapshot = null
}
}
}
}
private fun publish(snapshot: DictionaryProgressModel.Snapshot, now: Long) {
if (terminalActive) return
lastShownTitleRes = snapshot.titleRes
lastEmitMs = now
notifications.showProgress(snapshot.percent, snapshot.titleRes)
}
private fun resetThrottle() {
flushJob?.cancel()
pendingSnapshot = null
lastShownTitleRes = 0
lastEmitMs = 0L
}
fun beginFlow() { fun beginFlow() {
terminalActive = false terminalActive = false
resetThrottle()
notifications.cancelTerminalNotification()
} }
override fun showImportSuccess() { override fun showImportSuccess() {
@@ -101,7 +51,6 @@ class DictionaryProgressPresenter(
override fun cancelProgress() { override fun cancelProgress() {
terminalActive = false terminalActive = false
resetThrottle()
notifications.cancelProgressNotification() notifications.cancelProgressNotification()
} }
} }
@@ -6,6 +6,5 @@ interface NotificationPort {
fun showDownloadSuccess() fun showDownloadSuccess()
fun showError() fun showError()
fun cancelProgressNotification() fun cancelProgressNotification()
fun cancelTerminalNotification()
fun hasActiveProgressNotification(): Boolean fun hasActiveProgressNotification(): Boolean
} }
@@ -1,25 +0,0 @@
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,17 +1,13 @@
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)
@@ -28,14 +24,3 @@ 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,46 +0,0 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Badge
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
private val BadgeOutset = 4.dp
@Composable
fun BadgedIcon(
painter: Painter,
tint: Color,
size: Dp,
badgeDescription: String?,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.size(size)) {
Icon(
painter = painter,
contentDescription = null,
tint = tint,
modifier = Modifier.size(size)
)
if (badgeDescription != null) {
Badge(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = BadgeOutset, y = -BadgeOutset)
.testTag("badge")
.semantics { contentDescription = badgeDescription }
)
}
}
}
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -28,8 +29,7 @@ fun CollapsibleHeader(
onToggle: () -> Unit, onToggle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
testTag: String? = null, testTag: String? = null
badgeDescription: String? = null,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -43,11 +43,11 @@ fun CollapsibleHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
BadgedIcon( Icon(
painter = painterResource(iconRes), painter = painterResource(iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
size = 24.dp, modifier = Modifier.size(24.dp)
badgeDescription = badgeDescription,
) )
Column( Column(
@@ -19,8 +19,6 @@ 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"
} }
@@ -89,7 +87,6 @@ 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)
} }
@@ -98,18 +95,7 @@ class FileStorageManager {
if (tempBinFile.exists()) { if (tempBinFile.exists()) {
tempBinFile.delete() tempBinFile.delete()
} }
var created = targetFile.createNewFile() if (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")
@@ -130,7 +116,7 @@ class FileStorageManager {
for (attempt in 1..maxRetries) { for (attempt in 1..maxRetries) {
try { try {
if (file.delete()) { if (file.delete()) {
awaitGone(file::exists) waitForFileDeletion(file)
if (!file.exists()) { if (!file.exists()) {
return@withContext true return@withContext true
} }
@@ -155,10 +141,9 @@ class FileStorageManager {
} }
} }
// FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window private suspend fun waitForFileDeletion(file: DocumentFile) {
private suspend fun awaitGone(exists: () -> Boolean) {
var waitCount = 0 var waitCount = 0
while (exists() && waitCount < MAX_DELETE_WAIT_COUNT) { while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds) delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++ waitCount++
} }
@@ -13,7 +13,6 @@ 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
@@ -59,12 +58,13 @@ class NotificationHelper(private val context: Context) : NotificationPort {
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 {
android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU || if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return true
ContextCompat.checkSelfPermission( return 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) { override fun showProgress(percent: Int, titleRes: Int) {
showUnifiedProgressNotification( showUnifiedProgressNotification(
title = context.getString(titleRes), title = context.getString(titleRes),
@@ -91,7 +91,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(notification)
} }
fun showErrorNotification() { fun showErrorNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
@@ -103,17 +103,13 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(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 override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
fun showUnifiedProgressNotification( fun showUnifiedProgressNotification(
@@ -165,14 +161,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
// 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
@@ -183,7 +171,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(notification)
} }
fun createForegroundNotification(): Notification { fun createForegroundNotification(): Notification {
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -102,15 +102,12 @@ 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("https://") -> !trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
ValidationResult.Error(ValidationError.INVALID_PROTOCOL) ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
!lowerTemplate.endsWith(".gz") && !trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
!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)
@@ -135,15 +132,8 @@ data class DictionarySource(
val parts = afterProtocol.split("/") val parts = afterProtocol.split("/")
if (parts.size < 2) return false if (parts.size < 2) return false
val hostWithPort = parts[0] val domain = parts[0]
if (hostWithPort.isEmpty()) return false if (domain.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()
@@ -1,11 +0,0 @@
package com.example.research.core.domain.model
enum class SearchBarPosition {
TOP,
BOTTOM;
companion object {
fun fromStorage(value: String?): SearchBarPosition =
entries.firstOrNull { it.name == value } ?: TOP
}
}
@@ -1,128 +0,0 @@
package com.example.research.core.util
val NaturalStringComparator: Comparator<String> = Comparator { a, b -> compareNatural(a, b) }
private const val NUMERAL_CAP = 1_000_000_000_000_000_000L
private fun digitOf(c: Char): Int = when (c) {
'〇', '零' -> 0
'一', '壹', '壱', '弌' -> 1
'二', '貳', '贰', '弐', '弍', '兩', '两' -> 2
'三', '叁', '參', '参', '弎' -> 3
'四', '肆' -> 4
'五', '伍' -> 5
'六', '陸', '陆' -> 6
'七', '柒' -> 7
'八', '捌' -> 8
'九', '玖' -> 9
else -> Character.digit(c, 10)
}
private fun smallUnitOf(c: Char): Long = when (c) {
'十', '拾' -> 10L
'百', '佰' -> 100L
'千', '仟' -> 1000L
else -> 0L
}
private fun fixedTensOf(c: Char): Long = when (c) {
'廿' -> 20L
'卅' -> 30L
'卌' -> 40L
else -> 0L
}
private fun bigUnitOf(c: Char): Long = when (c) {
'万', '萬' -> 10_000L
'億', '亿' -> 100_000_000L
'兆' -> 1_000_000_000_000L
'京' -> 10_000_000_000_000_000L
else -> 0L
}
private fun isNumeralChar(c: Char): Boolean =
digitOf(c) >= 0 || smallUnitOf(c) > 0L || fixedTensOf(c) > 0L || bigUnitOf(c) > 0L
private fun satMul(a: Long, b: Long): Long =
if (a != 0L && b > NUMERAL_CAP / a) NUMERAL_CAP else minOf(a * b, NUMERAL_CAP)
private fun satAdd(a: Long, b: Long): Long = minOf(a + b, NUMERAL_CAP)
private fun numeralValue(s: String, start: Int, end: Int): Long {
var total = 0L
var section = 0L
var pending = -1L
for (k in start until end) {
val c = s[k]
val digit = digitOf(c)
if (digit >= 0) {
pending = if (pending < 0) digit.toLong() else satAdd(satMul(pending, 10L), digit.toLong())
continue
}
val small = smallUnitOf(c)
if (small > 0L) {
section = satAdd(section, satMul(if (pending < 0) 1L else pending, small))
pending = -1
continue
}
val fixed = fixedTensOf(c)
if (fixed > 0L) {
section = satAdd(section, fixed)
pending = -1
continue
}
val base = satAdd(section, maxOf(pending, 0L))
total = satAdd(total, satMul(if (base == 0L) 1L else base, bigUnitOf(c)))
section = 0L
pending = -1
}
return satAdd(total, satAdd(section, maxOf(pending, 0L)))
}
private fun compareNatural(a: String, b: String): Int {
var i = 0
var j = 0
while (i < a.length && j < b.length) {
val ca = a[i]
val cb = b[j]
val asciiA = ca in '0'..'9'
val asciiB = cb in '0'..'9'
when {
!asciiA && !asciiB && isNumeralChar(ca) && isNumeralChar(cb) -> {
var endA = i
while (endA < a.length && isNumeralChar(a[endA])) endA++
var endB = j
while (endB < b.length && isNumeralChar(b[endB])) endB++
val diff = numeralValue(a, i, endA).compareTo(numeralValue(b, j, endB))
if (diff != 0) return diff
i = endA
j = endB
}
asciiA && asciiB -> {
var endA = i
while (endA < a.length && a[endA] in '0'..'9') endA++
var endB = j
while (endB < b.length && b[endB] in '0'..'9') endB++
var startA = i
while (startA < endA - 1 && a[startA] == '0') startA++
var startB = j
while (startB < endB - 1 && b[startB] == '0') startB++
val lengthA = endA - startA
val lengthB = endB - startB
if (lengthA != lengthB) return lengthA.compareTo(lengthB)
for (k in 0 until lengthA) {
val diff = a[startA + k].compareTo(b[startB + k])
if (diff != 0) return diff
}
i = endA
j = endB
}
else -> {
if (ca != cb) return ca.compareTo(cb)
i++
j++
}
}
}
return (a.length - i).compareTo(b.length - j)
}
@@ -60,10 +60,7 @@ fun String.extractDictionaryPrefix(): String {
} }
fun String.removeAccentTagsForIndexing(): String = 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
@@ -83,7 +80,7 @@ internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, I
builder = StringBuilder(length) builder = StringBuilder(length)
} }
builder.append(this, appendStart, index) builder.append(this, appendStart, index)
appendInner(builder, index + 3, scan) builder.append(this, index + 3, scan)
index = scan + 4 index = scan + 4
appendStart = index appendStart = index
continue continue
@@ -97,7 +94,7 @@ internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, I
}?.toString() ?: this }?.toString() ?: this
} }
internal fun String.startsWithAt(start: Int, literal: String): Boolean { private fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false 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
@@ -9,41 +9,23 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey 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 DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries") 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 }
@@ -67,17 +49,6 @@ 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]
@@ -108,25 +79,6 @@ class PreferencesManager(private val context: Context) {
} }
} }
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) { suspend fun setDictionaryActive(path: String, isActive: Boolean) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
@@ -165,11 +117,6 @@ 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
@@ -180,7 +127,6 @@ 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
} }
} }
@@ -227,51 +173,6 @@ 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,17 +15,49 @@ 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,
dictionaries: List<Dictionary>, private val dictionaries: List<Dictionary>,
private val query: String private val query: String
) : PagingSource<Int, IndexEntry>() { ) : PagingSource<IndexEntryPagingSource.TailKey, IndexEntry>() {
private val activeDictionaries = dictionaries.filter { it.isActive } data class StreamCursor(
val dictPosition: Int,
val cursor: IndexSearcher.RangeCursor
)
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IndexEntry> { data class TailKey(val streams: List<StreamCursor>)
private data class EmittedKey(
val dictPosition: Int,
val word: String,
val offset: Long
)
private val emittedArticles = HashSet<EmittedKey>()
private val activeDictionaries = dictionaries.withIndex().filter { it.value.isActive }
override suspend fun load(params: LoadParams<TailKey>): LoadResult<TailKey, IndexEntry> {
return try { return try {
loadAll() val key = params.key
if (key == null) loadHead() else loadTail(key, params.loadSize)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@@ -33,22 +65,21 @@ class IndexEntryPagingSource(
} }
} }
private suspend fun loadAll(): LoadResult<Int, IndexEntry> = private suspend fun loadHead(): LoadResult<TailKey, IndexEntry> =
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) { ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) {
coroutineScope { coroutineScope {
val perDictionary = activeDictionaries val perDictionary = activeDictionaries
.map { dict -> .map { (position, dict) ->
async { async {
val indexFile = File(dict.indexPath) val indexFile = File(dict.indexPath)
if (!indexFile.exists()) return@async null if (!indexFile.exists()) return@async null
try { try {
val entries = indexSearcher.search( val result = indexSearcher.searchWithCursors(
pathOrUri = indexFile.absolutePath, pathOrUri = indexFile.absolutePath,
query = query, query = query
includeSubstringMatches = false
) )
dict to entries Triple(position, dict, result)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: Exception) { } catch (_: Exception) {
@@ -59,9 +90,9 @@ class IndexEntryPagingSource(
.awaitAll() .awaitAll()
.filterNotNull() .filterNotNull()
val rawResults = perDictionary.flatMap { (dict, entries) -> val rawResults = perDictionary.flatMap { (position, dict, result) ->
entries.map { entry -> result.entries.map { entry ->
entry.withDictionary( position to entry.withDictionary(
dict.metadata.name.ifBlank { dict.name }, dict.metadata.name.ifBlank { dict.name },
dict.path dict.path
) )
@@ -69,19 +100,95 @@ class IndexEntryPagingSource(
} }
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val ranked = rawResults val ranked = rawResults.map { it.second }
.rankBySearchRelevance(SearchRankingContext(normalizedQuery)) .rankBySearchRelevance(SearchRankingContext(normalizedQuery))
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) } .distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) }
val streams = perDictionary.flatMap { (position, _, result) ->
result.cursors.map { StreamCursor(position, it) }
}
LoadResult.Page( LoadResult.Page(
data = ranked, data = ranked,
prevKey = null, prevKey = null,
nextKey = null nextKey = if (streams.isEmpty()) null else TailKey(streams)
) )
} }
} }
override fun getRefreshKey(state: PagingState<Int, IndexEntry>): Int? { private suspend fun loadTail(key: TailKey, loadSize: Int): LoadResult<TailKey, IndexEntry> {
val streams = key.streams.map { TailStream(it.dictPosition, it.cursor) }
val page = ArrayList<IndexEntry>(loadSize)
val pageKeys = HashSet<EmittedKey>()
val chunkSize = maxOf(loadSize, MIN_CHUNK)
while (page.size < loadSize) {
streams.forEach { it.ensureBuffered(chunkSize) }
val next = streams
.filter { it.hasBuffered }
.minByOrNull { it.peekWord }
?: break
val scanned = next.consume()
val dict = dictionaries[next.dictPosition]
val entry = scanned.entry.withDictionary(
dict.metadata.name.ifBlank { dict.name },
dict.path
)
val emittedKey = entry.emittedKey(next.dictPosition)
if (emittedKey !in emittedArticles && pageKeys.add(emittedKey)) {
page.add(entry)
}
}
emittedArticles.addAll(pageKeys)
val remaining = streams.mapNotNull { stream ->
stream.nextCursor?.let { StreamCursor(stream.dictPosition, it) }
}
return LoadResult.Page(
data = page,
prevKey = null,
nextKey = if (remaining.isEmpty()) null else TailKey(remaining)
)
}
private inner class TailStream(
val dictPosition: Int,
initialCursor: IndexSearcher.RangeCursor
) {
var nextCursor: IndexSearcher.RangeCursor? = initialCursor
private set
private var buffer: List<IndexSearcher.ScannedEntry> = emptyList()
private var position = 0
val hasBuffered: Boolean get() = position < buffer.size
val peekWord: String get() = buffer[position].entry.word
suspend fun ensureBuffered(chunkSize: Int) {
if (hasBuffered) return
val cursor = nextCursor ?: return
val indexPath = File(dictionaries[dictPosition].indexPath).absolutePath
buffer = indexSearcher.scanTailChunk(indexPath, cursor, chunkSize)
position = 0
if (buffer.isEmpty()) nextCursor = null
}
fun consume(): IndexSearcher.ScannedEntry {
val scanned = buffer[position]
position++
nextCursor = scanned.cursorAfter
return scanned
}
}
private fun IndexEntry.emittedKey(dictPosition: Int) =
EmittedKey(dictPosition, word, offset.value)
override fun getRefreshKey(state: PagingState<TailKey, IndexEntry>): TailKey? {
return null return null
} }
} }
private const val MIN_CHUNK = 48
@@ -180,19 +180,14 @@ internal fun parseDslArticleContent(rawContent: String): String {
while (i < len) { while (i < len) {
val lineStart = i val lineStart = i
while (i < len && rawContent[i] != '\n' && rawContent[i] != '\r') i++ while (i < len && rawContent[i] != '\n') i++
val lineEnd = i val lineEnd = i
if (i < len && rawContent[i] == '\r') {
i++
if (i < len && rawContent[i] == '\n') 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.trim() val trimmed = line.trimEnd()
if (trimmed.isEmpty()) continue if (trimmed.isEmpty()) continue
if (skippingHeadwords) { if (skippingHeadwords) {
@@ -1,8 +1,5 @@
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(
@@ -120,12 +117,12 @@ object DslHeadwordParser {
} }
private fun reconstructDisplayPrefix(value: String): String { private fun reconstructDisplayPrefix(value: String): String {
return removeFormattingTags(removeCurlyBraceDelimiters(value).applyAccentMarks()).trim() val reconstructed = removeCurlyBraceDelimiters(value)
} val formattingTagIndex = firstFormattingTagIndex(reconstructed)
return if (formattingTagIndex >= 0) {
private fun String.applyAccentMarks(): String = rewriteAccentTags { builder, from, to -> reconstructed.substring(0, formattingTagIndex).trim()
if (to > from) { } else {
builder.append(this[from]).append('\u0301').append(this, from + 1, to) reconstructed.trim()
} }
} }
@@ -148,6 +145,21 @@ object DslHeadwordParser {
} }
} }
private fun firstFormattingTagIndex(value: String): Int {
var index = 0
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
return index
}
index++
}
return -1
}
private fun firstCurlyContent(value: String): String? { private fun firstCurlyContent(value: String): String? {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
@@ -334,6 +346,14 @@ 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' ||
@@ -24,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 = 7 const val INDEX_VERSION = 6
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
@@ -49,7 +49,6 @@ class DslIndexer(context: Context? = null) {
suspend fun createIndex( suspend fun createIndex(
dslInputStream: java.io.InputStream, dslInputStream: java.io.InputStream,
indexFile: File, indexFile: File,
totalInputBytes: Long = -1L,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
onOperationChange: (String) -> Unit = {}, onOperationChange: (String) -> Unit = {},
onFileProgress: (Float) -> Unit = {}, onFileProgress: (Float) -> Unit = {},
@@ -66,7 +65,7 @@ class DslIndexer(context: Context? = null) {
try { foldedIndexFile(indexFile).delete() } catch (_: Throwable) { /* Legacy v5 leftover */ } try { foldedIndexFile(indexFile).delete() } catch (_: Throwable) { /* Legacy v5 leftover */ }
onOperationChange("Parsing file") onOperationChange("Parsing file")
onFileProgress(0.0f) onFileProgress(0.0f)
counts = parseDslToTempFile(bufferedStream, tempFile, totalInputBytes, counts = parseDslToTempFile(bufferedStream, tempFile,
onProgress, onFileProgress, onCharsetDetected) onProgress, onFileProgress, onCharsetDetected)
onFileProgress(0.5f) onFileProgress(0.5f)
onOperationChange("Sorting index") onOperationChange("Sorting index")
@@ -89,7 +88,6 @@ class DslIndexer(context: Context? = null) {
private suspend fun parseDslToTempFile( private suspend fun parseDslToTempFile(
inputStream: java.io.InputStream, inputStream: java.io.InputStream,
tempFile: File, tempFile: File,
totalInputBytes: Long,
onProgress: (Int) -> Unit, onProgress: (Int) -> Unit,
onFileProgress: (Float) -> Unit = {}, onFileProgress: (Float) -> Unit = {},
onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {} onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {}
@@ -232,12 +230,8 @@ class DslIndexer(context: Context? = null) {
val yieldThreshold = adaptiveConfig.yieldInterval val yieldThreshold = adaptiveConfig.yieldInterval
if (yieldCounter >= yieldThreshold) { if (yieldCounter >= yieldThreshold) {
onProgress(entryCount) onProgress(entryCount)
val parseProgress = if (totalInputBytes > 0L) { val estimatedProgress = (lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000)
(reader.position.toFloat() / totalInputBytes.toFloat()) onFileProgress(estimatedProgress.coerceIn(0f, 0.4f))
} else {
(lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000)
}
onFileProgress(parseProgress.coerceIn(0f, 0.4f))
if (resourceManager != null) { if (resourceManager != null) {
if (resourceManager.shouldYield(adaptiveConfig)) { if (resourceManager.shouldYield(adaptiveConfig)) {
@@ -18,7 +18,6 @@ import kotlinx.coroutines.yield
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.Closeable import java.io.Closeable
import java.io.DataInputStream import java.io.DataInputStream
import java.io.EOFException
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.RandomAccessFile import java.io.RandomAccessFile
@@ -26,6 +25,7 @@ import java.io.RandomAccessFile
class IndexSearcher(private val context: Context) { class IndexSearcher(private val context: Context) {
private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L) private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L)
private val resultsCache = ResultsLruCache<List<IndexEntry>>(maxEntries = 20) private val resultsCache = ResultsLruCache<List<IndexEntry>>(maxEntries = 20)
private val cursorResultsCache = ResultsLruCache<CursorSearchResult>(maxEntries = 20)
/** /**
* Pre-allocated reusable buffer for readEntry() to reduce allocations. * Pre-allocated reusable buffer for readEntry() to reduce allocations.
@@ -45,6 +45,27 @@ class IndexSearcher(private val context: Context) {
val dataStartOffset: Long val dataStartOffset: Long
) )
data class RangeCursor(
val rangeQuery: String,
val absoluteIndex: Int,
val fileOffset: Long
)
internal class RangeScanResult(
val entries: List<IndexEntry>,
val nextCursor: RangeCursor?
)
class CursorSearchResult(
val entries: List<IndexEntry>,
val cursors: List<RangeCursor>
)
class ScannedEntry(
val entry: IndexEntry,
val cursorAfter: RangeCursor?
)
suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) { suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
try { try {
@@ -88,7 +109,7 @@ class IndexSearcher(private val context: Context) {
} }
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val foldedQuery = normalizedQuery.foldLatinDiacritics() val foldedQuery = normalizedQuery.foldLatinDiacritics()
val cacheKey = "$pathOrUri:$includeSubstringMatches:${normalizedQuery.sanitizeCacheKey()}" val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
resultsCache.get(cacheKey)?.let { resultsCache.get(cacheKey)?.let {
return@withContext it return@withContext it
} }
@@ -97,9 +118,9 @@ class IndexSearcher(private val context: Context) {
val count = metadata.totalCount val count = metadata.totalCount
if (count == 0) return@withContext emptyList() if (count == 0) return@withContext emptyList()
val results = openSource(pathOrUri).use { source -> val results = openSource(pathOrUri).use { source ->
val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).toMutableList() val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).entries.toMutableList()
if (foldedQuery != normalizedQuery) { if (foldedQuery != normalizedQuery) {
prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery)) prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery).entries)
} }
val substringMatches = mutableListOf<IndexEntry>() val substringMatches = mutableListOf<IndexEntry>()
@@ -144,9 +165,81 @@ class IndexSearcher(private val context: Context) {
metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) } metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
} }
suspend fun searchWithCursors(
pathOrUri: String,
query: String
): CursorSearchResult = withContext(Dispatchers.Default) {
if (query.isBlank()) {
return@withContext CursorSearchResult(emptyList(), emptyList())
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val foldedQuery = normalizedQuery.foldLatinDiacritics()
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
cursorResultsCache.get(cacheKey)?.let {
return@withContext it
}
try {
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
if (metadata.totalCount == 0) {
return@withContext CursorSearchResult(emptyList(), emptyList())
}
val result = openSource(pathOrUri).use { source ->
val scans = buildList {
add(readPrefixMatches(source, metadata, normalizedQuery))
if (foldedQuery != normalizedQuery) {
add(readPrefixMatches(source, metadata, foldedQuery))
}
}
val entries = scans.flatMap { it.entries }
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
.distinctBy { it.articleKey() }
CursorSearchResult(entries, scans.mapNotNull { it.nextCursor })
}
cursorResultsCache.put(cacheKey, result)
result
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "searchWithCursors() - error: ${e.message}", e)
if (e is DictionaryError) throw e
throw DictionaryError.SearchError.QueryFailed(context.getString(R.string.error_search_failed, query.take(20), pathOrUri.take(20)), e)
}
}
suspend fun scanTailChunk(
pathOrUri: String,
cursor: RangeCursor,
maxEntries: Int
): List<ScannedEntry> = withContext(Dispatchers.Default) {
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
if (cursor.absoluteIndex >= metadata.totalCount) return@withContext emptyList()
openSource(pathOrUri).use { source ->
source.seek(cursor.fileOffset)
val out = ArrayList<ScannedEntry>(maxEntries)
var currentIdx = cursor.absoluteIndex
var iterationCount = 0
while (currentIdx < metadata.totalCount && out.size < maxEntries) {
if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(cursor.rangeQuery)) break
currentIdx++
iterationCount++
val cursorAfter = if (currentIdx < metadata.totalCount) {
RangeCursor(cursor.rangeQuery, currentIdx, source.position())
} else {
null
}
out.add(ScannedEntry(entry.copy(word = entryWordLower), cursorAfter))
}
out
}
}
fun trimMemory(level: Int) { fun trimMemory(level: Int) {
metadataCache.trim(level) metadataCache.trim(level)
resultsCache.trim(level) resultsCache.trim(level)
cursorResultsCache.trim(level)
} }
suspend fun isMetadataLoaded(pathOrUri: String): Boolean { suspend fun isMetadataLoaded(pathOrUri: String): Boolean {
@@ -256,9 +349,9 @@ class IndexSearcher(private val context: Context) {
source: RandomAccessSource, source: RandomAccessSource,
metadata: IndexMetadata, metadata: IndexMetadata,
query: String query: String
): List<IndexEntry> { ): RangeScanResult {
val firstMatch = findFirstMatch(source, metadata, query) val firstMatch = findFirstMatch(source, metadata, query)
?: return emptyList() ?: return RangeScanResult(emptyList(), null)
return readMatchingEntries(source, metadata, firstMatch, query) return readMatchingEntries(source, metadata, firstMatch, query)
} }
@@ -322,21 +415,26 @@ class IndexSearcher(private val context: Context) {
metadata: IndexMetadata, 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 < metadata.totalCount) { while (currentIdx < metadata.totalCount) {
if (uniqueArticles.size >= MAX_PREFIX_RESULTS) {
return RangeScanResult(results, RangeCursor(query, currentIdx, source.position()))
}
if (iterationCount % 1000 == 0) yield() if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source, metadata) val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase() val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(query)) break if (!entryWordLower.startsWith(query)) break
uniqueArticles.add(entry.articleKey())
results.add(entry.copy(word = entryWordLower)) results.add(entry.copy(word = entryWordLower))
currentIdx++ currentIdx++
iterationCount++ iterationCount++
} }
return results return RangeScanResult(results, null)
} }
private fun readPooledString(source: RandomAccessSource, length: Int): String { private fun readPooledString(source: RandomAccessSource, length: Int): String {
@@ -356,7 +454,11 @@ class IndexSearcher(private val context: Context) {
val originalWord = 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())
val isAlias = metadata.version >= 6 && source.readUnsignedByte() and ENTRY_FLAG_ALIAS != 0 val isAlias = if (metadata.version >= 6) {
source.readUnsignedByte() and ENTRY_FLAG_ALIAS != 0
} else {
false
}
return IndexEntry(word, originalWord, offset, length, isAlias = isAlias) return IndexEntry(word, originalWord, offset, length, isAlias = isAlias)
} }
@@ -370,11 +472,8 @@ class IndexSearcher(private val context: Context) {
private fun openSource(pathOrUri: String): RandomAccessSource { private fun openSource(pathOrUri: String): RandomAccessSource {
val file = File(pathOrUri) val file = File(pathOrUri)
return if (file.exists()) { if (!file.exists()) throw DictionaryError.FileSystemError.NotFound(pathOrUri)
FileSource(file) return 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)
@@ -385,6 +484,7 @@ class IndexSearcher(private val context: Context) {
private const val TAG = "IndexSearcher" private const val TAG = "IndexSearcher"
private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000 private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000
private const val SPARSE_INTERVAL_V1 = 128 private const val SPARSE_INTERVAL_V1 = 128
private const val MAX_PREFIX_RESULTS = 200
private const val MAX_SUBSTRING_RESULTS = 100 private const val MAX_SUBSTRING_RESULTS = 100
} }
} }
@@ -489,89 +589,30 @@ interface RandomAccessSource : Closeable {
} }
class FileSource(file: File) : RandomAccessSource { class FileSource(file: File) : RandomAccessSource {
private val raf = RandomAccessFile(file, "r") private val raf = RandomAccessFile(file, "r")
private val buffer = ByteArray(BUFFER_SIZE)
private var bufferStart = 0L
private var bufferEnd = 0L
private var pos = 0L
override fun seek(pos: Long) { override fun seek(pos: Long) {
this.pos = pos
}
override fun position(): Long = pos
private fun refill(minNeeded: Int): Boolean {
if (bufferEnd - pos >= minNeeded) return true
bufferStart = pos
raf.seek(pos) raf.seek(pos)
val read = raf.read(buffer)
bufferEnd = bufferStart + maxOf(read, 0)
return bufferEnd - pos >= minNeeded
} }
override fun readUnsignedByte(): Int { override fun position(): Long = raf.filePointer
if (!refill(1)) throw EOFException()
return buffer[(pos++ - bufferStart).toInt()].toInt() and 0xFF
}
override fun readUnsignedShort(): Int { override fun readUnsignedByte(): Int = raf.readUnsignedByte()
if (!refill(2)) throw EOFException()
val i = (pos - bufferStart).toInt()
pos += 2
return (buffer[i].toInt() and 0xFF shl 8) or (buffer[i + 1].toInt() and 0xFF)
}
override fun readInt(): Int { override fun readUnsignedShort(): Int = raf.readUnsignedShort()
if (!refill(4)) throw EOFException()
val i = (pos - bufferStart).toInt()
pos += 4
return (buffer[i].toInt() shl 24) or
(buffer[i + 1].toInt() and 0xFF shl 16) or
(buffer[i + 2].toInt() and 0xFF shl 8) or
(buffer[i + 3].toInt() and 0xFF)
}
override fun readLong(): Long { override fun readInt(): Int = raf.readInt()
if (!refill(8)) throw EOFException()
val i = (pos - bufferStart).toInt() override fun readLong(): Long = raf.readLong()
pos += 8
var value = 0L
for (k in 0 until 8) {
value = (value shl 8) or (buffer[i + k].toLong() and 0xFF)
}
return value
}
override fun readFully(b: ByteArray) { override fun readFully(b: ByteArray) {
readFully(b, 0, b.size) raf.readFully(b)
} }
override fun readFully(b: ByteArray, off: Int, len: Int) { override fun readFully(b: ByteArray, off: Int, len: Int) {
if (len > BUFFER_SIZE / 2) {
raf.seek(pos)
raf.readFully(b, off, len) raf.readFully(b, off, len)
pos += len
bufferEnd = bufferStart
return
}
var remaining = len
var dst = off
while (remaining > 0) {
if (!refill(1)) throw EOFException()
val i = (pos - bufferStart).toInt()
val chunk = minOf(remaining, (bufferEnd - pos).toInt())
System.arraycopy(buffer, i, b, dst, chunk)
pos += chunk
dst += chunk
remaining -= chunk
}
} }
override fun close() { override fun close() {
raf.close() raf.close()
} }
private companion object {
private const val BUFFER_SIZE = 64 * 1024
}
} }
@@ -1,7 +1,6 @@
package com.example.research.data.parser package com.example.research.data.parser
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.NaturalStringComparator
import com.example.research.core.util.foldLatinDiacritics import com.example.research.core.util.foldLatinDiacritics
internal data class SearchRankingContext( internal data class SearchRankingContext(
@@ -41,11 +40,6 @@ internal fun List<IndexEntry>.rankBySearchRelevance(
entry = entry, entry = entry,
) )
} }
.sortedWith( .sortedWith(compareBy({ it.matchTier }, { it.exactMatch }, { it.charCount }, { it.word }))
compareBy<SortKey> { it.matchTier }
.thenBy { it.exactMatch }
.thenBy { it.charCount }
.thenBy(NaturalStringComparator) { it.word }
)
.map { it.entry } .map { it.entry }
} }
@@ -22,7 +22,6 @@ import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.io.RandomAccessFile
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
@@ -52,14 +51,12 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
val indexFile = File(indexPath) val indexFile = File(indexPath)
val metadata = readMetadata(dictPath) val metadata = readMetadata(dictPath)
val totalInputBytes = uncompressedInputLength(dictFile)
val articleCount = withContext(Dispatchers.IO) { val articleCount = withContext(Dispatchers.IO) {
openDslInputStream(dictPath).use { inputStream -> openDslInputStream(dictPath).use { inputStream ->
dslIndexer.createIndex( dslIndexer.createIndex(
dslInputStream = inputStream, dslInputStream = inputStream,
indexFile = indexFile, indexFile = indexFile,
totalInputBytes = totalInputBytes,
onProgress = { count -> onProgress = { count ->
onArticlesIndexed(count) onArticlesIndexed(count)
}, },
@@ -115,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 ((name, path, indexPath, _, _, metadata) in dictionaries) { for (dict in dictionaries) {
try { try {
val resolvedIndexPath = resolveIndexPath(indexPath) val indexPath = resolveIndexPath(dict.indexPath)
val entry = indexSearcher.findFirstEntry(resolvedIndexPath, query) val entry = indexSearcher.findFirstEntry(indexPath, query)
if (entry != null) { if (entry != null) {
allResults.add(entry.withDictionary(metadata.name.ifBlank { name }, path)) allResults.add(entry.withDictionary(dict.metadata.name.ifBlank { dict.name }, dict.path))
} }
if (allResults.size >= maxResults) break if (allResults.size >= maxResults) break
} catch (e: OutOfMemoryError) { } catch (e: OutOfMemoryError) {
Log.e(TAG, "search() - OOM for $indexPath", e) Log.e(TAG, "search() - OOM for ${dict.indexPath}", e)
trimMemory(80) trimMemory(80)
break break
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "search() - error for $indexPath: ${e.message}") Log.w(TAG, "search() - error for ${dict.indexPath}: ${e.message}")
} }
} }
allResults allResults
@@ -210,30 +207,6 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
} }
} }
private fun uncompressedInputLength(file: File): Long {
val name = file.name.lowercase()
if (!name.endsWith(".dz") && !name.endsWith(".gz")) return file.length()
return readGzipUncompressedLength(file).takeIf { it > 0L } ?: file.length()
}
private fun readGzipUncompressedLength(file: File): Long = try {
RandomAccessFile(file, "r").use { raf ->
val fileLength = raf.length()
if (fileLength < 18L) {
-1L
} else {
raf.seek(fileLength - 4L)
val b0 = raf.readUnsignedByte()
val b1 = raf.readUnsignedByte()
val b2 = raf.readUnsignedByte()
val b3 = raf.readUnsignedByte()
(b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)).toLong() and 0xFFFFFFFFL
}
}
} catch (_: Exception) {
-1L
}
private fun resolveIndexPath(indexArg: String): String { private fun resolveIndexPath(indexArg: String): String {
val candidate = File(indexArg) val candidate = File(indexArg)
if (candidate.exists()) return candidate.absolutePath if (candidate.exists()) return candidate.absolutePath
@@ -182,7 +182,7 @@ class LocalDictionaryRepository(
progressTracker = ProgressTracker(filesToScan) progressTracker = ProgressTracker(filesToScan)
existingDictionaries.forEach { dict -> existingDictionaries.forEach { dict ->
progressTracker?.updateFileProgress(File(dict.path).name, 1.0f) progressTracker?.updateFileProgress(dict.name, 1.0f)
} }
mutableIndexingProgress.emit( mutableIndexingProgress.emit(
@@ -538,12 +538,12 @@ class LocalDictionaryRepository(
val dictionaries = mutableDictionaries.value val dictionaries = mutableDictionaries.value
if (dictionaries.isEmpty()) return@withContext if (dictionaries.isEmpty()) return@withContext
for ((_, _, indexPath) in dictionaries) { for (dict in dictionaries) {
try { try {
val resolvedIndexPath = resolveIndexPath(indexPath) val indexPath = resolveIndexPath(dict.indexPath)
val indexSearcher = engine.getIndexSearcher() val indexSearcher = engine.getIndexSearcher()
if (!indexSearcher.isMetadataLoaded(resolvedIndexPath)) { if (!indexSearcher.isMetadataLoaded(indexPath)) {
engine.preloadIndexMetadata(resolvedIndexPath) engine.preloadIndexMetadata(indexPath)
} }
yield() yield()
} catch (_: Exception) { /* Ignore preload failure */ } } catch (_: Exception) { /* Ignore preload failure */ }
@@ -57,7 +57,6 @@ class DownloadManager(
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke() onFlowStarted?.invoke()
cancelCleanupJob = null cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
@@ -82,6 +81,8 @@ class DownloadManager(
return@withLock return@withLock
} }
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -148,7 +149,6 @@ class DownloadManager(
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke() onFlowStarted?.invoke()
cancelCleanupJob = null cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
@@ -177,6 +177,8 @@ class DownloadManager(
return@withLock return@withLock
} }
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -7,7 +7,6 @@ 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
@@ -50,36 +49,45 @@ class DictionaryChecker(
} }
} }
suspend fun sourcesWithUpdates( suspend fun areDictionariesUpToDate(
folderUri: Uri, folderUri: Uri,
sources: List<DictionarySource>, sources: List<DictionarySource>,
files: List<DocumentFileInfo>? = null files: List<DocumentFileInfo>? = null
): Set<String> = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
try { try {
val enabledSources = sources.filter { it.isEnabled }
if (enabledSources.isEmpty()) {
return@withContext true
}
val currentDate = DateUtils.getCurrentDateString() val currentDate = DateUtils.getCurrentDateString()
val previousDate = DateUtils.getPreviousDateString() val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files) val dictFiles = getDictFiles(folderUri, files)
sources
.filter { it.isEnabled && DictionarySource.hasDatePlaceholder(it.urlTemplate) } val allHaveToday = enabledSources.all { source ->
.filter { source -> dictFiles.hasFileForSource(source, currentDate)
when { }
dictFiles.hasFileForSource(source, currentDate) -> false
dictFiles.hasFileForSource(source, previousDate) -> if (allHaveToday) {
return@withContext true
}
val allHaveYesterday = enabledSources.all { source ->
dictFiles.hasFileForSource(source, previousDate)
}
if (allHaveYesterday) {
val serverHasToday = enabledSources.any { source ->
isSourceAvailableOnServer(source, currentDate) isSourceAvailableOnServer(source, currentDate)
else -> when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
} }
return@withContext !serverHasToday
} }
}
.mapTo(mutableSetOf(), DictionarySource::id) false
} 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)
emptySet() false
} }
} }
@@ -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 ((name, _, date, ext) in items) { for (item in items) {
if (DateUtils.isDateBefore(date, latestDsl)) { if (DateUtils.isDateBefore(item.date, latestDsl)) {
toDelete.add(name) toDelete.add(item.name)
continue continue
} }
if (ext in indexExtensions && date != latestDsl) { if (item.ext in indexExtensions && item.date != latestDsl) {
toDelete.add(name) toDelete.add(item.name)
continue continue
} }
if (ext in indexExtensions && date !in dslDates) { if (item.ext in indexExtensions && item.date !in dslDates) {
toDelete.add(name) toDelete.add(item.name)
} }
} }
} }
@@ -46,13 +46,14 @@ class DictionaryExtractor(
reportProgress(processedIndex.toFloat() / totalFiles) reportProgress(processedIndex.toFloat() / totalFiles)
} }
for ((fileName, uri) in gzFiles) { for (file 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, uri) ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed() markFileProcessed()
continue continue
} }
@@ -64,19 +65,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, uri) ArchiveUtils.deleteFile(context, file.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, uri, dslUri) val extractResult = ArchiveUtils.extractGzWithBom(context, file.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, uri) ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed() markFileProcessed()
continue continue
} }
@@ -91,7 +92,7 @@ class DictionaryExtractor(
ArchiveUtils.deleteFile(context, dslUri) ArchiveUtils.deleteFile(context, dslUri)
} }
} }
ArchiveUtils.deleteFile(context, uri) ArchiveUtils.deleteFile(context, file.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)
@@ -99,11 +100,12 @@ class DictionaryExtractor(
markFileProcessed() markFileProcessed()
} }
for ((dslFileName, uri) in rawDslFiles) { for (file 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, uri) ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed() markFileProcessed()
continue continue
} }
@@ -111,11 +113,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(uri, dslFileName, folderUri) { compressProgress -> val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri) { compressProgress ->
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight)) reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
} }
if (compressResult.isSuccess) { if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, uri) ArchiveUtils.deleteFile(context, file.uri)
} }
markFileProcessed() markFileProcessed()
} }
@@ -128,10 +130,11 @@ class DictionaryExtractor(
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) && !lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!lowerName.endsWith(".dsl.gz") !lowerName.endsWith(".dsl.gz")
} }
for ((dslFileName, uri) in remainingRawDslFiles) { for (file in remainingRawDslFiles) {
val compressResult = ensureDslDictZip(uri, dslFileName, folderUri, onProgress = null) val dslFileName = file.name
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri, onProgress = null)
if (compressResult.isSuccess) { if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, uri) ArchiveUtils.deleteFile(context, file.uri)
} }
} }
@@ -149,18 +152,18 @@ class DictionaryExtractor(
try { try {
withContext(Dispatchers.IO + NonCancellable) { withContext(Dispatchers.IO + NonCancellable) {
val files = fileStorageManager.listFilesInFolder(folderUri) val files = fileStorageManager.listFilesInFolder(folderUri)
for ((name, uri) in files) { for (file in files) {
val n = name.lowercase() val n = file.name.lowercase()
val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) && val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) &&
!n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!n.endsWith(".dsl.gz") !n.endsWith(".dsl.gz")
val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION)
when { when {
isBareDz -> ArchiveUtils.deleteFile(context, uri) isBareDz -> ArchiveUtils.deleteFile(context, file.uri)
isRawDsl -> { isRawDsl -> {
val pair = fileStorageManager.findFile(folderUri, "$name.dz") val pair = fileStorageManager.findFile(folderUri, file.name + ".dz")
if (pair == null || !pair.exists()) { if (pair == null || !pair.exists()) {
ArchiveUtils.deleteFile(context, uri) ArchiveUtils.deleteFile(context, file.uri)
} }
} }
} }
@@ -25,20 +25,21 @@ class DictionaryRepository(
private val extractor = DictionaryExtractor(context, fileStorageManager) private val extractor = DictionaryExtractor(context, fileStorageManager)
private val cleaner = DictionaryCleaner(context, fileStorageManager) private val cleaner = DictionaryCleaner(context, fileStorageManager)
private class DownloadSession(val filesBefore: Set<String>, val prefixes: Set<String>) private var filesBeforeDownload = mutableSetOf<String>()
private var downloadedPrefixes = mutableSetOf<String>()
@Volatile
private var session: DownloadSession? = null
companion object { companion object {
private const val TAG = "DictionaryRepository" private const val TAG = "DictionaryRepository"
} }
suspend fun sourcesWithUpdates( suspend fun areDictionariesUpToDate(
sources: List<DictionarySource> sources: List<DictionarySource>? = null
): Set<String> = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
val uri = Uri.fromFile(File(preferencesManager.dictionaryPath)) val folderUri = preferencesManager.dictionaryPath
checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri)) val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first()
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
} }
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> = suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
@@ -80,10 +81,15 @@ class DictionaryRepository(
val files = fileStorageManager.listFilesInFolder(uri) val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files) cleaner.deleteCorruptedArchives(uri, files)
session = DownloadSession( synchronized(filesBeforeDownload) {
filesBefore = files.mapTo(mutableSetOf()) { it.name }, filesBeforeDownload = files.map { it.name }.toMutableSet()
prefixes = sources.mapNotNullTo(mutableSetOf()) { DictionarySource.extractPrefix(it.urlTemplate) }, }
)
synchronized(downloadedPrefixes) {
downloadedPrefixes = sources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries( downloader.downloadDictionaries(
folderUri = uri, folderUri = uri,
@@ -108,22 +114,64 @@ class DictionaryRepository(
cleaner.deleteUnprocessedFiles(uri, files) cleaner.deleteUnprocessedFiles(uri, files)
val current = session ?: return@withContext Result.success(Unit) val filesAfterStandardCleanup = fileStorageManager.listFilesInFolder(uri)
session = null
fileStorageManager.listFilesInFolder(uri) val existingFiles = synchronized(filesBeforeDownload) {
.filter { file -> filesBeforeDownload.toSet()
file.name !in current.filesBefore &&
current.prefixes.any { prefix -> file.name.startsWith("${prefix}_") }
} }
.forEach { (fileName, fileUri) ->
val docFile = fileStorageManager.getDocumentFile(fileUri) val prefixes = synchronized(downloadedPrefixes) {
if (docFile != null && docFile.exists() && !docFile.delete() && downloadedPrefixes.toSet()
!fileStorageManager.forceDeleteFile(docFile, fileName) }
) {
val finalPrefixes = prefixes.ifEmpty {
val sources = preferencesManager.dictionarySources.first()
sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
}
val filesToDelete = if (existingFiles.isEmpty()) {
filesAfterStandardCleanup.filter { file ->
val fileName = file.name
finalPrefixes.any { prefix -> fileName.startsWith("${prefix}_") }
}
} else {
filesAfterStandardCleanup.filter { file ->
file.name !in existingFiles &&
finalPrefixes.any { prefix -> file.name.startsWith("${prefix}_") }
}
}
if (filesToDelete.isNotEmpty()) {
var deletedCount = 0
for (file in filesToDelete) {
val fileName = file.name
val docFile = fileStorageManager.getDocumentFile(file.uri)
if (docFile != null && docFile.exists()) {
val deleted = docFile.delete()
if (deleted) {
deletedCount++
} else {
val forceDeleted = fileStorageManager.forceDeleteFile(docFile, fileName)
if (!forceDeleted) {
Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName") Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName")
} else {
deletedCount++
} }
} }
}
}
}
synchronized(filesBeforeDownload) {
if (filesBeforeDownload.isNotEmpty()) {
filesBeforeDownload.clear()
}
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
Result.success(Unit) Result.success(Unit)
} }
@@ -136,7 +184,15 @@ class DictionaryRepository(
val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet() val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
val result = cleaner.performAllCleanup(uri, prefixes, files) val result = cleaner.performAllCleanup(uri, prefixes, files)
session = null
synchronized(filesBeforeDownload) {
filesBeforeDownload.clear()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
result result
} }
} }
@@ -88,12 +88,6 @@ 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)
} }
@@ -131,7 +125,7 @@ class DictionaryForegroundService : Service() {
startForegroundService() startForegroundService()
} }
ACTION_FINISH -> { ACTION_FINISH -> {
stopForegroundService(removeNotification = true) stopForegroundService(removeNotification = false)
} }
ACTION_STOP -> { ACTION_STOP -> {
try { try {
@@ -10,13 +10,11 @@ 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
import com.example.research.ui.article.DslBlock
import com.example.research.ui.article.DslAnnotatedParser import com.example.research.ui.article.DslAnnotatedParser
import com.example.research.ui.article.HEAVY_ARTICLE_BLOCK_COUNT
import com.example.research.ui.article.mergeDslBlocks
import com.example.research.R import com.example.research.R
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -30,12 +28,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) {
@@ -53,29 +51,20 @@ 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
private val parseCache = java.util.LinkedHashMap<String, ArticleState.Success>(PARSE_CACHE_SIZE, 0.75f, true) private val parseCache = java.util.LinkedHashMap<String, List<DslBlock>>(PARSE_CACHE_SIZE, 0.75f, true)
private val articleHistory = ArrayDeque<ArticleState.Success>() private val articleHistory = ArrayDeque<ArticleState.Success>()
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> = private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
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 = repository.dictionaries.value.filter { it.isActive } initialValue = emptyList()
) )
init { init {
@@ -133,24 +122,20 @@ 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 {
@@ -170,27 +155,6 @@ 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
} }
@@ -212,7 +176,12 @@ class SearchViewModel(
val cached = synchronized(parseCache) { parseCache[cacheKey] } val cached = synchronized(parseCache) { parseCache[cacheKey] }
if (cached != null) { if (cached != null) {
mutableSelectedArticle.update { mutableSelectedArticle.update {
cached.copy(title = entry.originalWord, dictionaryName = entry.dictionaryName) ArticleState.Success(
title = entry.originalWord,
dslContent = cached,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
} }
return@launch return@launch
} }
@@ -224,13 +193,6 @@ class SearchViewModel(
colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME, colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME,
) )
} }
val article = ArticleState.Success(
title = entry.originalWord,
dslContent = mergeDslBlocks(annotatedStringList),
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
isHeavy = annotatedStringList.size > HEAVY_ARTICLE_BLOCK_COUNT,
)
synchronized(parseCache) { synchronized(parseCache) {
if (parseCache.size >= PARSE_CACHE_SIZE) { if (parseCache.size >= PARSE_CACHE_SIZE) {
val iterator = parseCache.entries.iterator() val iterator = parseCache.entries.iterator()
@@ -238,12 +200,19 @@ class SearchViewModel(
iterator.next().run { iterator.remove() } iterator.next().run { iterator.remove() }
} }
} }
parseCache[cacheKey] = article parseCache[cacheKey] = annotatedStringList
} }
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
ArticleTextWarmUp.warmUp(getApplication(), article.dslContent) ArticleTextWarmUp.warmUp(getApplication(), annotatedStringList)
}
mutableSelectedArticle.update {
ArticleState.Success(
title = entry.originalWord,
dslContent = annotatedStringList,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
} }
mutableSelectedArticle.update { article }
} }
.onError { message, exception -> .onError { message, exception ->
Log.e(TAG, "loadArticle() - error: $message", exception) Log.e(TAG, "loadArticle() - error: $message", exception)
@@ -308,7 +277,6 @@ 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 ->
@@ -317,9 +285,21 @@ 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"
// Parse cache: stores parsed DslBlock lists to avoid re-parsing articles
// Size: 50 articles - typical user navigation depth in a session
// Memory estimate: ~50KB per article average (text + formatting)
// Total: ~2.5MB worst case
private const val PARSE_CACHE_SIZE = 50 private const val PARSE_CACHE_SIZE = 50
private const val KEY_SEARCH_QUERY = "search_query" private const val KEY_SEARCH_QUERY = "search_query"
@@ -2,11 +2,9 @@ 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(
@@ -76,8 +74,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)
.use { stream -> json.decodeFromStream(stream) } .bufferedReader()
.use { reader -> json.decodeFromString(reader.readText()) }
} }
@@ -1,24 +1,21 @@
package com.example.research.ui.about package com.example.research.ui.about
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.AlertDialogDefaults import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BasicAlertDialog 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
@@ -27,34 +24,29 @@ 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.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.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun AboutScreen( fun AboutScreen(
onNavigateBack: () -> Unit onNavigateBack: () -> Unit
@@ -74,25 +66,18 @@ fun AboutScreen(
} }
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 = {
AppBackButton( IconButton(onClick = onNavigateBack) {
onClick = onNavigateBack, Icon(
modifier = Modifier.padding(start = 6.dp) painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back)
) )
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
@@ -108,20 +93,24 @@ fun AboutScreen(
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
.padding(padding) .padding(padding)
) { ) {
item(key = "about_header", contentType = "about_header") { item(key = "about_header") {
AboutHeader(versionName = versionName) AboutHeader(versionName = versionName)
} }
items( items(
items = librariesData?.libraries.orEmpty(), 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.orEmpty(), licenses = librariesData?.licenses.orEmpty(),
expanded = expandedLibraryId == library.uniqueId, expanded = expandedLibraryId == library.uniqueId,
onToggle = onToggleLibrary, onToggle = {
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
null
} else {
library.uniqueId
}
},
onLicenseClick = { dialogLicense = it }, onLicenseClick = { dialogLicense = it },
) )
} }
@@ -154,14 +143,13 @@ 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 = versionName, text = stringResource(R.string.version_format, versionName),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline() modifier = Modifier.padding(bottom = 2.dp)
) )
} }
@@ -180,35 +168,30 @@ 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: (String) -> Unit, onToggle: () -> 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)
val background by animateColorAsState( Surface(
targetValue = if (expanded) { modifier = Modifier
.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
}, },
label = "aboutLibraryRowBackground", onClick = onToggle
)
Surface(
modifier = modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = background,
onClick = { onToggle(library.uniqueId) }
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -314,74 +297,26 @@ private fun LicenseDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit, onOpenWebsite: (String) -> Unit,
) { ) {
var open by remember { mutableStateOf(false) } AlertDialog(
var wasOpen by remember { mutableStateOf(false) } onDismissRequest = onDismiss,
LaunchedEffect(Unit) { open = true } title = { Text(license.name) },
val scale by animateFloatAsState( text = {
targetValue = if (open) 1f else 0.92f,
animationSpec = tween(180),
label = "licenseDialogScale",
)
val alpha by animateFloatAsState(
targetValue = if (open) 1f else 0f,
animationSpec = tween(180),
label = "licenseDialogAlpha",
)
LaunchedEffect(open) {
if (open) {
wasOpen = true
} else if (wasOpen) {
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(
text = license.content ?: stringResource(R.string.license_text_unavailable), text = license.content ?: stringResource(R.string.license_text_unavailable),
style = MaterialTheme.typography.bodyMedium, modifier = Modifier.verticalScroll(rememberScrollState())
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) )
Spacer(modifier = Modifier.height(24.dp)) },
Row( confirmButton = {
modifier = Modifier.fillMaxWidth(), TextButton(onClick = onDismiss) {
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) Text(stringResource(R.string.action_close))
) { }
},
dismissButton = {
license.url?.takeIf(String::isNotBlank)?.let { url -> license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) { TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website)) Text(stringResource(R.string.website))
} }
} }
TextButton(onClick = { open = false }) {
Text(stringResource(R.string.action_close))
}
}
}
}
} }
)
} }
@@ -196,7 +196,9 @@ private suspend fun PointerInputScope.detectReferenceTaps(
if (change == null) { if (change == null) {
break break
} }
if ((change.position - downPosition).getDistance() > viewConfiguration.touchSlop) { if (change.isConsumed ||
(change.position - downPosition).getDistance() > viewConfiguration.touchSlop
) {
canceled = true canceled = true
} }
if (!change.pressed) { if (!change.pressed) {
@@ -1,58 +1,52 @@
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.interaction.MutableInteractionSource import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.text.selection.SelectionContainer
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.foundation.text.selection.SelectionContainer import androidx.compose.material3.*
import androidx.compose.material3.Button import androidx.compose.runtime.*
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.text.style.LineHeightStyle import androidx.compose.ui.unit.dp
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.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.AppBackButton
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.AppWindowInsets import com.example.research.feature.search.SearchAction
import com.example.research.ui.theme.Spacing import com.example.research.ui.theme.Spacing
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.feature.search.SearchViewModel
import androidx.compose.runtime.getValue
@Composable
fun ArticleRoute(
searchViewModel: SearchViewModel,
onNavigateBack: () -> Unit
) {
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
ArticleScreen(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = { word ->
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
},
onScrollPositionChanged = { index, offset ->
searchViewModel.updateArticleScrollPosition(index, offset)
},
)
}
@Composable @Composable
fun ArticleContent( fun ArticleContent(
@@ -68,17 +62,9 @@ fun ArticleContent(
} }
is ArticleState.Success -> { is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}" val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
val colorScheme = MaterialTheme.colorScheme val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
val typography = MaterialTheme.typography color = MaterialTheme.colorScheme.onSurface
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)
@@ -134,42 +120,38 @@ fun ArticleContent(
} }
} }
val isHeavyArticle = articleState.isHeavy val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
articleState.dslContent,
baseTextStyle,
darkTheme
) {
if (darkTheme) {
articleState.dslContent.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
}
} else {
articleState.dslContent
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn( LazyColumn(
state = listState, state = listState,
contentPadding = PaddingValues(top = Spacing.listItemVertical), modifier = modifier
modifier = listModifier .fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
.testTag("article_content") .testTag("article_content")
.semantics { .semantics {
testTagsAsResourceId = true testTagsAsResourceId = true
} }
) { ) {
itemsIndexed( itemsIndexed(
items = displayBlocks, items = articleState.dslContent,
key = { index, _ -> key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index" "$articleSaveKey:$index"
}, },
contentType = { _, _ -> "article_block" } contentType = { _, _ -> "article_block" }
) { index, block -> ) { index, block ->
val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(start = Spacing.contentStart + indentStep * block.indent) .padding(
start = Spacing.iconStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) { ) {
ArticleLinkText( ArticleLinkText(
block = block, block = block,
@@ -186,17 +168,12 @@ fun ArticleContent(
) )
} }
} }
}
}
val articleListModifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
if (isHeavyArticle) { if (isHeavyArticle) {
articleList(articleListModifier) textContent()
} else { } else {
SelectionContainer { articleList(articleListModifier) } SelectionContainer { textContent() }
}
}
} }
} }
is ArticleState.Error -> { is ArticleState.Error -> {
@@ -219,7 +196,16 @@ fun ArticleContent(
} }
} }
null -> { null -> {
Box(modifier = modifier.fillMaxSize()) Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.article_no_selected),
style = MaterialTheme.typography.bodyLarge
)
}
} }
} }
} }
@@ -229,56 +215,48 @@ 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 -> "" null -> stringResource(R.string.article_no_selected)
} }
val backButtonOffset = if (compact) { val backIcon = painterResource(R.drawable.ic_arrow_back)
val titleLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp()
}
Modifier.offset(y = (titleLineHeight - 48.dp) / 2 + 3.dp)
} else {
Modifier
}
Surface( Surface(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
Row( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing) .windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.then( .padding(
if (compact) { top = Spacing.topBarPadding,
Modifier.padding(vertical = Spacing.listItemVertical) bottom = Spacing.topBarPadding
} else { )
Modifier
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
.height(Spacing.topBarHeight) .height(Spacing.topBarHeight)
}
),
verticalAlignment = if (compact) Alignment.Top else Alignment.CenterVertically
) { ) {
if (showBackButton) { if (showBackButton) {
AppBackButton( Box(
onClick = onNavigateBack,
modifier = Modifier modifier = Modifier
.padding(start = 10.dp) .align(Alignment.CenterStart)
.then(backButtonOffset) .padding(start = Spacing.iconStart)
.size(24.dp)
.clickable(onClick = onNavigateBack),
contentAlignment = Alignment.Center
) {
Icon(
painter = backIcon,
contentDescription = stringResource(R.string.action_back),
modifier = Modifier.size(24.dp)
) )
} }
}
key(selectionReset) {
SelectionContainer {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.dictionaryTitleLarge, style = MaterialTheme.typography.dictionaryTitleLarge,
@@ -286,16 +264,16 @@ fun ArticleTitleBar(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.padding(start = if (showBackButton) 0.dp else 16.dp) .align(Alignment.CenterStart)
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
.testTag("article_title") .testTag("article_title")
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
) )
} }
} }
}
}
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun ArticleScreen( internal fun ArticleScreen(
articleState: ArticleState?, articleState: ArticleState?,
@@ -303,20 +281,12 @@ internal fun ArticleScreen(
onRefClick: ((String) -> Unit)? = null, onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null, onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) { ) {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Box(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
) {
Scaffold( Scaffold(
topBar = { topBar = {
ArticleTitleBar( ArticleTitleBar(
articleState = articleState, articleState = articleState,
showBackButton = true, showBackButton = true,
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack
selectionReset = titleSelectionReset.intValue
) )
}, },
contentWindowInsets = AppWindowInsets.contentWithNavigation contentWindowInsets = AppWindowInsets.contentWithNavigation
@@ -329,5 +299,4 @@ internal fun ArticleScreen(
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)
) )
} }
}
} }
@@ -37,7 +37,6 @@ sealed class ArticleState {
val dictionaryPath: String = "", val dictionaryPath: String = "",
val scrollIndex: Int = 0, val scrollIndex: Int = 0,
val scrollOffset: Int = 0, val scrollOffset: Int = 0,
val isHeavy: Boolean = false,
) : ArticleState() ) : ArticleState()
@Immutable @Immutable
data class Error( data class Error(
@@ -13,14 +13,11 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100 internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100
internal const val HEAVY_ARTICLE_TOTAL_CHARS = 1_500
object ArticleTextWarmUp { object ArticleTextWarmUp {
suspend fun warmUp(context: Context, blocks: List<DslBlock>) { suspend fun warmUp(context: Context, blocks: List<DslBlock>) {
if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT && if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT) return
blocks.sumOf { it.text.length } <= HEAVY_ARTICLE_TOTAL_CHARS
) return
val density = Density(context) val density = Density(context)
val measurer = TextMeasurer( val measurer = TextMeasurer(
defaultFontFamilyResolver = createFontFamilyResolver(context), defaultFontFamilyResolver = createFontFamilyResolver(context),
@@ -24,40 +24,6 @@ object DslAnnotatedParser {
"ref", "ex", "e", "trn", "com", "lang", "sup", "'" "ref", "ex", "e", "trn", "com", "lang", "sup", "'"
) )
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,
val labelText: Color, val labelText: Color,
@@ -70,6 +36,11 @@ 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
} }
@@ -83,9 +54,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)
@@ -103,38 +74,16 @@ object DslAnnotatedParser {
} }
fun flushBlock() { fun flushBlock() {
val text = builder.toAnnotatedString() if (builder.length > 0) {
if (text.any { !it.isWhitespace() }) {
result.add(DslBlock( result.add(DslBlock(
text = text, text = builder.toAnnotatedString(),
indent = currentIndent, indent = currentIndent,
lazyRefs = lazyRefs.toList() lazyRefs = lazyRefs.toList()
)) ))
}
builder = AnnotatedString.Builder() builder = AnnotatedString.Builder()
stack.forEach { builder.pushStyle(it) } stack.forEach { builder.pushStyle(it) }
lazyRefs.clear() 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 {
@@ -150,11 +99,20 @@ object DslAnnotatedParser {
when (val char = dsl[i]) { when (val char = dsl[i]) {
'\\' -> { '\\' -> {
if (i + 1 < length) { if (i + 1 < length) {
appendChar(dsl[i + 1], hard = true) val next = dsl[i + 1]
builder.append(next)
if (refStack.isNotEmpty()) {
refStack.last().second.append(next)
}
lastChar = next
i += 2 i += 2
continue continue
} }
appendChar(char) builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
i++ i++
} }
'[' -> { '[' -> {
@@ -191,10 +149,6 @@ object DslAnnotatedParser {
accentNext = false accentNext = false
} }
pop() pop()
if (tagName.startsWith("m")) {
flushBlock()
currentIndent = 0
}
} else { } else {
when (tagName) { when (tagName) {
"'" -> { "'" -> {
@@ -207,15 +161,17 @@ object DslAnnotatedParser {
} else { } else {
builder.append("\n") builder.append("\n")
} }
lastChar = '\n'
} }
"p" -> { "p" -> {
push(labelStyle.copy(fontStyle = FontStyle.Italic)) push(labelStyle)
} }
"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 (lastAppended != null && !lastAppended!!.isWhitespace()) { if (lastChar != '\n' && lastChar != null) {
flushBlock() if (refStack.isEmpty()) flushBlock() else builder.append("\n")
} }
lastChar = '\n'
currentIndent = marginIndent(tagName) currentIndent = marginIndent(tagName)
push(exampleStyle) push(exampleStyle)
} }
@@ -225,18 +181,7 @@ object DslAnnotatedParser {
refStack.addLast(builder.length to StringBuilder()) refStack.addLast(builder.length to StringBuilder())
} }
"ex", "e" -> push(exampleStyle) "ex", "e" -> push(exampleStyle)
"c" -> { "c", "t", "trn", "lang", "sup" -> push(SpanStyle())
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))
} }
} }
@@ -244,23 +189,18 @@ object DslAnnotatedParser {
continue continue
} }
} }
appendChar(char) builder.append(char)
i++ if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
} }
'\n' -> { lastChar = char
if (refStack.isEmpty()) {
flushBlock()
} else {
appendChar(char)
}
i++
}
'\t' -> {
appendText("\u2002")
i++ i++
} }
else -> { else -> {
appendChar(char) builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
if (accentNext) { if (accentNext) {
builder.append('\u0301') builder.append('\u0301')
@@ -270,6 +210,7 @@ object DslAnnotatedParser {
accentNext = false accentNext = false
} }
lastChar = char
i++ i++
} }
} }
@@ -411,35 +352,6 @@ 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,51 +0,0 @@
package com.example.research.ui.article
import androidx.compose.ui.text.AnnotatedString
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
internal fun mergeDslBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
val merged = ArrayList<DslBlock>(blocks.size / 4)
var i = 0
while (i < blocks.size) {
val builder = AnnotatedString.Builder()
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
var j = i
while (j < blocks.size) {
val block = blocks[j]
repeat(block.indent) { builder.append(' ') }
val paraStart = builder.length
builder.append(block.text)
block.lazyRefs.forEach { ref ->
refs.add(
ref.copy(
startIndex = ref.startIndex + paraStart,
endIndex = ref.endIndex + paraStart
)
)
}
val more = j + 1 < blocks.size &&
(j + 1 - i) < MERGED_BLOCK_MAX_ITEMS &&
builder.length < MERGED_BLOCK_MAX_CHARS
if (more) {
builder.append('\n')
}
j++
if (!more) {
break
}
}
merged.add(
DslBlock(
text = builder.toAnnotatedString(),
indent = 0,
lazyRefs = refs,
continuesPrevious = blocks[i].continuesPrevious
)
)
i = j
}
return merged
}
@@ -1,7 +1,5 @@
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
@@ -14,8 +12,7 @@ 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.theme.AppWindowInsets import com.example.research.ui.main.components.MainTopBar
import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
@@ -23,11 +20,9 @@ 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,
@@ -48,37 +43,21 @@ 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
val searchBar: @Composable () -> Unit = { Column(modifier = Modifier.fillMaxSize()) {
MainSearchBar( MainTopBar(
searchState = searchState, searchState = searchState,
canSearch = canSearch, enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom, onQueryChange = { onAction(MainAction.QueryChanged(it)) },
searchHistory = searchHistory, onClearQuery = { onAction(MainAction.ClearQuery) },
onAction = onAction, onNavigateToSettings = onNavigateToSettings
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(
@@ -105,28 +84,20 @@ fun AdaptiveMainScreen(
.windowInsetsPadding( .windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
) )
.padding(start = Spacing.contentStart, end = listEndPadding), .padding(start = 32.dp, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars), listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
alignFirstResultWithTitleBar = isSearchBarAtBottom,
) )
}, },
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 && articleState != null, showBackButton = isShowingDetail,
onNavigateBack = onNavigateBackFromArticle, onNavigateBack = onNavigateBackFromArticle,
compact = !isSearchBarAtBottom,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding( modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top) WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
) )
@@ -139,21 +110,21 @@ fun AdaptiveMainScreen(
} }
}, },
) )
if (isSearchBarAtBottom) {
searchBar()
}
} }
LaunchedEffect(articleState) { LaunchedEffect(articleState, isQueryEmpty) {
val targetValue = if (articleState == null) { val targetValue = when {
ThreePaneScaffoldValue( articleState == null -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden, primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded, secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden, tertiary = PaneAdaptedValue.Hidden,
) )
} else { isQueryEmpty -> ThreePaneScaffoldValue(
ThreePaneScaffoldValue( primary = PaneAdaptedValue.Expanded,
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,7 +5,4 @@ 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,9 +1,7 @@
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
@@ -16,13 +14,11 @@ 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
@@ -40,11 +36,9 @@ 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
@@ -52,6 +46,17 @@ 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) {
@@ -73,48 +78,28 @@ 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()) {
false
} else {
activeDictionaries.any { dict -> activeDictionaries.any { dict ->
val fileName = dict.path.substringAfterLast("/") val fileName = dict.path.substringAfterLast("/")
!DictionaryConfig.isDabkrsDictionary(fileName) !DictionaryConfig.isDabkrsDictionary(fileName)
} }
} }
}
BoxWithConstraints { BoxWithConstraints {
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.onSearchQueryChanged(action.query)
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,
@@ -135,11 +120,9 @@ 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,
@@ -149,82 +132,28 @@ 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
) { ) {
val searchBar: @Composable () -> Unit = {
MainSearchBar(
searchState = searchState,
canSearch = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
searchHistory = searchHistory,
onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
)
}
if (isSearchBarAtBottom) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
) {
SearchResultsPane(
searchState = searchState,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onAction,
onArticleClick = onArticleClick,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp),
alignFirstResultWithTitleBar = true,
)
searchBar()
}
return
}
Scaffold( Scaffold(
topBar = { searchBar() }, topBar = {
MainTopBar(
searchState = searchState,
enabled = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
onNavigateToSettings = onNavigateToSettings
)
},
contentWindowInsets = AppWindowInsets.navigationOnly contentWindowInsets = AppWindowInsets.navigationOnly
) { paddingValues -> ) { paddingValues ->
SearchResultsPane( SearchResultsPane(
@@ -240,7 +169,7 @@ internal fun MainScreen(
.padding(paddingValues) .padding(paddingValues)
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing) .windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp), .padding(start = 32.dp, end = 16.dp),
) )
} }
} }
@@ -8,7 +8,6 @@ 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
@@ -16,12 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.text.rememberTextMeasurer
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.Spacing
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
@@ -48,7 +42,6 @@ internal fun SearchResultsPane(
onArticleClick: (IndexEntry) -> Unit, onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listModifier: Modifier = Modifier, listModifier: Modifier = Modifier,
alignFirstResultWithTitleBar: Boolean = false,
) { ) {
val listState = rememberLazyListState( val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition.first, initialFirstVisibleItemIndex = scrollPosition.first,
@@ -57,11 +50,9 @@ 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()
} }
val topPadding = if (alignFirstResultWithTitleBar) titleBarAlignedTopPadding() else 0.dp
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { snapshotFlow {
@@ -81,21 +72,8 @@ 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) {
@@ -146,21 +124,7 @@ internal fun SearchResultsPane(
modifier = listModifier.weight(1f), modifier = listModifier.weight(1f),
listState = listState, listState = listState,
showDictionaryName = showDictionaryName, showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(top = topPadding, bottom = imeBottomPadding), contentPadding = PaddingValues(bottom = imeBottomPadding),
staleEntries = shownStale,
) )
} }
} }
@Composable
private fun titleBarAlignedTopPadding(): Dp {
val textMeasurer = rememberTextMeasurer(cacheSize = 0)
val style = MaterialTheme.typography.dictionaryTitleLarge
val density = LocalDensity.current
return remember(textMeasurer, style, density) {
val titleHeight = with(density) {
textMeasurer.measure(text = "A", style = style, maxLines = 1).size.height.toDp()
}
(Spacing.topBarCenter - Spacing.listItemVertical - titleHeight / 2).coerceAtLeast(0.dp)
}
}
@@ -1,15 +1,9 @@
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.PaddingValues
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.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
@@ -23,108 +17,55 @@ 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.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.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 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 com.example.research.ui.theme.Spacing
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
private val TopBarHeight = Spacing.topBarHeight 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
private val HistorySheetBottomPadding = 16.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 modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.onGloballyPositioned { topBarBottomPx = it.boundsInWindow().bottom.roundToInt() }
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding( .windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
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
@@ -134,21 +75,9 @@ 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)
) )
@@ -171,39 +100,18 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
private fun SearchField( private fun SearchField(
searchState: TextFieldState, searchState: TextFieldState,
enabled: Boolean, enabled: Boolean,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit, onClearQuery: () -> Unit,
closeIcon: Painter, searchIcon: androidx.compose.ui.graphics.painter.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
@@ -226,16 +134,11 @@ private fun SearchField(
lineLimits = TextFieldLineLimits.SingleLine, lineLimits = TextFieldLineLimits.SingleLine,
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = historyIcon, painter = searchIcon,
contentDescription = historyLabel, contentDescription = null,
tint = LocalContentColor.current.copy(
alpha = if (historyEnabled) 1f else 0.38f
),
modifier = Modifier modifier = Modifier
.offset(x = 4.dp) .offset(x = 4.dp)
.size(28.dp) .size(24.dp)
.appTestTag("history_button")
.iconClickable(enabled = historyEnabled, onClick = onShowHistory)
) )
}, },
trailingIcon = if (searchState.text.isNotEmpty()) { trailingIcon = if (searchState.text.isNotEmpty()) {
@@ -280,52 +183,3 @@ private fun SearchField(
.semantics { testTagsAsResourceId = true } .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 = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val density = LocalDensity.current
val windowHeightPx = LocalWindowInfo.current.containerSize.height
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
val maxContentHeight = remember {
with(density) {
val screenHeight = windowHeightPx.toDp()
if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
} else {
screenHeight - topBarBottomPx.toDp()
}
}
}
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,
contentPadding = PaddingValues(bottom = HistorySheetBottomPadding),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
.padding(horizontal = 24.dp)
)
}
}
@@ -1,138 +0,0 @@
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)
)
}
}
}
@@ -6,13 +6,17 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.* import androidx.paging.compose.*
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes import com.example.research.core.util.removeDictionarySuffixes
@@ -25,11 +29,8 @@ 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,18 +40,11 @@ fun SearchResultsList(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items( items(
count = rowCount, count = results.itemCount,
key = { index -> key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
val entry = if (showStale) staleEntries.getOrNull(index) contentType = results.itemContentType { "search_result" }
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 ->
val entry = if (showStale) staleEntries.getOrNull(index) results[index]?.let { entry ->
else if (index in 0 until results.itemCount) results[index] else null
if (entry != null) {
SearchResultItem( SearchResultItem(
entry = entry, entry = entry,
resultIndex = index, resultIndex = index,
@@ -59,6 +53,26 @@ fun SearchResultsList(
) )
} }
} }
when (results.loadState.append) {
is LoadState.Error -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.error_loading_more),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
else -> {}
}
} }
} }
@@ -1,29 +1,16 @@
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.animation.AnimatedContent import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
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.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.ui.about.AboutScreen import com.example.research.ui.about.AboutScreen
import com.example.research.ui.article.ArticleScreen import com.example.research.ui.article.ArticleRoute
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.SettingsRoute import com.example.research.ui.settings.SettingsRoute
import com.example.research.ui.settings.SettingsViewModel import com.example.research.ui.settings.SettingsViewModel
@@ -38,6 +25,7 @@ enum class Screen {
Home, Settings, Article, About Home, Settings, Article, About
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun AppNavigation( fun AppNavigation(
searchViewModel: SearchViewModel, searchViewModel: SearchViewModel,
@@ -84,12 +72,6 @@ fun AppNavigation(
.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()) {
@@ -103,33 +85,14 @@ 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
currentScreen == Screen.Settings -> Screen.Settings else -> currentScreen
else -> Screen.Home
} }
Box { when (effectiveScreen) {
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,
@@ -160,6 +123,18 @@ 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 = {
@@ -167,36 +142,5 @@ 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)
},
)
}
}
} }
} }
@@ -3,11 +3,6 @@ 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
@@ -19,7 +14,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.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
@@ -44,18 +41,17 @@ 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,
@@ -103,6 +99,7 @@ fun SettingsRoute(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
uiState: SettingsUiState, uiState: SettingsUiState,
@@ -124,11 +121,13 @@ fun SettingsScreen(
title = { Text(stringResource(R.string.settings_title)) }, title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = { navigationIcon = {
if (showBackButton) { if (showBackButton) {
AppBackButton( IconButton(onClick = onNavigateBack) {
onClick = onNavigateBack, Icon(
modifier = Modifier.padding(start = 6.dp) painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back)
) )
} }
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
@@ -236,24 +235,6 @@ 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))
} }
@@ -285,11 +266,7 @@ private fun CollapsibleSettingsSection(
testTag = testTag testTag = testTag
) )
AnimatedVisibility( if (isExpanded) {
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -3,7 +3,6 @@ package com.example.research.ui.settings
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable 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
@@ -14,10 +13,8 @@ import com.example.research.feature.download.model.DownloadState
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,
@@ -33,7 +30,6 @@ data class SettingsUiState(
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,9 +7,7 @@ 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
@@ -18,7 +16,6 @@ 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
@@ -47,13 +44,11 @@ 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,
) )
@@ -66,8 +61,6 @@ 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(
@@ -75,16 +68,7 @@ class SettingsViewModel(
validator = DictionarySourceValidator(), validator = DictionarySourceValidator(),
) )
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion))
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
@@ -102,32 +86,14 @@ class SettingsViewModel(
private var cancelRefreshPending = false private var cancelRefreshPending = false
private var errorStateHandled = false private var errorStateHandled = false
private var successStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init { init {
setupStateObservation() setupStateObservation()
observeDictionariesForSeedFlag() observeDictionariesForSeedFlag()
persistDictionaryStatus()
initialize() initialize()
} }
private fun persistDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.collect { status ->
val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate"
is DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty"
else -> null
}
if (persistedName != null) {
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
}
}
}
}
private fun observeDictionariesForSeedFlag() { private fun observeDictionariesForSeedFlag() {
viewModelScope.launch { viewModelScope.launch {
localDictionaryRepository.dictionaries.collect { dictionaries -> localDictionaryRepository.dictionaries.collect { dictionaries ->
@@ -140,6 +106,8 @@ class SettingsViewModel(
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 }
@@ -158,48 +126,45 @@ 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,
searchBarPosition = params[2] as SearchBarPosition, importState = params[2] as ImportState,
importState = params[3] as ImportState, dictionaryStatus = params[3] as DictionaryStatus,
dictionaryStatus = params[4] as DictionaryStatus, hasCompletedStartupScan = params[4] as Boolean,
hasCompletedStartupScan = params[5] as Boolean, isThemeExpanded = params[5] as Boolean,
isThemeExpanded = params[6] as Boolean, isLanguageExpanded = params[6] as Boolean,
isLanguageExpanded = params[7] as Boolean, isDictionariesExpanded = 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 cancelRefreshPending = false
errorStateHandled = 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
@@ -248,6 +213,7 @@ class SettingsViewModel(
refreshDictionaryStatus() refreshDictionaryStatus()
} }
} }
else -> {}
} }
SettingsUiState( SettingsUiState(
@@ -257,7 +223,6 @@ 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,
@@ -266,7 +231,6 @@ class SettingsViewModel(
!operationState.importState.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,
) )
@@ -277,15 +241,9 @@ class SettingsViewModel(
} }
private fun initialize() { private fun initialize() {
var scanCompleted = false
viewModelScope.launch { viewModelScope.launch {
try { try {
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) { scanDirectoryInternal(preferencesManager.dictionaryPath)
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()) {
@@ -302,20 +260,17 @@ 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( preferencesManager.setHadNoDictionaries(
localDictionaryRepository.dictionaries.value.isEmpty() localDictionaryRepository.dictionaries.value.isEmpty()
) )
} }
} }
} }
}
fun onEvent(event: SettingsUiEvent) { fun onEvent(event: SettingsUiEvent) {
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)
@@ -365,8 +320,8 @@ class SettingsViewModel(
return DictionaryStatus.UpToDate return DictionaryStatus.UpToDate
} }
val updatable = dictionaryRepository.sourcesWithUpdates(enabledSources) val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources)
return if (updatable.isEmpty()) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate(updatable) return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e) android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) { if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
@@ -388,12 +343,6 @@ 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)
@@ -551,25 +500,6 @@ class SettingsViewModel(
): List<DictionarySource> = ): List<DictionarySource> =
DictionarySourceFileMatcher.installedSources(sources, dictionaries) DictionarySourceFileMatcher.installedSources(sources, dictionaries)
private suspend fun installedDictionariesAreCurrent(): Boolean {
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) }
}
}
private fun cancelDownload() { private fun cancelDownload() {
// Cancel the ViewModel-side scan job so no further status recomputes // Cancel the ViewModel-side scan job so no further status recomputes
localDictionaryRepository.cancelIndexing() localDictionaryRepository.cancelIndexing()
@@ -621,22 +551,11 @@ class SettingsViewModel(
} else if (isDownloadInProgress) { } else if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false isDownloadInProgress = false
} else {
val savedStatus = preferencesManager.dictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate 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() }
dictionaryStatus.value = status dictionaryStatus.value = status
} }
}
if (result.data > 0) { if (result.data > 0) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
@@ -50,7 +50,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.components.BadgedIcon
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -85,8 +84,7 @@ fun DictionaryListItem(
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false }, isDeleteBlocked: () -> Boolean = { false }
hasUpdate: Boolean = false,
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -197,15 +195,11 @@ fun DictionaryListItem(
modifier = Modifier.size(IconButtonSize), modifier = Modifier.size(IconButtonSize),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
BadgedIcon( Icon(
painter = painterResource(R.drawable.ic_book), painter = painterResource(R.drawable.ic_book),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
size = IconSize, modifier = Modifier.size(IconSize)
badgeDescription = if (hasUpdate) {
stringResource(R.string.dictionary_update_available)
} else {
null
},
) )
} }
@@ -1,23 +1,12 @@
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
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -26,17 +15,15 @@ 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.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.invisibleToUser
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
@@ -48,7 +35,6 @@ import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.EmptyState import com.example.research.common.ui.components.EmptyState
import com.example.research.common.ui.components.SectionCard import com.example.research.common.ui.components.SectionCard
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState import com.example.research.ui.settings.ImportState
@@ -77,10 +63,7 @@ fun DictionaryManagement(
) { ) {
val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) } val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) }
val sheetState = rememberBottomSheetState( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@@ -95,26 +78,12 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting } derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
} }
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } } val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
val isUpdateAvailable = dictionaryStatus is DictionaryStatus.NeedsUpdate && val isBlockingMutations = isInProgress
!isInProgress && hasEnabledSources && dictionaries.isNotEmpty()
val updatablePaths = remember(dictionaryStatus, sources, dictionaries, isUpdateAvailable) {
val ids = (dictionaryStatus as? DictionaryStatus.NeedsUpdate)?.sourceIds
if (!isUpdateAvailable || ids.isNullOrEmpty()) {
emptySet()
} else {
val updatableSources = sources.filter { it.id in ids }
dictionaries
.filter { dictionary ->
updatableSources.any { DictionarySourceFileMatcher.matches(it, dictionary) }
}
.mapTo(mutableSetOf(), Dictionary::path)
}
}
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf, // Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read // snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
// (e.g. Modifier.alpha(if (isDeleteBlocked()) ...)) would invalidate every row on each flip. // (e.g. Modifier.alpha(if (isDeleteBlocked()) ...)) would invalidate every row on each flip.
val isMutationsBlockedState = rememberUpdatedState(isInProgress) val isMutationsBlockedState = rememberUpdatedState(isBlockingMutations)
val isDeleteBlocked = remember { { isMutationsBlockedState.value } } val isDeleteBlocked = remember { { isMutationsBlockedState.value } }
SectionCard( SectionCard(
@@ -138,19 +107,10 @@ fun DictionaryManagement(
isExpanded = isExpanded, isExpanded = isExpanded,
onToggle = onToggleExpanded, onToggle = onToggleExpanded,
subtitle = headerSubtitle, subtitle = headerSubtitle,
testTag = "dictionary_header", testTag = "dictionary_header"
badgeDescription = if (isUpdateAvailable && !isExpanded) {
stringResource(R.string.dictionaries_update_available)
} else {
null
},
) )
AnimatedVisibility( if (isExpanded) {
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()
@@ -175,13 +135,13 @@ 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 (isBlockingMutations) {
Modifier.semantics { hideFromAccessibility() } Modifier.semantics { invisibleToUser() }
} else { } else {
Modifier Modifier
} }
if (dictionaries.isEmpty() && !isInProgress) { if (dictionaries.isEmpty() && !isBlockingMutations) {
Column( Column(
modifier = backgroundModifier modifier = backgroundModifier
.fillMaxWidth() .fillMaxWidth()
@@ -203,7 +163,7 @@ fun DictionaryManagement(
) )
} }
} else { } else {
if (isUpdateAvailable) { if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isBlockingMutations && hasEnabledSources) {
DictionaryUpdateCard( DictionaryUpdateCard(
onStartDownload = onStartDownload, onStartDownload = onStartDownload,
modifier = backgroundModifier modifier = backgroundModifier
@@ -217,11 +177,10 @@ fun DictionaryManagement(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(backgroundModifier), .then(backgroundModifier),
isDeleteBlocked = isDeleteBlocked, isDeleteBlocked = isDeleteBlocked
updatablePaths = updatablePaths,
) )
if (!isInProgress) { if (!isBlockingMutations) {
DictionaryActionButtons( DictionaryActionButtons(
onAddSource = { onAddSource = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -246,8 +205,7 @@ 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,
@@ -290,7 +248,6 @@ private fun DictionaryProgressSectionHost(
label = label, label = label,
onCancel = onCancel, onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0, percent = progressSnapshot?.percent ?: 0,
indeterminate = progressSnapshot?.indeterminate ?: false,
testTag = progressTestTag, testTag = progressTestTag,
) )
} }
@@ -301,39 +258,21 @@ private fun DictionaryListSection(
onToggleDictionary: (String) -> Unit, onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit, onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false }, isDeleteBlocked: () -> Boolean = { false }
updatablePaths: Set<String> = emptySet(),
) { ) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
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 ->
key(dictionary.path) { key(dictionary.path) {
val hasAppeared = dictionary.path in appearedPaths
val rowAlpha by animateFloatAsState(
targetValue = if (hasAppeared) 1f else 0f,
animationSpec = tween(150),
label = "dictionaryRowAppear",
)
LaunchedEffect(dictionary.path) {
appearedPaths = appearedPaths + dictionary.path
}
Box(modifier = Modifier.alpha(rowAlpha)) {
DictionaryListItem( DictionaryListItem(
dictionary = dictionary, dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) }, onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) }, onDelete = { onDeleteDictionary(dictionary) },
isDeleteBlocked = isDeleteBlocked, isDeleteBlocked = isDeleteBlocked
hasUpdate = dictionary.path in updatablePaths,
) )
} }
} }
} }
}
} }
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -32,7 +31,6 @@ fun DictionaryProgressSection(
onCancel: () -> Unit, onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
percent: Int, percent: Int,
indeterminate: Boolean,
testTag: String, testTag: String,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@@ -46,7 +44,6 @@ fun DictionaryProgressSection(
var displayedLabel by remember { mutableStateOf(label) } var displayedLabel by remember { mutableStateOf(label) }
var displayedPercent by remember { mutableIntStateOf(percent) } var displayedPercent by remember { mutableIntStateOf(percent) }
var displayedTestTag by remember { mutableStateOf(testTag) } var displayedTestTag by remember { mutableStateOf(testTag) }
var displayedIndeterminate by remember { mutableStateOf(indeterminate) }
LaunchedEffect(visible) { LaunchedEffect(visible) {
if (visible) { if (visible) {
@@ -63,7 +60,6 @@ fun DictionaryProgressSection(
displayedLabel = label displayedLabel = label
displayedPercent = percent displayedPercent = percent
displayedTestTag = testTag displayedTestTag = testTag
displayedIndeterminate = indeterminate
} }
} }
val animatedState = remember { Animatable(0f) } val animatedState = remember { Animatable(0f) }
@@ -92,23 +88,13 @@ fun DictionaryProgressSection(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
if (!displayedIndeterminate) {
Text( Text(
text = "$displayedPercent%", text = "$displayedPercent%",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
} Spacer(Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(WavyProgressIndicatorDefaults.LinearContainerHeight),
contentAlignment = Alignment.Center,
) {
if (displayedIndeterminate) {
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator( LinearProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier modifier = Modifier
@@ -116,9 +102,7 @@ fun DictionaryProgressSection(
.height(4.dp) .height(4.dp)
.clip(MaterialTheme.shapes.small), .clip(MaterialTheme.shapes.small),
) )
} Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(4.dp))
OutlinedButton( OutlinedButton(
onClick = onCancel, onClick = onCancel,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -3,9 +3,6 @@ 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.*
@@ -19,6 +16,7 @@ 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
@@ -66,29 +64,6 @@ private fun validateDictionaryUrl(context: UrlValidationContext): String? {
} }
} }
private val urlSplitRegex = Regex("[\\s,;]+")
private fun splitPastedUrls(text: String): List<String>? =
text.split(urlSplitRegex).filter { it.isNotEmpty() }.takeIf { tokens ->
tokens.size > 1 && tokens.all {
DictionarySource.validateTemplate(it) is DictionarySource.ValidationResult.Valid
}
}
private val urlInputTransformation = InputTransformation {
val text = toString()
val filtered = text
.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>,
@@ -96,22 +71,11 @@ fun DictionaryUrlInputContent(
onCancel: () -> Unit, onCancel: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val urls = remember { mutableStateListOf(TextFieldState("")) } val urls = remember { mutableStateListOf(TextFieldValue("")) }
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),
@@ -132,8 +96,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 { urlState -> urls.associate { urlValue ->
val urlString = urlState.trimmedText() val urlString = urlValue.text.trim()
urlString to if (urlString.isNotEmpty()) { urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString) DictionarySource.extractPrefix(urlString)
} else null } else null
@@ -155,10 +119,10 @@ fun DictionaryUrlInputContent(
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
urls.forEachIndexed { index, urlState -> urls.forEachIndexed { index, urlValue ->
val isLastItem = index == urls.lastIndex val isLastItem = index == urls.lastIndex
val urlString = urlState.trimmedText() val urlString = urlValue.text.trim()
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(
@@ -166,7 +130,7 @@ fun DictionaryUrlInputContent(
url = urlString, url = urlString,
existingSources = existingSources, existingSources = existingSources,
existingPrefixes = existingPrefixes, existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.trimmedText() }, urlsInForm = urls.map { it.text.trim() },
urlPrefix = cachedPrefix, urlPrefix = cachedPrefix,
errorMessages = errorMessages errorMessages = errorMessages
) )
@@ -176,8 +140,20 @@ fun DictionaryUrlInputContent(
val displayError = validationErrors[index] ?: realtimeError val displayError = validationErrors[index] ?: realtimeError
OutlinedTextField( OutlinedTextField(
state = urlState, value = urlValue,
inputTransformation = urlInputTransformation, onValueChange = { newValue ->
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) } },
@@ -192,7 +168,7 @@ fun DictionaryUrlInputContent(
Modifier Modifier
} }
), ),
lineLimits = TextFieldLineLimits.SingleLine, singleLine = true,
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
@@ -212,7 +188,7 @@ fun DictionaryUrlInputContent(
TextButton( TextButton(
onClick = { onClick = {
urls.add(TextFieldState("")) urls.add(TextFieldValue(""))
}, },
modifier = Modifier modifier = Modifier
.align(Alignment.Start) .align(Alignment.Start)
@@ -248,8 +224,8 @@ fun DictionaryUrlInputContent(
val validUrls = mutableListOf<String>() val validUrls = mutableListOf<String>()
val seenUrls = mutableSetOf<String>() val seenUrls = mutableSetOf<String>()
urls.forEachIndexed { index, urlState -> urls.forEachIndexed { index, urlValue ->
val urlString = urlState.trimmedText() val urlString = urlValue.text.trim()
if (urlString.isBlank()) { if (urlString.isBlank()) {
if (urls.size == 1) { if (urls.size == 1) {
validationErrors[index] = errorMessages.empty validationErrors[index] = errorMessages.empty
@@ -287,9 +263,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.trimmedText().isNotBlank() } && enabled = urls.any { it.text.trim().isNotBlank() } &&
urls.none { urlState -> urls.none { urlValue ->
val urlString = urlState.trimmedText() val urlString = urlValue.text.trim()
if (urlString.isEmpty()) { if (urlString.isEmpty()) {
false false
} else { } else {
@@ -299,7 +275,7 @@ fun DictionaryUrlInputContent(
url = urlString, url = urlString,
existingSources = existingSources, existingSources = existingSources,
existingPrefixes = existingPrefixes, existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.trimmedText() }, urlsInForm = urls.map { it.text.trim() },
urlPrefix = urlPrefix, urlPrefix = urlPrefix,
errorMessages = errorMessages errorMessages = errorMessages
) )
@@ -313,5 +289,3 @@ fun DictionaryUrlInputContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
private fun TextFieldState.trimmedText(): String = text.trim().toString()
@@ -1,40 +0,0 @@
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,8 +6,7 @@ object Spacing {
// Semantic spacing constants for specific UI elements // Semantic spacing constants for specific UI elements
val topBarPadding = 8.dp val topBarPadding = 8.dp
val topBarHeight = 56.dp val topBarHeight = 56.dp
val topBarCenter = topBarPadding + topBarHeight / 2 val iconStart = 32.dp
val searchTextStart = 68.dp
val listItemVertical = 8.dp val listItemVertical = 8.dp
val contentStart = 32.dp
} }
@@ -24,20 +24,12 @@ 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
-9
View File
@@ -1,9 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
+5 -10
View File
@@ -2,6 +2,7 @@
<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>
@@ -11,13 +12,8 @@
<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="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="recent_searches">История поиска</string> <string name="article_no_selected">Статья не выбрана</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>
@@ -52,7 +48,6 @@
<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>
@@ -62,6 +57,7 @@
<string name="error_scan_directory_failed">Не удалось просканировать папку со словарями</string> <string name="error_scan_directory_failed">Не удалось просканировать папку со словарями</string>
<string name="error_search_failed">Ошибка поиска запроса \'%1$s\' в %2$s</string> <string name="error_search_failed">Ошибка поиска запроса \'%1$s\' в %2$s</string>
<string name="error_failed_load_index_metadata">Не удалось загрузить метаданные индекса: %1$s</string> <string name="error_failed_load_index_metadata">Не удалось загрузить метаданные индекса: %1$s</string>
<string name="error_loading_more">Ошибка загрузки</string>
<plurals name="active_dictionaries_count"> <plurals name="active_dictionaries_count">
<item quantity="one">%1$d активный словарь</item> <item quantity="one">%1$d активный словарь</item>
<item quantity="few">%1$d активных словаря</item> <item quantity="few">%1$d активных словаря</item>
@@ -80,14 +76,13 @@
<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">Должен начинаться с https://</string> <string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string>
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dsl.dz</string> <string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .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>
<string name="dictionaries_not_downloaded">Словари еще не загружены</string> <string name="dictionaries_not_downloaded">Словари еще не загружены</string>
<string name="dictionaries_update_available">Доступны обновления словарей</string> <string name="dictionaries_update_available">Доступны обновления словарей</string>
<string name="dictionary_update_available">Доступно обновление</string>
<string name="action_cancel">Отмена</string> <string name="action_cancel">Отмена</string>
<string name="action_add">Добавить</string> <string name="action_add">Добавить</string>
<string name="action_add_another_url">Добавить ещё URL</string> <string name="action_add_another_url">Добавить ещё URL</string>
+5 -10
View File
@@ -2,6 +2,7 @@
<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>
@@ -11,13 +12,8 @@
<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="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="recent_searches">Search history</string> <string name="article_no_selected">No article selected</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>
@@ -52,7 +48,6 @@
<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>
@@ -62,6 +57,7 @@
<string name="error_scan_directory_failed">Failed to scan dictionary folder</string> <string name="error_scan_directory_failed">Failed to scan dictionary folder</string>
<string name="error_search_failed">Search failed for query \'%1$s\' in %2$s</string> <string name="error_search_failed">Search failed for query \'%1$s\' in %2$s</string>
<string name="error_failed_load_index_metadata">Failed to load index metadata: %1$s</string> <string name="error_failed_load_index_metadata">Failed to load index metadata: %1$s</string>
<string name="error_loading_more">Loading error</string>
<plurals name="active_dictionaries_count"> <plurals name="active_dictionaries_count">
<item quantity="one">%1$d active dictionary</item> <item quantity="one">%1$d active dictionary</item>
<item quantity="other">%1$d active dictionaries</item> <item quantity="other">%1$d active dictionaries</item>
@@ -78,14 +74,13 @@
<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 https://</string> <string name="dictionary_source_invalid_protocol">Must start with http:// or https://</string>
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.dz</string> <string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .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>
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string> <string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
<string name="dictionaries_update_available">Dictionary updates available</string> <string name="dictionaries_update_available">Dictionary updates available</string>
<string name="dictionary_update_available">Update available</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="action_add">Add</string> <string name="action_add">Add</string>
<string name="action_add_another_url">Add another URL</string> <string name="action_add_another_url">Add another URL</string>
@@ -1,9 +0,0 @@
# 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);
}
+1 -1
View File
@@ -2,7 +2,7 @@ plugins {
alias(libs.plugins.aboutlibraries) apply false alias(libs.plugins.aboutlibraries) apply false
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
id("com.github.ben-manes.versions") version "0.65.0" id("com.github.ben-manes.versions") version "0.54.0"
} }
tasks.register("assembleDebug") { tasks.register("assembleDebug") {
dependsOn(":app:assembleDebug") dependsOn(":app:assembleDebug")
@@ -1 +1 @@
- Initial release - Initial F-Droid release
@@ -1,6 +0,0 @@
- Added search history
- The search bar can be placed at the top or bottom of the screen
- Fixed headwords with stress marks displaying truncated in search results
- Improved download, indexing and navigation stability
- The About screen opens faster
- Updated build tooling and code cleanup
@@ -1,6 +0,0 @@
- Search loads all prefix matches; broad prefix searches are much faster
- Fixed indexing progress freezing on large dictionaries
- Large articles open faster
- Numbered results sort in numeric order
- UI improvements
- Updated libraries and build tooling
@@ -1,16 +0,0 @@
- The app starts faster
- Faster dictionary indexing
- Improved app stability
- Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout
- 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
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
- Updated build tooling and documentation
@@ -1,4 +1,4 @@
ReSearch is an Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression. ReSearch is a fast Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression.
Features: Features:
@@ -8,7 +8,7 @@ Features:
- Import .dsl and .dsl.dz files from device storage - Import .dsl and .dsl.dz files from device storage
- Automatic indexing with binary index files for instant search - Automatic indexing with binary index files for instant search
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection - Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
- Adaptive two-pane layout for tablets and large screens - Adaptive three-pane layout for tablets and large screens
- Material 3 UI with dynamic color, plus light, dark, and system-follow themes - Material 3 UI with dynamic color, plus light, dark, and system-follow themes
- English and Russian localization - English and Russian localization
- No tracking, no ads, fully open source - No tracking, no ads, fully open source
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

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

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

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