Initial ReSearch export

This commit is contained in:
OneWay
2026-07-06 09:29:50 +08:00
commit e8a49575c1
145 changed files with 14188 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
<h1 align="center">ReSearch</h1>
<h4 align="center">A fast Android dictionary reader for DSL-format dictionaries.</h4>
<p align="center">
<a href="#screenshots">Screenshots</a> &bull;
<a href="#features">Features</a> &bull;
<a href="#architecture">Architecture</a> &bull;
<a href="#dictionary-pipeline">Pipeline</a> &bull;
<a href="#build">Build</a>
</p>
ReSearch is an Android dictionary reader for DSL-format dictionaries. It can download, import, index, and search multiple dictionaries.
The app stores dictionaries as DictZip files and builds compact binary indexes for fast offline headword search.
## Screenshots
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch article screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
## Features
- Fast headword search across multiple DSL dictionaries.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- Download dictionaries from configured URLs.
- Automatic extraction, DictZip compression, and indexing.
- Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries.
- Background processing with progress shown in Settings and notifications.
- Dictionary management with enable, disable, update, and delete actions.
- Material 3 interface with light, dark, and system themes.
- English and Russian localization.
- 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
ReSearch supports both downloaded and locally imported dictionaries.
Download flow:
1. Resolve enabled dictionary source URLs.
2. Download dictionary archives with OkHttp.
3. Report progress through a foreground notification and Settings UI.
4. Extract archives when needed.
5. Convert raw DSL files to DictZip.
6. Build missing indexes.
Import flow:
1. Copy user-selected files into the app dictionary directory.
2. Extract archives or convert existing DSL files to DictZip.
3. Build indexes after import.
4. Refresh the dictionary list as dictionaries become available.
Indexed dictionaries remain usable offline.
## Indexing And Search
The indexer reads DSL files while preserving byte offsets for article lookup. It groups alternate headwords that point to the same article body, normalizes searchable words, and writes a compact binary `.idx` file.
For small dictionaries, entries can be sorted in memory. For large dictionaries, ReSearch uses external sorting to keep memory usage bounded.
The sparse table lets search jump into the index without scanning the full file. Article offsets point back into the DictZip dictionary, allowing ReSearch to load article text by byte range instead of reading the whole dictionary into memory.
## Responsiveness
Dictionary processing can involve large files, so heavy work runs off the main thread:
- download, extraction, compression, indexing, and file operations use background dispatchers;
- indexing is serialized to avoid CPU and I/O contention;
- progress updates are throttled before reaching Compose;
- oversized article blocks are split into lazy-list chunks so reference-heavy articles render without freezing a frame on text measurement;
- orientation changes are handled without Activity recreation;
- memory pressure triggers cache trimming for indexes, search results, article charset data, and DictZip chunks;
- out-of-memory conditions on critical paths are handled explicitly where possible.
The main search screen remains usable as long as at least one indexed dictionary is available.
## Tech Stack
- Kotlin 2.4.0
- Jetpack Compose (BOM 2026.06.01)
- Material 3
- Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1
- Paging 3.5.0
- OkHttp 5.4.0
- kotlinx.serialization 1.11.0
- AboutLibraries 15.0.3 metadata generation
- Android Gradle Plugin 9.2.1
## Build
```bash
./gradlew :app:assembleRelease
```
The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
## License
MIT
+198
View File
@@ -0,0 +1,198 @@
@file:Suppress("UnstableApiUsage")
import java.util.regex.Pattern
fun catalogLibraryIds(file: File): List<String> {
var insideLibraries = false
val tomlString = "\"([^\"]+)\""
return file.readLines()
.asSequence()
.map { it.substringBefore("#").trim() }
.onEach { line ->
if (line.startsWith("[") && line.endsWith("]")) {
insideLibraries = line == "[libraries]"
}
}
.filter { insideLibraries && it.contains("=") && it.contains("{") }
.mapNotNull { line ->
val definition = line.substringAfter("{").substringBeforeLast("}")
val module = Regex("""module\s*=\s*$tomlString""")
.find(definition)
?.groupValues
?.get(1)
if (module != null) {
module
} else {
val group = Regex("""group\s*=\s*$tomlString""")
.find(definition)
?.groupValues
?.get(1)
val name = Regex("""name\s*=\s*$tomlString""")
.find(definition)
?.groupValues
?.get(1)
if (group != null && name != null) "$group:$name" else null
}
}
.distinct()
.toList()
}
fun catalogAllowlistPattern(libraryIds: List<String>): Pattern {
val allowedIds = libraryIds
.flatMap { id ->
val group = id.substringBefore(":")
val name = id.substringAfter(":")
if (group == "com.mikepenz" && name.startsWith("aboutlibraries-")) {
listOf("$group:$name-android")
} else {
listOf(
"$group:$name",
"$group:$name-android",
"$group:$name-jvm",
"$group:$name-android-debug",
"$group:$name-jvm-debug",
)
}
}
.distinct()
.joinToString("|") { Pattern.quote(it) }
return Pattern.compile("^(?!(?:$allowedIds)$).*")
}
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.aboutlibraries)
}
kotlin {
jvmToolchain(21)
compilerOptions {
languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_4)
apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_4)
progressiveMode.set(true)
freeCompilerArgs.addAll(
"-Xreturn-value-checker=check"
)
}
}
android {
namespace = "com.example.research"
compileSdk = project.property("compileSdk").toString().toInt()
defaultConfig {
applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 1
versionName = "1.0"
val isFdroid = project.hasProperty("fdroid")
if (isFdroid) {
applicationIdSuffix = ".fdroid"
versionNameSuffix = "-fdroid"
}
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
debug {
ndk {
abiFilters.clear()
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
release {
ndk {
abiFilters.clear()
abiFilters += "arm64-v8a"
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
}
bundle {
language {
enableSplit = false
}
}
androidResources {
localeFilters += listOf("en", "ru")
}
}
aboutLibraries {
collect {
fetchRemoteLicense = true
fetchRemoteFunding = false
configPath = file("config")
}
library {
duplicationMode = com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
duplicationRule = com.mikepenz.aboutlibraries.plugin.DuplicateRule.SIMPLE
exclusionPatterns = listOf(
catalogAllowlistPattern(catalogLibraryIds(rootProject.file("gradle/libs.versions.toml")))
)
}
}
dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.documentfile)
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(libs.bundles.compose)
implementation(libs.androidx.compose.material3.adaptive.layout)
debugImplementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp)
// Coroutines with Android-optimized dispatchers
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
}
View File
+11
View File
@@ -0,0 +1,11 @@
# Remove logging in release builds
# Keep Log.w() and Log.e() for crash reporting, remove only verbose/debug/info
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
-keepattributes *Annotation*
-keepattributes SourceFile, LineNumberTable
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".ReSearchApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ReSearch">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|locale|layoutDirection|fontScale|uiMode"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ReSearch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:label="@string/app_name">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<service
android:name=".feature.download.service.DictionaryForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync">
</service>
<receiver
android:name=".feature.download.receiver.DownloadCancelReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.example.research.CANCEL_DOWNLOAD" />
</intent-filter>
</receiver>
</application>
</manifest>
+34
View File
@@ -0,0 +1,34 @@
# Seeded from the measured ReSearch startup, search, scrolling, and article-opening hot paths.
# Keep this profile checked in: release builders, including F-Droid, only compile it.
# Updated 2026-06-25: Added optimized cache paths from performance optimization stages 1-4
HSPLcom/example/research/ReSearchApplication;->**(**)**
HSPLcom/example/research/MainActivity;->**(**)**
HSPLcom/example/research/common/ui/AppLocaleProviderKt**->**(**)**
HSPLcom/example/research/common/ui/theme/ThemeKt**->**(**)**
HSPLcom/example/research/ui/navigation/AppNavigationKt**->**(**)**
HSPLcom/example/research/ui/main/MainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/AdaptiveMainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/components/MainTopBarKt**->**(**)**
HSPLcom/example/research/ui/main/components/SearchResultsListKt**->**(**)**
HSPLcom/example/research/feature/search/SearchViewModel**->**(**)**
HSPLcom/example/research/data/paging/IndexEntryPagingSource**->**(**)**
HSPLcom/example/research/data/parser/IndexSearcher**->**(**)**
HSPLcom/example/research/data/parser/SearchRankingKt**->**(**)**
HSPLcom/example/research/data/repository/LocalDictionaryRepository**->**(**)**
HSPLcom/example/research/data/repository/KotlinDictionaryEngine**->**(**)**
HSPLcom/example/research/data/parser/DslArticleReader**->**(**)**
HSPLcom/example/research/ui/article/DslAnnotatedParser**->**(**)**
HSPLcom/example/research/ui/article/ArticleScreenKt**->**(**)**
# Hot paths for optimized article rendering (Stage 1)
HSPLcom/example/research/ui/article/ArticleLinkText**->**(**)**
HSPLcom/example/research/ui/article/ArticleState**->**(**)**
# About screen (bonus optimization)
HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)**
@@ -0,0 +1,10 @@
package com.example.research
sealed interface DictionaryStatus {
data object Unknown : DictionaryStatus
data object Checking : DictionaryStatus
data object UpToDate : DictionaryStatus
data object NeedsUpdate : DictionaryStatus
data object Empty : DictionaryStatus
data class Error(val message: String) : DictionaryStatus
}
@@ -0,0 +1,187 @@
package com.example.research
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.research.common.ui.AppLocaleProvider
import com.example.research.common.ui.BackgroundTextMeasurementProvider
import com.example.research.common.ui.theme.ReSearchTheme
import com.example.research.core.domain.model.AppTheme
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
import com.example.research.ui.navigation.AppNavigation
import com.example.research.ui.settings.SettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() {
companion object {
private const val PREF_NOTIFICATION_PERMISSION_REQUESTED = "notification_permission_requested"
}
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* Permission result handled silently */ }
private val searchViewModel: SearchViewModel by viewModels {
val app = application as ReSearchApplication
viewModelFactory {
initializer {
SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle())
}
}
}
private val settingsViewModel: SettingsViewModel by viewModels {
val app = application as ReSearchApplication
val versionName = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: "1.0"
} catch (_: Exception) {
"1.0"
}
viewModelFactory {
initializer {
SettingsViewModel(
application,
app.preferencesManager,
app.downloadDictionaryRepository,
app.downloadManager,
app.localDictionaryRepository,
app.dictionaryImportManager,
app.dictionaryProgressStateHolder,
versionName,
)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
// Load theme and language synchronously before setContent to prevent flashing
val app = application as ReSearchApplication
val initialTheme = loadInitialTheme(app)
val initialLanguage = loadInitialLanguage(app)
handleIntent(intent)
setContent {
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
// Use loaded values initially, then switch to state values when ready
val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: initialTheme
val language = settingsState.language.takeIf { it != "system" } ?: initialLanguage
val darkTheme = when (theme) {
AppTheme.LIGHT -> false
AppTheme.DARK -> true
AppTheme.SYSTEM -> isSystemInDarkTheme()
}
AppLocaleProvider(language = language) {
ReSearchTheme(darkTheme = darkTheme) {
BackgroundTextMeasurementProvider {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavigation(
searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel
)
}
}
}
}
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val permission = Manifest.permission.POST_NOTIFICATIONS
val isGranted = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
val prefs = getPreferences(MODE_PRIVATE)
val wasRequested = prefs.getBoolean(PREF_NOTIFICATION_PERMISSION_REQUESTED, false)
if (!isGranted && !wasRequested) {
prefs.edit {
putBoolean(PREF_NOTIFICATION_PERMISSION_REQUESTED, true)
}
notificationPermissionLauncher.launch(permission)
}
}
/**
* Load initial theme synchronously from DataStore to prevent theme flashing.
* This is called before setContent to ensure the correct theme is applied immediately.
* Uses runBlocking to synchronously read from DataStore.
*/
private fun loadInitialTheme(app: ReSearchApplication): AppTheme {
return try {
runBlocking {
val themeString = app.preferencesManager.themeMode.first()
try {
AppTheme.valueOf(themeString)
} catch (_: Exception) {
android.util.Log.w("MainActivity", "Invalid theme value: $themeString, using SYSTEM")
AppTheme.SYSTEM
}
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to load theme preference: ${e.message}")
AppTheme.SYSTEM
}
}
/**
* Load initial language synchronously from DataStore to prevent language flashing.
* This is called before setContent to ensure the correct language is applied immediately.
* Uses runBlocking to synchronously read from DataStore.
*/
private fun loadInitialLanguage(app: ReSearchApplication): String {
return try {
runBlocking {
app.preferencesManager.language.first()
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to load language preference: ${e.message}")
"system"
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Debug hook for user leaving the app
}
private fun handleIntent(intent: Intent) {
if (intent.action == Intent.ACTION_PROCESS_TEXT) {
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
text?.toString()?.let {
searchViewModel.onAction(SearchAction.HandleExternalSearch(it))
}
}
}
}
@@ -0,0 +1,87 @@
package com.example.research
import android.app.Application
import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.repository.DictionaryRepository
import com.example.research.common.util.FileStorageManager
import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Protocol
class ReSearchApplication : Application() {
lateinit var preferencesManager: PreferencesManager
private set
lateinit var localDictionaryRepository: LocalDictionaryRepository
private set
lateinit var downloadDictionaryRepository: DictionaryRepository
private set
lateinit var downloadManager: DownloadManager
private set
lateinit var dictionaryImportManager: DictionaryImportManager
private set
lateinit var okHttpClient: OkHttpClient
private set
lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder
private set
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
preferencesManager = PreferencesManager(this)
applicationScope.launch(Dispatchers.IO) {
preferencesManager.sanitizeDictionarySources()
}
localDictionaryRepository = LocalDictionaryRepository(this)
okHttpClient = OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
.build()
val fileStorageManager = FileStorageManager()
downloadDictionaryRepository = DictionaryRepository(
context = this,
preferencesManager = preferencesManager,
fileStorageManager = fileStorageManager,
client = okHttpClient
)
downloadManager = DownloadManager(
dictionaryRepository = downloadDictionaryRepository,
unknownErrorMessage = getString(R.string.unknown_error)
)
dictionaryImportManager = DictionaryImportManager(
application = this
)
dictionaryProgressStateHolder = DictionaryProgressStateHolder(
scope = applicationScope,
downloadManager = downloadManager,
dictionaryImportManager = dictionaryImportManager,
localDictionaryRepository = localDictionaryRepository,
)
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (::localDictionaryRepository.isInitialized) {
localDictionaryRepository.trimMemory(level)
}
}
}
@@ -0,0 +1,134 @@
package com.example.research.common.progress
import android.content.Context
import androidx.annotation.StringRes
import com.example.research.R
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState
object DictionaryProgressModel {
// Weights for pipeline stages. Sum must equal 1.0.
private const val DOWNLOAD_WEIGHT = 0.4f
private const val EXTRACT_WEIGHT = 0.2f
private const val INDEX_WEIGHT = 0.4f
private const val IMPORT_COPY_WEIGHT = 0.25f
private const val IMPORT_EXTRACT_WEIGHT = 0.25f
private const val IMPORT_INDEX_WEIGHT = 0.5f
data class Snapshot(
val progress: Float,
val percent: Int,
@param:StringRes val titleRes: Int,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Snapshot) return false
return percent == other.percent && titleRes == other.titleRes
}
override fun hashCode(): Int {
return 31 * percent + titleRes
}
}
fun computeProgressSnapshot(
downloadState: DownloadState,
downloadProgress: Float,
importState: ImportState,
indexingProgress: IndexingProgress,
): Snapshot? {
val dl = downloadProgress.coerceIn(0f, 1f)
val isIndexing = indexingProgress.isIndexing
val isImportFlow = importState is ImportState.Importing ||
importState is ImportState.Extracting ||
importState is ImportState.Success
val isLoading = downloadState is DownloadState.Loading
val isExtracting = downloadState is DownloadState.Extracting
val isWaitingForIndexing = downloadState is DownloadState.Success && !isIndexing
return when {
isImportFlow && isIndexing -> indexingSnapshot(indexingProgress, fromImport = true)
importState is ImportState.Importing -> importCopySnapshot(importState)
importState is ImportState.Extracting -> importExtractSnapshot(importState)
importState is ImportState.Success -> importWaitingSnapshot()
isLoading -> loadingSnapshot(dl)
isExtracting -> extractingSnapshot(dl)
isWaitingForIndexing -> waitingSnapshot()
isIndexing -> indexingSnapshot(indexingProgress, fromImport = false)
else -> null
}
}
private fun importCopySnapshot(state: ImportState.Importing): Snapshot {
val p = state.progress.coerceIn(0f, 1f) * IMPORT_COPY_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_import_title,
)
}
private fun importExtractSnapshot(state: ImportState.Extracting): Snapshot {
val p = IMPORT_COPY_WEIGHT + state.progress.coerceIn(0f, 1f) * IMPORT_EXTRACT_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_import_title,
)
}
private fun importWaitingSnapshot(): Snapshot {
val p = IMPORT_COPY_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_import_title,
)
}
private fun loadingSnapshot(dl: Float): Snapshot {
val p = dl * DOWNLOAD_WEIGHT
val pct = (p * 100f).toInt().coerceIn(0, 100)
return Snapshot(
progress = p,
percent = pct,
titleRes = R.string.notification_download_title,
)
}
private fun extractingSnapshot(dl: Float): Snapshot {
val p = DOWNLOAD_WEIGHT + dl * EXTRACT_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_download_title,
)
}
private fun waitingSnapshot(): Snapshot {
val p = DOWNLOAD_WEIGHT + EXTRACT_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_indexing_title,
)
}
private fun indexingSnapshot(indexing: IndexingProgress, fromImport: Boolean = false): Snapshot {
val idx = indexing.progress
val p = if (fromImport) {
IMPORT_COPY_WEIGHT + IMPORT_EXTRACT_WEIGHT + idx * IMPORT_INDEX_WEIGHT
} else {
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT
}
val pct = (p * 100f).toInt().coerceIn(0, 100)
return Snapshot(
progress = p,
percent = pct,
titleRes = R.string.notification_indexing_title,
)
}
}
fun DictionaryProgressModel.Snapshot.renderTitle(context: Context): String =
context.getString(titleRes)
@@ -0,0 +1,34 @@
package com.example.research.common.progress
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
class DictionaryProgressStateHolder(
scope: CoroutineScope,
downloadManager: DownloadManager,
dictionaryImportManager: DictionaryImportManager,
localDictionaryRepository: LocalDictionaryRepository,
) {
val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?> =
combine(
downloadManager.downloadProgressState,
dictionaryImportManager.importState,
localDictionaryRepository.indexingProgress,
) { download, importState, indexingProgress ->
DictionaryProgressModel.computeProgressSnapshot(
downloadState = download.state,
downloadProgress = download.progress,
importState = importState,
indexingProgress = indexingProgress,
)
}
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, null)
}
@@ -0,0 +1,52 @@
package com.example.research.common.ui
import android.content.Context
import android.content.ContextWrapper
import android.content.res.AssetManager
import android.content.res.Configuration
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import java.util.Locale
private class LocalizedContextWrapper(
base: Context,
locale: Locale
) : ContextWrapper(base) {
private val localizedResources: Resources = run {
val config = Configuration(base.resources.configuration).apply {
setLocale(locale)
setLayoutDirection(locale)
}
base.createConfigurationContext(config).resources
}
override fun getResources(): Resources = localizedResources
override fun getAssets(): AssetManager = localizedResources.assets
}
@Composable
fun AppLocaleProvider(language: String, content: @Composable () -> Unit) {
val baseContext = LocalContext.current
val baseConfig = LocalConfiguration.current
val wrapped = remember(language, baseContext, baseConfig) {
val locale = if (language == "system") {
Resources.getSystem().configuration.locales[0]
} else {
Locale.forLanguageTag(language)
}
LocalizedContextWrapper(baseContext, locale)
}
val wrappedConfig = remember(wrapped) { wrapped.resources.configuration }
CompositionLocalProvider(
LocalContext provides wrapped,
LocalConfiguration provides wrappedConfig
) {
content()
}
}
@@ -0,0 +1,39 @@
package com.example.research.common.ui
import androidx.compose.foundation.text.LocalBackgroundTextMeasurementExecutor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import java.util.concurrent.Executors
/**
* Pre-warms Android's text layout caches away from the UI thread.
*
* Compose applies its own device and text-length heuristics before scheduling work, so the
* executor can safely be shared by all text rendered under this provider. A single worker keeps
* text prefetch from competing with UI work when both the search results and an article are visible.
*/
@Composable
fun BackgroundTextMeasurementProvider(
content: @Composable () -> Unit,
) {
val executor = remember {
Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "ReSearch-text-measurement").apply {
priority = Thread.NORM_PRIORITY - 1
}
}
}
DisposableEffect(executor) {
onDispose {
executor.shutdownNow()
}
}
CompositionLocalProvider(
LocalBackgroundTextMeasurementExecutor provides executor,
content = content,
)
}
@@ -0,0 +1,26 @@
package com.example.research.common.ui
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
fun Modifier.appTestTag(tag: String): Modifier = this
.testTag(tag)
.semantics { testTagsAsResourceId = true }
@Composable
fun Modifier.clickableWithHaptics(
hapticType: HapticFeedbackType = HapticFeedbackType.TextHandleMove,
onClick: () -> Unit
): Modifier {
val haptic = LocalHapticFeedback.current
return this.clickable {
haptic.performHapticFeedback(hapticType)
onClick()
}
}
@@ -0,0 +1,81 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.clickableWithHaptics
@Composable
fun CollapsibleHeader(
title: String,
iconRes: Int,
isExpanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
testTag: String? = null
) {
Row(
modifier = modifier
.fillMaxWidth()
.then(testTag?.let { Modifier.testTag(it) } ?: Modifier)
.clickableWithHaptics(
hapticType = HapticFeedbackType.TextHandleMove,
onClick = onToggle
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Icon(
imageVector = if (isExpanded) {
ImageVector.vectorResource(R.drawable.ic_keyboard_arrow_up)
} else {
ImageVector.vectorResource(R.drawable.ic_keyboard_arrow_down)
},
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -0,0 +1,76 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(
message: String,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit)? = null,
description: String? = null,
primaryAction: EmptyStateAction? = null,
secondaryAction: EmptyStateAction? = null
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
icon?.invoke()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = message,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
primaryAction?.let { action ->
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(onClick = action.onClick) {
Text(action.label)
}
secondaryAction?.let { secondary ->
OutlinedButton(onClick = secondary.onClick) {
Text(secondary.label)
}
}
}
}
}
}
data class EmptyStateAction(
val label: String,
val onClick: () -> Unit
)
@@ -0,0 +1,77 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun OutlinedChoiceButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false,
fillWidth: Boolean = true,
highlighted: Boolean = false,
enforceMinimumInteractiveSize: Boolean = true,
) {
val minimumInteractiveSize = if (enforceMinimumInteractiveSize) {
LocalMinimumInteractiveComponentSize.current
} else {
0.dp
}
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides minimumInteractiveSize
) {
Surface(
onClick = onClick,
modifier = modifier.heightIn(min = 32.dp),
shape = FilterChipDefaults.shape,
color = if (highlighted) {
MaterialTheme.colorScheme.primaryContainer
} else {
Color.Transparent
},
border = BorderStroke(
width = 1.dp,
color = if (highlighted || selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
}
)
) {
Box(
modifier = Modifier
.then(if (fillWidth) Modifier.fillMaxWidth() else Modifier)
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
color = when {
highlighted -> MaterialTheme.colorScheme.onPrimaryContainer
selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
}
@@ -0,0 +1,51 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun SectionCard(
modifier: Modifier = Modifier,
variant: SectionCardVariant = SectionCardVariant.Default,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val colors = when (variant) {
SectionCardVariant.Default -> CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
SectionCardVariant.Primary -> CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
SectionCardVariant.Error -> CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
}
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier,
colors = colors,
shape = MaterialTheme.shapes.medium,
content = content
)
} else {
Card(
modifier = modifier,
colors = colors,
shape = MaterialTheme.shapes.medium,
content = content
)
}
}
enum class SectionCardVariant {
Default,
Primary,
Error
}
@@ -0,0 +1,8 @@
package com.example.research.common.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,57 @@
package com.example.research.common.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
@Composable
fun ReSearchTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val view = LocalView.current
val isInEditMode = view.isInEditMode
val colorScheme = when {
dynamicColor -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
if (!isInEditMode) {
SideEffect {
val activity = view.context as? Activity
activity?.let {
val window = it.window
val controller = WindowCompat.getInsetsController(window, view)
controller.isAppearanceLightStatusBars = !darkTheme
controller.isAppearanceLightNavigationBars = !darkTheme
}
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography
) {
content()
}
}
@@ -0,0 +1,87 @@
package com.example.research.common.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
@@ -0,0 +1,67 @@
package com.example.research.common.util
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import com.example.research.R
import kotlinx.coroutines.*
import java.util.zip.GZIPInputStream
object ArchiveUtils {
suspend fun extractGzWithBom(
context: Context,
sourceUri: Uri,
destinationUri: Uri
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(sourceUri)
?: return@withContext Result.failure(Exception(context.getString(R.string.archive_failed_open_source_file)))
inputStream.use { rawInput ->
val outputStream = context.contentResolver.openOutputStream(destinationUri, "wt")
?: return@withContext Result.failure(Exception(context.getString(R.string.archive_failed_create_output_file)))
GZIPInputStream(rawInput).use { gzipInput ->
val bufferedInput = java.io.BufferedInputStream(gzipInput)
outputStream.use { out ->
val detectedCharset = com.example.research.data.parser.DslCharsetDetector.detect(bufferedInput)
if (detectedCharset == com.example.research.data.parser.DslCharsetDetector.DetectedCharset.UTF8_NO_BOM) {
out.write(byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()))
}
val buffer = ByteArray(64 * 1024)
var read: Int
var bytesSinceYield = 0
while (bufferedInput.read(buffer).also { read = it } != -1) {
out.write(buffer, 0, read)
bytesSinceYield += read
if (bytesSinceYield >= 2 * 1024 * 1024) {
yield()
bytesSinceYield = 0
}
}
}
}
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deleteFile(context: Context, uri: Uri): Result<Unit> = withContext(Dispatchers.IO) {
try {
val file = if (uri.scheme == "file") {
val path = uri.path ?: return@withContext Result.failure(Exception(context.getString(R.string.error_invalid_file_path)))
DocumentFile.fromFile(java.io.File(path))
} else {
DocumentFile.fromSingleUri(context, uri)
}
if (file != null && file.exists() && file.delete()) {
Result.success(Unit)
} else if (file != null && !file.exists()) {
Result.success(Unit)
} else {
Result.failure(Exception(context.getString(R.string.archive_failed_delete_file, file?.name ?: uri.toString())))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,28 @@
package com.example.research.common.util
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
object DateUtils {
private val dateFormat = SimpleDateFormat("yyMMdd", Locale.US)
fun getCurrentDateString(): String {
return dateFormat.format(Calendar.getInstance().time)
}
fun getPreviousDateString(): String {
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -1)
return dateFormat.format(calendar.time)
}
fun extractDateFromFileName(fileName: String): String? {
val regex = Regex("""_(\d{6})\.(gz|dsl|idx|dsl\.dz|dsl\.idx|dsl\.gz)$""")
return regex.find(fileName)?.groupValues?.get(1)
}
fun isDateBefore(date1: String, date2: String): Boolean {
return try {
val d1 = dateFormat.parse(date1) ?: return false
val d2 = dateFormat.parse(date2) ?: return false
d1.before(d2)
} catch (_: Exception) {
false
}
}
}
@@ -0,0 +1,177 @@
package com.example.research.common.util
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
data class DocumentFileInfo(
val name: String,
val uri: Uri
)
class FileStorageManager {
companion object {
private const val DELETE_RETRY_DELAY_BASE = 100L
private const val DELETE_WAIT_INTERVAL = 50L
private const val MAX_DELETE_WAIT_COUNT = 10
private const val DEFAULT_MAX_RETRIES = 5
private const val TAG = "FileStorageManager"
}
private val folderCache = ConcurrentHashMap<Uri, DocumentFile>()
private fun invalidateCache(folderUri: Uri) {
folderCache.remove(folderUri)
}
private fun getCachedFolder(folderUri: Uri): DocumentFile? {
val cached = folderCache[folderUri]
if (cached != null && cached.exists()) {
return cached
}
val file = File(folderUri.path ?: return null)
if (!file.exists()) file.mkdirs()
val folder = DocumentFile.fromFile(file)
folderCache[folderUri] = folder
return folder
}
suspend fun listFilesInFolder(folderUri: Uri): List<DocumentFileInfo> = withContext(Dispatchers.IO) {
try {
val folder = getCachedFolder(folderUri) ?: return@withContext emptyList()
if (!folder.exists() || !folder.isDirectory) {
invalidateCache(folderUri)
return@withContext emptyList()
}
val files = folder.listFiles().mapNotNull { file ->
file.name?.let { name ->
DocumentFileInfo(
name = name,
uri = file.uri
)
}
}
files
} catch (e: Exception) {
Log.e(TAG, "Error listing files: ${e.message}", e)
invalidateCache(folderUri)
emptyList()
}
}
suspend fun createFileInFolder(folderUri: Uri, fileName: String, overwrite: Boolean = true): Uri? = withContext(Dispatchers.IO) {
try {
if (SafeFileName.validate(fileName) == null) {
Log.e(TAG, "Rejected unsafe file name: $fileName")
return@withContext null
}
val folderPath = folderUri.path ?: run {
Log.e(TAG, "Invalid folder URI: $folderUri")
return@withContext null
}
val folderFile = File(folderPath)
if (!folderFile.exists()) {
folderFile.mkdirs()
}
if (!folderFile.isDirectory) {
Log.e(TAG, "Path is not a directory: $folderPath")
return@withContext null
}
val targetFile = File(folderFile, fileName)
if (targetFile.exists()) {
if (overwrite) {
if (!targetFile.delete()) {
Log.e(TAG, "Could not delete existing file: $fileName")
return@withContext null
}
} else {
return@withContext Uri.fromFile(targetFile)
}
}
val tempBinFile = File(folderFile, "$fileName.bin")
if (tempBinFile.exists()) {
tempBinFile.delete()
}
if (targetFile.createNewFile()) {
Uri.fromFile(targetFile)
} else {
Log.e(TAG, "Failed to create file: $fileName")
null
}
} catch (e: Exception) {
Log.e(TAG, "Error creating file: ${e.message}", e)
invalidateCache(folderUri)
null
}
}
suspend fun forceDeleteFile(file: DocumentFile, fileName: String, maxRetries: Int = DEFAULT_MAX_RETRIES): Boolean = withContext(Dispatchers.IO) {
if (!file.exists()) {
return@withContext true
}
for (attempt in 1..maxRetries) {
try {
if (file.delete()) {
waitForFileDeletion(file)
if (!file.exists()) {
return@withContext true
}
}
if (attempt < maxRetries) {
delay((DELETE_RETRY_DELAY_BASE * attempt).milliseconds)
}
} catch (e: Exception) {
Log.e(TAG, "Error deleting file $fileName (attempt $attempt): ${e.message}")
if (attempt < maxRetries) {
delay((DELETE_RETRY_DELAY_BASE * attempt).milliseconds)
}
}
}
if (!file.exists()) {
true
} else {
Log.e(TAG, "Failed to delete file after $maxRetries attempts: $fileName")
false
}
}
private suspend fun waitForFileDeletion(file: DocumentFile) {
var waitCount = 0
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++
}
}
suspend fun getDocumentFile(uri: Uri): DocumentFile? = withContext(Dispatchers.IO) {
runCatching {
val file = File(uri.path ?: return@withContext null)
DocumentFile.fromFile(file)
}.onFailure { e ->
Log.e(TAG, "Error getting DocumentFile: ${e.message}", e)
}.getOrNull()
}
suspend fun findFile(folderUri: Uri, fileName: String): DocumentFile? = withContext(Dispatchers.IO) {
val folder = getCachedFolder(folderUri) ?: return@withContext null
folder.findFile(fileName)
}
suspend fun forceDeleteFileByName(folderUri: Uri, fileName: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
val folder = getCachedFolder(folderUri) ?: return@withContext false
val file = folder.findFile(fileName)
if (file == null || !file.exists()) {
return@withContext true
}
forceDeleteFile(file, fileName)
}.onFailure { e ->
Log.e(TAG, "Error in forceDeleteFileByName for $fileName", e)
}.getOrDefault(false)
}
}
@@ -0,0 +1,169 @@
package com.example.research.common.util
import android.annotation.SuppressLint
import android.app.*
import android.content.Context
import android.content.Intent
import androidx.core.app.*
import androidx.core.content.ContextCompat
import com.example.research.MainActivity
import com.example.research.R
import com.example.research.feature.download.receiver.DownloadCancelReceiver
class NotificationHelper(private val context: Context) {
companion object {
const val CHANNEL_ID = "download_progress_channel"
const val NOTIFICATION_ID = 1
}
private val notificationManager = NotificationManagerCompat.from(context)
private var lastNotificationKey: Int = 0
private val mainActivityIntent by lazy {
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
private val mainActivityPendingIntent by lazy {
PendingIntent.getActivity(
context,
0,
mainActivityIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
private val cancelIntent by lazy {
Intent(context, DownloadCancelReceiver::class.java).apply {
action = DownloadCancelReceiver.ACTION_CANCEL_DOWNLOAD
}
}
private val cancelPendingIntent by lazy {
PendingIntent.getBroadcast(
context,
0,
cancelIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
init {
createNotificationChannel()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_download_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = context.getString(R.string.notification_channel_download_description)
setShowBadge(false)
importance = NotificationManager.IMPORTANCE_LOW
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.createNotificationChannel(channel)
}
private fun canShowNotification(): Boolean {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
fun showSuccessNotification() {
if (!canShowNotification()) return
lastNotificationKey = 0
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.notification_download_success_title))
.setContentText(context.getString(R.string.notification_download_success_text))
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
}
fun showErrorNotification() {
if (!canShowNotification()) return
lastNotificationKey = 0
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.notification_download_error_title))
.setContentText(context.getString(R.string.notification_download_error_text))
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
}
fun cancelNotification() {
lastNotificationKey = 0
notificationManager.cancel(NOTIFICATION_ID)
}
fun showUnifiedProgressNotification(
title: String,
contentText: String,
progressPercent: Int,
) {
if (!canShowNotification()) return
val progressClamped = progressPercent.coerceIn(0, 100)
val key = notificationKey(title, contentText, progressClamped)
if (key == lastNotificationKey && progressClamped != 0 && progressClamped != 100) {
return
}
lastNotificationKey = key
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setProgress(100, progressClamped, false)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(false)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
context.getString(R.string.button_cancel_download),
cancelPendingIntent
)
safeNotify(builder.build())
}
private fun notificationKey(
title: String,
contentText: String,
percent: Int,
): Int {
var h = percent
h = 31 * h + title.hashCode()
h = 31 * h + contentText.hashCode()
return if (h == 0) 1 else h
}
@SuppressLint("MissingPermission")
private fun safeNotify(notification: Notification) {
try {
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
// Notification permission denied or security policy blocked
}
}
fun showImportSuccessNotification() {
if (!canShowNotification()) return
lastNotificationKey = 0
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.notification_import_success_title))
.setContentText(context.getString(R.string.notification_import_success_text))
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
}
fun createForegroundNotification(): Notification {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.notification_channel_download_name))
.setContentText(context.getString(R.string.notification_download_preparing))
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setContentIntent(mainActivityPendingIntent)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setProgress(100, 0, true)
.build()
return notification
}
}
@@ -0,0 +1,12 @@
package com.example.research.common.util
object SafeFileName {
fun validate(rawName: String): String? {
if (rawName.isEmpty()) return null
if (rawName.contains('/') || rawName.contains('\\')) return null
if (rawName.contains('\u0000')) return null
if (rawName == "." || rawName == "..") return null
return rawName
}
}
@@ -0,0 +1,6 @@
package com.example.research.core.domain.model
enum class AppTheme {
LIGHT,
DARK,
SYSTEM
}
@@ -0,0 +1,14 @@
package com.example.research.core.domain.model
import kotlin.jvm.JvmInline
@JvmInline
value class ArticleOffset(val value: Long) {
companion object {
val ZERO = ArticleOffset(0L)
}
}
@JvmInline
value class ArticleLength(val value: Int) {
companion object {
val ZERO = ArticleLength(0)
}
}
@@ -0,0 +1,25 @@
package com.example.research.core.domain.model
import androidx.compose.runtime.Immutable
/**
* Represents a dictionary with its metadata.
* Marked as @Immutable for Compose optimization - all properties are immutable.
*/
@Immutable
data class Dictionary(
val name: String,
val path: String,
val indexPath: String,
val isActive: Boolean = true,
val articleCount: Int = 0,
val metadata: com.example.research.data.parser.DslHeaderParser.DslMetadata = com.example.research.data.parser.DslHeaderParser.DslMetadata()
) {
init {
require(name.isNotBlank()) { "Dictionary name cannot be blank" }
require(path.isNotBlank()) { "Dictionary path cannot be blank" }
require(indexPath.isNotBlank()) { "Dictionary index path cannot be blank" }
require(articleCount >= 0) { "Article count cannot be negative" }
}
fun withActiveStatus(active: Boolean): Dictionary = copy(isActive = active)
}
@@ -0,0 +1,7 @@
package com.example.research.core.domain.model
data class DictionaryInfo(
var name: String = "",
var articlesCount: Int = 0,
var indexError: Boolean = false,
var errorMsg: String = ""
)
@@ -0,0 +1,173 @@
package com.example.research.core.domain.model
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class DictionarySource(
val id: String,
val name: String,
val urlTemplate: String,
val isEnabled: Boolean = true
) {
companion object {
const val DATE_PLACEHOLDER = "{DATE}"
fun hasDatePlaceholder(template: String): Boolean =
template.contains(DATE_PLACEHOLDER)
fun buildUrl(template: String, date: String): String {
val normalized = if (hasDatePlaceholder(template)) template else normalizeTemplate(template)
return if (hasDatePlaceholder(normalized)) {
normalized.replace(DATE_PLACEHOLDER, date)
} else {
template
}
}
fun extractFileName(template: String, date: String): String =
buildUrl(template, date).substringAfterLast('/')
fun extractPrefix(template: String): String? {
val fileName = template.substringAfterLast('/')
val dateIndex = fileName.indexOf("{DATE}")
if (dateIndex > 0) {
val beforeDate = fileName.substring(0, dateIndex)
val prefix = beforeDate.removeSuffix("_").removeSuffix("-")
return prefix
}
val prefixEnd = fileName.indexOf('_')
val prefix = if (prefixEnd > 0) {
fileName.substring(0, prefixEnd)
} else {
val nameWithoutExt = fileName
.removeSuffix(".dz")
.removeSuffix(".dsl")
.removeSuffix(".gz")
nameWithoutExt.ifEmpty { null }
}
return prefix
}
fun normalizeTemplate(template: String): String {
val trimmed = template.trim()
val fileName = trimmed.substringAfterLast('/')
val dateRegex = Regex("""([_-])(\d{6})(\.|$)""")
val match = dateRegex.find(fileName)
return if (match != null && !hasDatePlaceholder(trimmed)) {
val dateStr = match.groupValues[2]
trimmed.replaceFirst(dateStr, DATE_PLACEHOLDER)
} else {
trimmed
}
}
fun identityKey(template: String): String = normalizeTemplate(template)
fun matchesDictionaryFile(template: String, fileName: String): Boolean {
val normalizedTemplate = normalizeTemplate(template)
val sourceBaseName = normalizedTemplate
.substringAfterLast('/')
.lowercase()
.removeDictionaryExtension()
val dictionaryBaseName = fileName
.substringAfterLast('/')
.lowercase()
.removeDictionaryExtension()
if (!hasDatePlaceholder(normalizedTemplate)) {
return dictionaryBaseName == sourceBaseName
}
val prefix = sourceBaseName
.substringBefore(DATE_PLACEHOLDER.lowercase())
.removeSuffix("_")
.removeSuffix("-")
if (prefix.isEmpty()) return false
if (dictionaryBaseName == prefix) return true
val suffix = dictionaryBaseName.removePrefix(prefix)
return suffix.matches(Regex("""^[_-]\d{6}$"""))
}
private fun String.removeDictionaryExtension(): String {
val extensions = listOf(".dsl.dz", ".dsl.gz", ".dsl", ".dz", ".gz", ".idx")
return extensions.firstOrNull(::endsWith)
?.let { removeSuffix(it) }
?: this
}
fun validateTemplate(template: String): ValidationResult {
val trimmed = template.trim()
return when {
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
!trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
ValidationResult.Error(ValidationError.INVALID_EXTENSION)
!isValidUrlStructure(trimmed) ->
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
containsSuspiciousCharacters(trimmed) ->
ValidationResult.Error(ValidationError.SUSPICIOUS_CHARACTERS)
else -> ValidationResult.Valid
}
}
fun createCustomSource(name: String, urlTemplate: String): DictionarySource =
DictionarySource(
id = Uuid.random().toString(),
name = name.ifBlank { extractPrefix(urlTemplate) ?: "Custom" },
urlTemplate = urlTemplate,
)
private fun isValidUrlStructure(url: String): Boolean {
return try {
val afterProtocol = url.substringAfter("://", "")
if (afterProtocol.isEmpty()) return false
val parts = afterProtocol.split("/")
if (parts.size < 2) return false
val domain = parts[0]
if (domain.isEmpty()) return false
if (domain != "localhost" && !domain.contains(".")) return false
val filename = parts.last()
if (filename.isEmpty()) return false
val domainPattern = Regex("^[a-zA-Z0-9.-]+$")
if (!domainPattern.matches(domain)) return false
true
} catch (_: Exception) {
false
}
}
private fun containsSuspiciousCharacters(url: String): Boolean {
return url.any { char ->
char.isISOControl() ||
char == '\u0000' ||
char.code in 128..159
}
}
}
sealed interface ValidationResult {
data object Valid : ValidationResult
data class Error(val error: ValidationError) : ValidationResult
}
enum class ValidationError {
EMPTY,
INVALID_PROTOCOL,
INVALID_EXTENSION,
TOO_LONG,
INVALID_URL_STRUCTURE,
SUSPICIOUS_CHARACTERS
}
}
@@ -0,0 +1,27 @@
package com.example.research.core.domain.model
import androidx.compose.runtime.Immutable
/**
* Represents an entry in the dictionary index.
* Marked as @Immutable for Compose optimization - all properties are immutable.
*/
@Immutable
data class IndexEntry(
val word: String,
val originalWord: String,
val offset: ArticleOffset = ArticleOffset.ZERO,
val length: ArticleLength = ArticleLength.ZERO,
val dictionaryName: String = "",
val dictionaryPath: String = ""
) : Comparable<IndexEntry> {
init {
require(word.isNotBlank()) { "Word cannot be blank" }
require(offset.value >= 0) { "Offset cannot be negative: ${offset.value}" }
require(length.value >= 0) { "Length cannot be negative: ${length.value}" }
}
override fun compareTo(other: IndexEntry): Int = word.compareTo(other.word)
fun withDictionary(name: String, path: String): IndexEntry =
copy(dictionaryName = name, dictionaryPath = path)
}
@@ -0,0 +1,42 @@
package com.example.research.core.domain.model
import kotlin.math.roundToInt
/**
* Snapshot of an ongoing indexing operation.
*
* [progress] is the authoritative [0f, 1f] completion value and is O(1) to
* compute: the repository's internal `ProgressTracker` maintains a running
* sum across files incrementally and stores it in [aggregateSum], avoiding
* an O(n) walk over [perFileProgress] on every read.
* [perFileProgress] is kept for diagnostics; callers should prefer
* [progress] / [progressPercent].
*/
data class IndexingProgress(
val currentFile: String = "",
val currentIndex: Int = 0,
val totalFiles: Int = 0,
val isIndexing: Boolean = false,
val currentFileProgress: Float = 0f,
val label: String = "",
val perFileProgress: Map<String, Float> = emptyMap(),
/** Pre-aggregated sum in [0f, totalFiles] supplied by the producer; -1f = unknown. */
val aggregateSum: Float = -1f,
) {
val progress: Float
get() = if (totalFiles > 0) {
when {
aggregateSum >= 0f -> (aggregateSum / totalFiles).coerceIn(0f, 1f)
perFileProgress.isNotEmpty() -> {
val totalProgress = perFileProgress.values.sum()
(totalProgress / totalFiles).coerceIn(0f, 1f)
}
else -> {
val completedFiles = (currentIndex - 1).coerceAtLeast(0)
((completedFiles + currentFileProgress) / totalFiles).coerceIn(0f, 1f)
}
}
} else 0f
val progressPercent: Int
get() = (progress * 100).roundToInt().coerceIn(0, 100)
}
@@ -0,0 +1,54 @@
package com.example.research.core.domain.usecase
import com.example.research.core.domain.model.DictionarySource
class DictionarySourceValidator {
sealed class ValidationResult {
object Valid : ValidationResult()
data class Invalid(val reason: ValidationReason) : ValidationResult()
}
enum class ValidationReason {
DUPLICATE_URL,
DUPLICATE_PREFIX,
INVALID_FORMAT
}
fun validateNewSource(
urlTemplate: String,
existingSources: List<DictionarySource>
): ValidationResult {
val formatValidation = DictionarySource.validateTemplate(urlTemplate)
if (formatValidation is DictionarySource.ValidationResult.Error) {
return ValidationResult.Invalid(ValidationReason.INVALID_FORMAT)
}
if (existingSources.any { it.urlTemplate == urlTemplate }) {
return ValidationResult.Invalid(ValidationReason.DUPLICATE_URL)
}
val newPrefix = DictionarySource.extractPrefix(urlTemplate)
if (newPrefix != null) {
// Cache existing prefixes to avoid repeated extraction
val existingPrefixes = existingSources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toSet()
if (existingPrefixes.contains(newPrefix)) {
return ValidationResult.Invalid(ValidationReason.DUPLICATE_PREFIX)
}
}
return ValidationResult.Valid
}
fun findMatchingSourceWithPrecomputedPrefixes(
dictionaryPrefix: String,
sourcesWithPrefixes: List<Pair<DictionarySource, String?>>
): DictionarySource? {
return sourcesWithPrefixes.find { (_, sourcePrefix) ->
sourcePrefix != null && dictionaryPrefix.equals(sourcePrefix, ignoreCase = true)
}?.first
}
}
@@ -0,0 +1,50 @@
package com.example.research.core.domain.usecase
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.data.local.preferences.PreferencesManager
import kotlinx.coroutines.flow.first
class ManageDictionarySourcesUseCase(
private val preferencesManager: PreferencesManager,
private val validator: DictionarySourceValidator
) {
suspend fun addSource(urlTemplate: String): AddSourceResult {
val existingSources = preferencesManager.dictionarySources.first()
return when (val validation = validator.validateNewSource(urlTemplate, existingSources)) {
is DictionarySourceValidator.ValidationResult.Valid -> {
val normalizedUrl = DictionarySource.normalizeTemplate(urlTemplate)
val source = DictionarySource.createCustomSource("", normalizedUrl)
preferencesManager.addDictionarySource(source)
AddSourceResult.Success
}
is DictionarySourceValidator.ValidationResult.Invalid -> {
AddSourceResult.ValidationFailed(validation.reason)
}
}
}
suspend fun removeSource(sourceId: String) {
preferencesManager.removeDictionarySource(sourceId)
}
suspend fun removeSourceForDictionary(dictionary: Dictionary) {
val dictionaryPrefix = dictionary.name
val sources = preferencesManager.dictionarySources.first()
val sourcesWithPrefixes = sources.map { source ->
source to DictionarySource.extractPrefix(source.urlTemplate)
}
val matchingSource = validator.findMatchingSourceWithPrecomputedPrefixes(dictionaryPrefix, sourcesWithPrefixes)
if (matchingSource != null) {
preferencesManager.removeDictionarySource(matchingSource.id)
}
}
sealed class AddSourceResult {
object Success : AddSourceResult()
data class ValidationFailed(val reason: DictionarySourceValidator.ValidationReason) : AddSourceResult()
}
}
@@ -0,0 +1,52 @@
package com.example.research.core.util
sealed class DictionaryError(
override val message: String,
override val cause: Throwable? = null
) : Exception(message, cause) {
sealed class FileSystemError(
message: String,
cause: Throwable? = null
) : DictionaryError(message, cause) {
data class NotFound(
val path: String,
override val cause: Throwable? = null
) : FileSystemError("File not found: $path", cause)
data class AccessDenied(
val path: String,
override val cause: Throwable? = null
) : FileSystemError("Access denied to file: $path", cause)
data class IoError(
override val message: String,
override val cause: Throwable? = null
) : FileSystemError(message, cause)
}
sealed class SearchError(
message: String,
cause: Throwable? = null
) : DictionaryError(message, cause) {
data class IndexCorrupted(
val dictionaryName: String,
override val message: String,
override val cause: Throwable? = null
) : SearchError(message, cause)
data class QueryFailed(
override val message: String,
override val cause: Throwable? = null
) : SearchError(message, cause)
}
data class Unknown(
override val message: String,
override val cause: Throwable? = null
) : DictionaryError(message, cause)
class OperationInProgress : DictionaryError("Operation is already in progress")
}
fun Throwable.toDictionaryError(): DictionaryError = when (this) {
is java.io.FileNotFoundException ->
DictionaryError.FileSystemError.NotFound(message ?: "Unknown file", this)
is java.io.IOException ->
DictionaryError.FileSystemError.IoError(message ?: "I/O error", this)
is SecurityException ->
DictionaryError.FileSystemError.AccessDenied(message ?: "Unknown file", this)
else ->
DictionaryError.Unknown(message ?: "Unknown error", this)
}
@@ -0,0 +1,58 @@
package com.example.research.core.util
import okhttp3.internal.http2.ErrorCode
import okhttp3.internal.http2.StreamResetException
import javax.net.ssl.SSLHandshakeException
fun Throwable.isTimeoutError(): Boolean =
this is java.io.InterruptedIOException ||
this is java.util.concurrent.TimeoutException ||
(this is java.io.IOException && message?.contains("timeout", ignoreCase = true) == true)
fun Throwable.isSslHandshakeError(): Boolean = when (this) {
is SSLHandshakeException -> true
is java.io.IOException ->
cause is SSLHandshakeException ||
message?.contains("SSL", ignoreCase = true) == true ||
message?.contains("handshake", ignoreCase = true) == true
else -> false
}
fun Throwable.isHttp2StreamResetError(): Boolean = when (this) {
is StreamResetException -> {
errorCode == ErrorCode.CANCEL ||
errorCode == ErrorCode.INTERNAL_ERROR ||
errorCode == ErrorCode.REFUSED_STREAM
}
is java.io.IOException -> {
val cause = cause
cause is StreamResetException &&
(cause.errorCode == ErrorCode.CANCEL ||
cause.errorCode == ErrorCode.INTERNAL_ERROR ||
cause.errorCode == ErrorCode.REFUSED_STREAM)
}
else -> false
}
fun Throwable.isNetworkError(): Boolean {
if (isHttp2StreamResetError()) return true
if (isTimeoutError()) return true
if (isSslHandshakeError()) return true
return when (this) {
is java.io.IOException -> {
val message = message ?: ""
message.contains("connection", ignoreCase = true) ||
message.contains("connection reset", ignoreCase = true) ||
message.contains("connection refused", ignoreCase = true) ||
message.contains("network", ignoreCase = true) ||
message.contains("unreachable", ignoreCase = true) ||
message.contains("no route", ignoreCase = true) ||
message.contains("broken pipe", ignoreCase = true) ||
message.contains("ECONNRESET", ignoreCase = true) ||
message.contains("ECONNREFUSED", ignoreCase = true) ||
message.contains("ENETUNREACH", ignoreCase = true)
}
else -> false
}
}
@@ -0,0 +1,77 @@
package com.example.research.core.util
private const val MAX_QUERY_LENGTH = 255
fun String.sanitizeQuery(): String {
return this
.filter { it != '\u0000' && !it.isISOControl() }
.let { java.text.Normalizer.normalize(it, java.text.Normalizer.Form.NFC) }
.take(MAX_QUERY_LENGTH)
}
fun String.sanitizeCacheKey(): String {
return this.replace(":", "_").replace("\n", "_").replace("\t", "_")
}
fun String.removeDictionarySuffixes(): String {
return this.removeSuffix(".dsl.dz").removeSuffix(".dsl")
}
fun String.extractDictionaryPrefix(): String {
val nameWithoutExt = this
.removeSuffix(".dsl.dz")
.removeSuffix(".dsl")
.removeSuffix(".gz")
val separatorIndex = nameWithoutExt.indexOfAny(charArrayOf('_', '-'))
return if (separatorIndex > 0) {
nameWithoutExt.substring(0, separatorIndex)
} else {
nameWithoutExt
}
}
fun String.removeAccentTagsForIndexing(): String {
val firstOpen = indexOf("[']")
if (firstOpen < 0) return this
var builder: StringBuilder? = null
var appendStart = 0
var index = firstOpen
while (index < length) {
if (startsWithAt(index, "[']")) {
var scan = index + 3
while (scan < length && this[scan] != '[') {
scan++
}
if (startsWithAt(scan, "[/']")) {
if (builder == null) {
builder = StringBuilder(length)
}
builder.append(this, appendStart, index)
builder.append(this, index + 3, scan)
index = scan + 4
appendStart = index
continue
}
}
index++
}
return builder?.also {
it.append(this, appendStart, length)
}?.toString() ?: this
}
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
}
@@ -0,0 +1,30 @@
package com.example.research.core.util
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
sealed interface OperationResult<out T> {
data class Success<T>(val data: T) : OperationResult<T>
data class Error(
val message: String,
val cause: Throwable? = null,
val error: DictionaryError? = null
) : OperationResult<Nothing>
}
@OptIn(ExperimentalContracts::class)
inline fun <T> OperationResult<T>.onSuccess(action: (T) -> Unit): OperationResult<T> {
@Suppress("UnusedReturnValue")
contract {
callsInPlace(action, InvocationKind.AT_MOST_ONCE)
}
if (this is OperationResult.Success) action(data)
return this
}
@OptIn(ExperimentalContracts::class)
inline fun <T> OperationResult<T>.onError(action: (String, Throwable?) -> Unit): OperationResult<T> {
@Suppress("UnusedReturnValue")
contract {
callsInPlace(action, InvocationKind.AT_MOST_ONCE)
}
if (this is OperationResult.Error) action(message, cause)
return this
}
@@ -0,0 +1,226 @@
package com.example.research.data.dictzip
import java.io.IOException
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
class DictZipHeader private constructor(
val version: Int,
val chunkLength: Int,
val chunkCount: Int,
val chunkSizes: IntArray,
val dataOffset: Long,
val filename: String?
) {
val chunkOffsets: LongArray by lazy {
LongArray(chunkCount + 1).apply {
this[0] = dataOffset
for (i in 0 until chunkCount) {
this[i + 1] = this[i] + chunkSizes[i]
}
}
}
val uncompressedSize: Long
get() = chunkLength.toLong() * chunkCount
fun getChunkIndex(uncompressedOffset: Long): Int {
return (uncompressedOffset / chunkLength).toInt().coerceIn(0, chunkCount - 1)
}
fun getOffsetInChunk(uncompressedOffset: Long): Int {
return (uncompressedOffset % chunkLength).toInt()
}
fun getCompressedOffset(chunkIndex: Int): Long {
return chunkOffsets[chunkIndex]
}
fun getCompressedSize(chunkIndex: Int): Int {
return chunkSizes[chunkIndex]
}
companion object {
private const val GZIP_MAGIC_1 = 0x1f
private const val GZIP_MAGIC_2 = 0x8b
private const val COMPRESSION_DEFLATE = 8
private const val FHCRC = 0x02
private const val FEXTRA = 0x04
private const val FNAME = 0x08
private const val FCOMMENT = 0x10
private const val DICTZIP_SI1 = 'R'.code.toByte()
private const val DICTZIP_SI2 = 'A'.code.toByte()
fun isDictZip(file: RandomAccessFile): Boolean {
return try {
file.seek(0)
val magic1 = file.read()
val magic2 = file.read()
if (magic1 != GZIP_MAGIC_1 || magic2 != GZIP_MAGIC_2) {
return false
}
file.skipBytes(1)
val flags = file.read()
if (flags and FEXTRA == 0) {
return false
}
file.skipBytes(6)
val xlen = readUInt16LE(file)
var remaining = xlen
while (remaining >= 4) {
val si1 = file.read().toByte()
val si2 = file.read().toByte()
val sublen = readUInt16LE(file)
remaining -= 4
if (si1 == DICTZIP_SI1 && si2 == DICTZIP_SI2) {
return true
}
file.skipBytes(sublen)
remaining -= sublen
}
false
} catch (_: Exception) {
false
}
}
fun parse(file: RandomAccessFile): DictZipHeader {
file.seek(0)
val magic1 = file.read()
val magic2 = file.read()
if (magic1 != GZIP_MAGIC_1 || magic2 != GZIP_MAGIC_2) {
throw IOException("Not a GZIP file: invalid magic number")
}
val cm = file.read()
if (cm != COMPRESSION_DEFLATE) {
throw IOException("Unsupported compression method: $cm")
}
val flags = file.read()
if (flags and FEXTRA == 0) {
throw IOException("Not a DictZip file: FEXTRA flag not set")
}
file.skipBytes(6)
val xlen = readUInt16LE(file)
val extraField = ByteArray(xlen)
file.readFully(extraField)
var version = 0
var chunkLength = 0
var chunkCount = 0
var chunkSizes: IntArray? = null
val buffer = ByteBuffer.wrap(extraField).order(ByteOrder.LITTLE_ENDIAN)
while (buffer.remaining() >= 4) {
val si1 = buffer.get()
val si2 = buffer.get()
val sublen = buffer.short.toInt() and 0xFFFF
if (si1 == DICTZIP_SI1 && si2 == DICTZIP_SI2) {
version = buffer.short.toInt() and 0xFFFF
chunkLength = buffer.short.toInt() and 0xFFFF
chunkCount = buffer.short.toInt() and 0xFFFF
chunkSizes = IntArray(chunkCount)
for (i in 0 until chunkCount) {
chunkSizes[i] = buffer.short.toInt() and 0xFFFF
}
break
} else {
buffer.position(buffer.position() + sublen)
}
}
if (chunkSizes == null) {
throw IOException("Not a DictZip file: 'RA' subfield not found")
}
var dataOffset = file.filePointer
var filename: String? = null
if (flags and FNAME != 0) {
val nameBytes = mutableListOf<Byte>()
var b = file.read()
while (b != 0 && b != -1) {
nameBytes.add(b.toByte())
b = file.read()
}
filename = String(nameBytes.toByteArray(), Charsets.ISO_8859_1)
dataOffset = file.filePointer
}
if (flags and FCOMMENT != 0) {
var b = file.read()
while (b != 0 && b != -1) {
b = file.read()
}
dataOffset = file.filePointer
}
if (flags and FHCRC != 0) {
file.skipBytes(2)
dataOffset = file.filePointer
}
return DictZipHeader(
version = version,
chunkLength = chunkLength,
chunkCount = chunkCount,
chunkSizes = chunkSizes,
dataOffset = dataOffset,
filename = filename
)
}
private fun readUInt16LE(file: RandomAccessFile): Int {
val b1 = file.read()
val b2 = file.read()
return (b2 shl 8) or b1
}
fun isValidDictZip(file: RandomAccessFile): Boolean {
return try {
file.seek(0)
val header = parse(file)
if (header.chunkCount == 0 || header.chunkSizes.isEmpty()) {
return false
}
val compressedSize = header.chunkSizes[0]
val compressedOffset = header.dataOffset
val compressedBuffer = ByteArray(compressedSize)
file.seek(compressedOffset)
file.readFully(compressedBuffer)
val inflater = java.util.zip.Inflater(true)
try {
inflater.setInput(compressedBuffer)
val decompressBuffer = ByteArray(header.chunkLength)
val decompressedSize = inflater.inflate(decompressBuffer)
decompressedSize > 0
} finally {
inflater.end()
}
} catch (_: Exception) {
false
}
}
}
}
@@ -0,0 +1,168 @@
package com.example.research.data.dictzip
import java.io.Closeable
import java.io.IOException
import java.io.InputStream
import java.io.RandomAccessFile
import java.util.zip.DataFormatException
import java.util.zip.Inflater
class DictZipInputStream(
private val file: RandomAccessFile,
private val header: DictZipHeader
) : InputStream(), Closeable {
private val inflater = Inflater(true)
private var position: Long = 0
private var markPosition: Long = -1
private var cachedChunkIndex: Int = -1
private var cachedChunkData: ByteArray = ByteArray(0)
private var cachedChunkSize: Int = 0
private var compressedBuffer: ByteArray = ByteArray(header.chunkLength + 1024)
private var decompressBuffer: ByteArray = ByteArray(header.chunkLength)
fun seek(newPosition: Long) {
if (newPosition < 0) {
throw IllegalArgumentException("Position cannot be negative: $newPosition")
}
position = newPosition
}
fun getPosition(): Long = position
private fun readBytes(length: Int): ByteArray {
if (length <= 0) return ByteArray(0)
val result = ByteArray(length)
val bytesRead = read(result, 0, length).coerceAtLeast(0)
return if (bytesRead == length) {
result
} else {
result.copyOf(bytesRead)
}
}
fun readBytesAt(offset: Long, length: Int): ByteArray {
val savedPosition = position
seek(offset)
val result = readBytes(length)
position = savedPosition
return result
}
override fun read(): Int {
if (position >= header.uncompressedSize) {
return -1
}
val chunkIndex = header.getChunkIndex(position)
val chunkSize = ensureChunkData(chunkIndex)
val chunkData = cachedChunkData
val offsetInChunk = header.getOffsetInChunk(position)
if (offsetInChunk >= chunkSize) {
return -1
}
position++
return chunkData[offsetInChunk].toInt() and 0xFF
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
if (len == 0) return 0
if (position >= header.uncompressedSize) return -1
var bytesRead = 0
while (bytesRead < len && position < header.uncompressedSize) {
val chunkIndex = header.getChunkIndex(position)
if (chunkIndex >= header.chunkCount) break
val chunkSize = ensureChunkData(chunkIndex)
val offsetInChunk = header.getOffsetInChunk(position)
val availableInChunk = chunkSize - offsetInChunk
if (availableInChunk <= 0) break
val toRead = minOf(len - bytesRead, availableInChunk)
System.arraycopy(cachedChunkData, offsetInChunk, b, off + bytesRead, toRead)
bytesRead += toRead
position += toRead
}
return if (bytesRead > 0) bytesRead else -1
}
override fun available(): Int {
val remaining = header.uncompressedSize - position
return if (remaining > Int.MAX_VALUE) Int.MAX_VALUE else remaining.toInt()
}
override fun skip(n: Long): Long {
if (n <= 0) return 0
val oldPosition = position
val newPosition = minOf(position + n, header.uncompressedSize)
position = newPosition
return newPosition - oldPosition
}
override fun markSupported(): Boolean = true
override fun mark(readlimit: Int) {
markPosition = position
}
override fun reset() {
if (markPosition < 0) {
throw IOException("Mark not set")
}
position = markPosition
}
override fun close() {
inflater.end()
cachedChunkData = ByteArray(0)
cachedChunkSize = 0
file.close()
}
private fun decompressChunk(chunkIndex: Int, targetBuffer: ByteArray): Int {
val compressedSize = header.getCompressedSize(chunkIndex)
val compressedOffset = header.getCompressedOffset(chunkIndex)
if (compressedBuffer.size < compressedSize) {
compressedBuffer = ByteArray(compressedSize)
}
synchronized(file) {
file.seek(compressedOffset)
file.readFully(compressedBuffer, 0, compressedSize)
}
inflater.reset()
inflater.setInput(compressedBuffer, 0, compressedSize)
return try {
inflater.inflate(targetBuffer)
} catch (e: DataFormatException) {
throw IOException("Failed to decompress chunk $chunkIndex: ${e.message}", e)
}
}
private fun ensureChunkData(chunkIndex: Int): Int {
if (chunkIndex == cachedChunkIndex && cachedChunkSize > 0) {
return cachedChunkSize
}
cachedChunkSize = decompressChunk(chunkIndex, decompressBuffer)
cachedChunkIndex = chunkIndex
cachedChunkData = decompressBuffer
return cachedChunkSize
}
}
@@ -0,0 +1,124 @@
package com.example.research.data.dictzip
import java.io.Closeable
import java.io.IOException
import java.io.InputStream
import java.io.RandomAccessFile
class DictZipRandomAccessFile private constructor(
private val file: RandomAccessFile,
private val header: DictZipHeader,
private val stream: DictZipInputStream
) : Closeable {
private val lock = Any()
val length: Long
get() = header.uncompressedSize
fun seek(pos: Long) {
synchronized(lock) {
stream.seek(pos)
}
}
fun getFilePointer(): Long {
synchronized(lock) {
return stream.getPosition()
}
}
fun read(): Int {
synchronized(lock) {
return stream.read()
}
}
fun read(buffer: ByteArray, offset: Int, length: Int): Int {
synchronized(lock) {
return stream.read(buffer, offset, length)
}
}
fun readBytesAt(offset: Long, length: Int): ByteArray {
synchronized(lock) {
return stream.readBytesAt(offset, length)
}
}
fun skipBytes(n: Int): Int {
synchronized(lock) {
return stream.skip(n.toLong()).toInt()
}
}
fun asInputStream(): InputStream {
return object : InputStream() {
private var markPos: Long = -1
override fun read(): Int = this@DictZipRandomAccessFile.read()
override fun read(b: ByteArray, off: Int, len: Int): Int =
this@DictZipRandomAccessFile.read(b, off, len)
override fun available(): Int =
(length - getFilePointer()).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
override fun skip(n: Long): Long {
val skipped = skipBytes(n.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
return skipped.toLong()
}
override fun markSupported(): Boolean = true
override fun mark(readlimit: Int) {
markPos = getFilePointer()
}
override fun reset() {
if (markPos < 0) {
throw IOException("Mark not set")
}
seek(markPos)
}
override fun close() {
this@DictZipRandomAccessFile.close()
}
}
}
override fun close() {
synchronized(lock) {
stream.close()
file.close()
}
}
companion object {
fun open(path: String): DictZipRandomAccessFile {
val file = RandomAccessFile(path, "r")
return try {
val header = DictZipHeader.parse(file)
val stream = DictZipInputStream(file, header)
DictZipRandomAccessFile(file, header, stream)
} catch (e: Exception) {
file.close()
throw IOException("Failed to open DictZip file: ${e.message}", e)
}
}
fun isDictZip(path: String): Boolean {
return try {
RandomAccessFile(path, "r").use { file ->
DictZipHeader.isDictZip(file)
}
} catch (_: Exception) {
false
}
}
fun isValidDictZip(path: String): Boolean {
return try {
RandomAccessFile(path, "r").use { file ->
DictZipHeader.isValidDictZip(file)
}
} catch (_: Exception) {
false
}
}
}
}
@@ -0,0 +1,165 @@
package com.example.research.data.dictzip
import java.io.Closeable
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.CRC32
import java.util.zip.Deflater
class DictZipWriter private constructor(
private val chunkSize: Int,
private val output: OutputStream,
private val filename: String?
) : Closeable {
private val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true)
private val crc32 = CRC32()
private val chunkSizesFile = File.createTempFile("dzwriter_", ".chunks")
private val chunkSizesStream = FileOutputStream(chunkSizesFile)
private val chunkDataFile = File.createTempFile("dzwriter_", ".data")
private val chunkDataStream = FileOutputStream(chunkDataFile)
private var totalUncompressedSize: Long = 0
private var chunkCount: Int = 0
private fun addChunk(data: ByteArray, length: Int) {
crc32.update(data, 0, length)
totalUncompressedSize += length
deflater.reset()
deflater.setInput(data, 0, length)
deflater.finish()
val buffer = ByteArray(8192)
var compressedBytes = 0
while (!deflater.finished()) {
val count = deflater.deflate(buffer)
if (count > 0) {
chunkDataStream.write(buffer, 0, count)
compressedBytes += count
}
}
chunkSizesStream.write(compressedBytes and 0xFF)
chunkSizesStream.write((compressedBytes shr 8) and 0xFF)
chunkCount++
}
private fun finishWriting() {
chunkSizesStream.close()
chunkDataStream.close()
val buffer = ByteBuffer.allocate(65536).order(ByteOrder.LITTLE_ENDIAN)
val raSubfieldSize = 2 + 2 + 2 + (chunkCount * 2)
val xlen = 4 + raSubfieldSize
buffer.clear()
buffer.put(0x1f.toByte())
buffer.put(0x8b.toByte())
buffer.put(0x08.toByte())
val flags = 0x04 or if (filename != null) 0x08 else 0x00
buffer.put(flags.toByte())
buffer.putInt(0)
buffer.put(0x00.toByte())
buffer.put(0x03.toByte())
buffer.putShort(xlen.toShort())
buffer.put('R'.code.toByte())
buffer.put('A'.code.toByte())
buffer.putShort(raSubfieldSize.toShort())
buffer.putShort(1.toShort())
buffer.putShort(chunkSize.toShort())
buffer.putShort(chunkCount.toShort())
RandomAccessFile(chunkSizesFile, "r").use { raf ->
repeat(chunkCount) {
val size = raf.read() or (raf.read() shl 8)
buffer.putShort(size.toShort())
}
}
output.write(buffer.array(), 0, buffer.position())
if (filename != null) {
output.write(filename.toByteArray(Charsets.ISO_8859_1))
output.write(0)
}
RandomAccessFile(chunkDataFile, "r").use { raf ->
val dataBuffer = ByteArray(65536)
var read: Int
while (raf.read(dataBuffer).also { read = it } != -1) {
output.write(dataBuffer, 0, read)
}
}
buffer.clear()
buffer.putInt(crc32.value.toInt())
buffer.putInt((totalUncompressedSize and 0xFFFFFFFF).toInt())
output.write(buffer.array(), 0, 8)
}
override fun close() {
deflater.end()
try { chunkSizesStream.close() } catch (_: Exception) {}
try { chunkDataStream.close() } catch (_: Exception) {}
chunkSizesFile.delete()
chunkDataFile.delete()
}
companion object {
const val DEFAULT_CHUNK_SIZE = 58315
fun compressStream(
input: InputStream,
output: OutputStream,
chunkSize: Int = DEFAULT_CHUNK_SIZE,
totalSize: Long? = null,
filename: String? = null,
onProgress: ((Float) -> Unit)? = null
): Result<Unit> {
return try {
val writer = DictZipWriter(chunkSize, output, filename)
writer.use {
val buffer = ByteArray(chunkSize)
var bytesProcessed = 0L
while (true) {
val bytesRead = readFully(input, buffer)
if (bytesRead <= 0) break
writer.addChunk(buffer, bytesRead)
bytesProcessed += bytesRead
if (onProgress != null && totalSize != null && totalSize > 0) {
onProgress((bytesProcessed.toFloat() / totalSize).coerceAtMost(1f))
}
}
writer.finishWriting()
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(IOException("Failed to compress stream: ${e.message}", e))
}
}
private fun readFully(input: InputStream, buffer: ByteArray): Int {
var totalRead = 0
while (totalRead < buffer.size) {
val read = input.read(buffer, totalRead, buffer.size - totalRead)
if (read < 0) break
totalRead += read
}
return totalRead
}
}
}
@@ -0,0 +1,165 @@
package com.example.research.data.local.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.research.core.domain.model.DictionarySource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
class PreferencesManager(private val context: Context) {
private object PreferencesKeys {
val THEME_MODE = stringPreferencesKey("theme_mode")
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
val LANGUAGE = stringPreferencesKey("language")
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
}
private val json = Json { ignoreUnknownKeys = true }
val dictionaryPath: String =
File(context.getExternalFilesDir(null), "dictionaries").absolutePath
val themeMode: Flow<String> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.THEME_MODE] ?: "SYSTEM"
}
val language: Flow<String> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.LANGUAGE] ?: "system"
}
suspend fun saveLanguage(language: String) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.LANGUAGE] = language
}
}
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data
.map { preferences ->
val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES]
if (sourcesJson != null) {
try {
json.decodeFromString<List<DictionarySource>>(sourcesJson)
} catch (_: Exception) {
emptyList()
}
} else {
emptyList()
}
}
suspend fun saveThemeMode(theme: String) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.THEME_MODE] = theme
}
}
val isThemeExpanded: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false
}
val isLanguageExpanded: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false
}
val isDictionariesExpanded: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true
}
suspend fun saveSectionExpanded(key: String, isExpanded: Boolean) {
context.dataStore.edit { preferences ->
when (key) {
"theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded
"language" -> preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] = isExpanded
"dictionaries" -> preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] = isExpanded
}
}
}
suspend fun addDictionarySource(source: DictionarySource) {
context.dataStore.edit { preferences ->
val currentSources = decodeSources(
preferences[PreferencesKeys.DICTIONARY_SOURCES]
).toMutableList()
val normalizedSource = source.copy(
urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate)
)
val sourceKey = DictionarySource.identityKey(normalizedSource.urlTemplate)
val existingIndex = currentSources.indexOfFirst { current ->
current.id == normalizedSource.id ||
DictionarySource.identityKey(current.urlTemplate) == sourceKey
}
if (existingIndex >= 0) {
val existing = currentSources[existingIndex]
currentSources[existingIndex] = normalizedSource.copy(id = existing.id)
} else {
currentSources.add(normalizedSource)
}
preferences[PreferencesKeys.DICTIONARY_SOURCES] = json.encodeToString(currentSources)
}
}
suspend fun removeDictionarySource(sourceId: String) {
removeDictionarySources(listOf(sourceId))
}
suspend fun removeDictionarySources(sourceIds: Collection<String>) {
val ids = sourceIds.toSet()
if (ids.isEmpty()) return
context.dataStore.edit { preferences ->
val currentSources = decodeSources(
preferences[PreferencesKeys.DICTIONARY_SOURCES]
).toMutableList()
currentSources.removeAll { it.id in ids }
preferences[PreferencesKeys.DICTIONARY_SOURCES] = json.encodeToString(currentSources)
}
}
suspend fun sanitizeDictionarySources() {
context.dataStore.edit { preferences ->
val currentSources = decodeSources(
preferences[PreferencesKeys.DICTIONARY_SOURCES]
)
val seenIds = mutableSetOf<String>()
val seenUrls = mutableSetOf<String>()
val sanitized = currentSources.mapNotNull { source ->
val normalized = source.copy(
urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate)
)
val isUnique = seenIds.add(normalized.id) &&
seenUrls.add(DictionarySource.identityKey(normalized.urlTemplate))
normalized.takeIf { isUnique }
}
if (sanitized != currentSources) {
preferences[PreferencesKeys.DICTIONARY_SOURCES] = json.encodeToString(sanitized)
}
}
}
private fun decodeSources(value: String?): List<DictionarySource> {
if (value == null) return emptyList()
return try {
json.decodeFromString<List<DictionarySource>>(value)
} catch (_: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,99 @@
package com.example.research.data.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.performance.ReSearchTrace
import com.example.research.core.util.sanitizeQuery
import com.example.research.data.parser.IndexSearcher
import com.example.research.data.parser.SearchRankingContext
import com.example.research.data.parser.rankBySearchRelevance
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.CancellationException
import java.io.File
class IndexEntryPagingSource(
private val indexSearcher: IndexSearcher,
private val dictionaries: List<Dictionary>,
private val query: String
) : PagingSource<Int, IndexEntry>() {
private var searchResults: List<IndexEntry>? = null
override val jumpingSupported: Boolean = true
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IndexEntry> {
val offset = params.key ?: 0
return try {
val allResults = searchResults ?: ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) {
coroutineScope {
val rawResults = dictionaries
.filter { it.isActive }
.map { dict ->
async {
val indexFile = File(dict.indexPath)
if (!indexFile.exists()) return@async emptyList()
try {
val results = indexSearcher.search(
pathOrUri = indexFile.absolutePath,
query = query,
includeSubstringMatches = false
)
results.map {
it.withDictionary(
dict.metadata.name.ifBlank { dict.name },
dict.path
)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
emptyList()
}
}
}
.awaitAll()
.flatten()
// Precompute sorting keys to optimize comparator performance.
// entry.word is already lowercase from indexing.
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
rawResults.rankBySearchRelevance(
SearchRankingContext(normalizedQuery)
)
}
}.also { searchResults = it }
val totalSize = allResults.size
val startIndex = offset.coerceAtLeast(0).coerceAtMost(totalSize)
val endIndex = (startIndex + params.loadSize).coerceAtMost(totalSize)
val pageItems = allResults.subList(startIndex, endIndex)
val hasMore = endIndex < totalSize
val nextKey = if (hasMore) endIndex else null
val prevKey = if (offset == 0) null else (offset - params.loadSize).coerceAtLeast(0)
LoadResult.Page(
data = pageItems,
prevKey = prevKey,
nextKey = nextKey
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, IndexEntry>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(state.config.pageSize)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(state.config.pageSize)
}
}
}
@@ -0,0 +1,207 @@
package com.example.research.data.parser
import android.content.Context
import android.util.Log
import com.example.research.R
import com.example.research.core.domain.model.ArticleLength
import com.example.research.core.domain.model.ArticleOffset
import com.example.research.core.performance.ReSearchTrace
import com.example.research.data.dictzip.DictZipRandomAccessFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.RandomAccessFile
import java.util.zip.GZIPInputStream
class DslArticleReader(
private val context: Context
) {
private val charsetCache = LruCharsetCache(maxSize = 32)
suspend fun readArticle(
dslPathOrUri: String,
offset: ArticleOffset,
length: ArticleLength
): String = withContext(Dispatchers.IO) {
ReSearchTrace.asyncSection(ReSearchTrace.ARTICLE_READ) {
try {
val detectedCharset = detectCharset(dslPathOrUri)
readFromFile(dslPathOrUri, offset, length, detectedCharset)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "readArticle() - OOM for $dslPathOrUri offset=${offset.value} len=${length.value}", e)
trimMemory(80)
throw e
}
}
}
fun preloadCharset(pathOrUri: String, charset: DslCharsetDetector.DetectedCharset) {
charsetCache.put(pathOrUri, charset)
}
fun trimMemory(level: Int) {
if (level >= 15) {
charsetCache.evictAll()
}
}
private fun detectCharset(pathOrUri: String): DslCharsetDetector.DetectedCharset {
return charsetCache.getOrPut(pathOrUri) {
try {
openInputStream(pathOrUri).use { stream ->
val buffered = if (stream.markSupported()) stream else BufferedInputStream(stream)
buffered.use { s -> DslCharsetDetector.detect(s) }
}
} catch (e: Exception) {
Log.w(TAG, "Failed to detect charset for $pathOrUri, using default: ${e.message}")
DslCharsetDetector.DetectedCharset.DEFAULT
}
}
}
private fun readFromFile(
path: String,
offset: ArticleOffset,
length: ArticleLength,
charset: DslCharsetDetector.DetectedCharset
): String {
val file = File(path)
if (!file.exists()) {
throw IllegalArgumentException(context.getString(R.string.error_file_not_found, path))
}
val fileName = file.name.lowercase()
val bytes = when {
fileName.endsWith(".dsl.dz") && DictZipRandomAccessFile.isDictZip(path) -> {
DictZipRandomAccessFile.open(path).use { dzFile ->
dzFile.readBytesAt(offset.value, length.value)
}
}
fileName.endsWith(".dsl.gz") -> {
readCompressedBytes(path, offset, length)
}
fileName.endsWith(".dsl.dz") -> {
readCompressedBytes(path, offset, length)
}
else -> {
readUncompressedBytes(file, offset, length)
}
}
return parseDslArticleContent(String(bytes, charset.charset))
}
private fun readUncompressedBytes(
file: File,
offset: ArticleOffset,
length: ArticleLength
): ByteArray {
return RandomAccessFile(file, "r").use { raf ->
raf.seek(offset.value)
val available = (file.length() - offset.value).coerceAtLeast(0)
val bytes = ByteArray(minOf(length.value.toLong(), available).toInt())
val bytesRead = raf.read(bytes).coerceAtLeast(0)
if (bytesRead == bytes.size) bytes else bytes.copyOf(bytesRead)
}
}
private fun readCompressedBytes(
path: String,
offset: ArticleOffset,
length: ArticleLength
): ByteArray {
GZIPInputStream(FileInputStream(path)).use { gzipStream ->
var remaining = offset.value
val skipBuffer = ByteArray(8192)
while (remaining > 0) {
val toSkip = minOf(remaining, skipBuffer.size.toLong()).toInt()
val skipped = gzipStream.read(skipBuffer, 0, toSkip)
if (skipped <= 0) break
remaining -= skipped
}
val bytes = ByteArray(length.value)
var bytesRead = 0
while (bytesRead < length.value) {
val read = gzipStream.read(bytes, bytesRead, length.value - bytesRead)
if (read <= 0) break
bytesRead += read
}
return if (bytesRead == length.value) bytes else bytes.copyOf(bytesRead)
}
}
private fun openInputStream(pathOrUri: String): InputStream {
val file = File(pathOrUri)
val fileName = file.name.lowercase()
return when {
fileName.endsWith(".dsl.dz") && DictZipRandomAccessFile.isDictZip(pathOrUri) -> {
DictZipRandomAccessFile.open(pathOrUri).asInputStream()
}
fileName.endsWith(".dsl.dz") || fileName.endsWith(".dsl.gz") -> {
GZIPInputStream(FileInputStream(pathOrUri))
}
else -> {
FileInputStream(pathOrUri)
}
}
}
companion object {
private const val TAG = "DslArticleReader"
}
}
private class LruCharsetCache(private val maxSize: Int) {
private val map = object : LinkedHashMap<String, DslCharsetDetector.DetectedCharset>(maxSize + 1, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, DslCharsetDetector.DetectedCharset>) = size > maxSize
}
@Synchronized fun getOrPut(key: String, loader: () -> DslCharsetDetector.DetectedCharset): DslCharsetDetector.DetectedCharset =
map[key] ?: loader().also { map[key] = it }
@Synchronized fun put(key: String, value: DslCharsetDetector.DetectedCharset) { map[key] = value }
@Synchronized fun evictAll() { map.clear() }
}
internal fun parseDslArticleContent(rawContent: String): String {
val result = StringBuilder(rawContent.length)
var i = 0
val len = rawContent.length
if (len > 0 && rawContent[0] == '\uFEFF') i = 1
var skippingHeadwords = true
var firstContentLine = true
while (i < len) {
val lineStart = i
while (i < len && rawContent[i] != '\n') i++
val lineEnd = i
if (i < len && rawContent[i] == '\n') i++
if (lineStart == lineEnd && i >= len) break
val line = rawContent.substring(lineStart, lineEnd)
val trimmed = line.trimEnd()
if (trimmed.isEmpty()) continue
if (skippingHeadwords) {
val isBodyLine = line[0] == ' ' || line[0] == '\t'
if (!isBodyLine) continue
skippingHeadwords = false
}
if (!firstContentLine) {
result.append('\n')
}
result.append(trimmed)
firstContentLine = false
}
return result.toString()
}
@@ -0,0 +1,76 @@
package com.example.research.data.parser
import java.nio.charset.Charset
object DslCharsetDetector {
data class DetectedCharset(
val charset: Charset,
val bomSize: Int,
val isTwoByte: Boolean = charset.name().startsWith("UTF-16")
) {
companion object {
val UTF16LE = DetectedCharset(
charset = Charset.forName("UTF-16LE"),
bomSize = 2
)
val UTF16BE = DetectedCharset(
charset = Charset.forName("UTF-16BE"),
bomSize = 2
)
val UTF8 = DetectedCharset(
charset = Charsets.UTF_8,
bomSize = 3
)
val UTF8_NO_BOM = DetectedCharset(
charset = Charsets.UTF_8,
bomSize = 0
)
val DEFAULT = UTF16LE
}
}
fun detect(inputStream: java.io.InputStream): DetectedCharset {
val buffer = ByteArray(3)
if (inputStream.markSupported()) {
inputStream.mark(3)
}
val bytesRead = inputStream.read(buffer)
if (inputStream.markSupported()) {
inputStream.reset()
}
return when {
bytesRead >= 3 &&
buffer[0] == 0xEF.toByte() &&
buffer[1] == 0xBB.toByte() &&
buffer[2] == 0xBF.toByte() -> DetectedCharset.UTF8
bytesRead >= 2 &&
buffer[0] == 0xFF.toByte() &&
buffer[1] == 0xFE.toByte() -> DetectedCharset.UTF16LE
bytesRead >= 2 &&
buffer[0] == 0xFE.toByte() &&
buffer[1] == 0xFF.toByte() -> DetectedCharset.UTF16BE
else -> {
if (looksLikeUtf8(inputStream)) {
DetectedCharset.UTF8_NO_BOM
} else {
DetectedCharset.DEFAULT
}
}
}
}
private fun looksLikeUtf8(inputStream: java.io.InputStream): Boolean {
if (!inputStream.markSupported()) return false
inputStream.mark(8192)
return try {
val sample = ByteArray(8192)
val read = inputStream.read(sample)
if (read <= 0) return false
val decoder = Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
decoder.decode(java.nio.ByteBuffer.wrap(sample, 0, read))
true
} catch (_: Exception) {
false
} finally {
inputStream.reset()
}
}
}
@@ -0,0 +1,59 @@
package com.example.research.data.parser
object DslHeaderParser {
data class DslMetadata(
val name: String = "",
val indexLanguage: String = "",
val contentsLanguage: String = ""
) {
val isEmpty: Boolean
get() = name.isEmpty() && indexLanguage.isEmpty() && contentsLanguage.isEmpty()
}
fun parse(inputStream: java.io.InputStream, charset: DslCharsetDetector.DetectedCharset): DslMetadata {
var name = ""
var indexLanguage = ""
var contentsLanguage = ""
if (charset.bomSize > 0) {
var remainingBomBytes = charset.bomSize
while (remainingBomBytes > 0 && inputStream.read() >= 0) {
remainingBomBytes--
}
}
inputStream.bufferedReader(charset.charset).use { reader ->
for (line in reader.lineSequence()) {
val trimmedLine = line.trim()
if (!trimmedLine.startsWith('#')) {
break
}
when {
trimmedLine.startsWith("#NAME") -> {
name = extractHeaderValue(trimmedLine, "#NAME")
}
trimmedLine.startsWith("#INDEX_LANGUAGE") -> {
indexLanguage = extractHeaderValue(trimmedLine, "#INDEX_LANGUAGE")
}
trimmedLine.startsWith("#CONTENTS_LANGUAGE") -> {
contentsLanguage = extractHeaderValue(trimmedLine, "#CONTENTS_LANGUAGE")
}
}
}
}
return DslMetadata(
name = name,
indexLanguage = indexLanguage,
contentsLanguage = contentsLanguage
)
}
private fun extractHeaderValue(line: String, key: String): String {
val afterKey = line.substringAfter(key).trim()
return when {
afterKey.startsWith('"') && afterKey.endsWith('"') -> {
afterKey.substring(1, afterKey.length - 1)
}
afterKey.startsWith('\'') && afterKey.endsWith('\'') -> {
afterKey.substring(1, afterKey.length - 1)
}
else -> afterKey
}
}
}
@@ -0,0 +1,289 @@
package com.example.research.data.parser
object DslHeadwordParser {
private val FORMATTING_LITERAL_TAGS = arrayOf(
"/c", "'", "/'", "br", "p", "b", "i",
"ref", "ex", "e", "trn", "com", "lang", "sup", "s", "sub", "u"
)
data class ParsedHeadword(
val simplified: String,
val displayText: String,
val searchableText: String
)
fun parse(headword: String): ParsedHeadword {
val trimmed = headword.trim()
if (trimmed.isEmpty()) {
return ParsedHeadword(
simplified = "",
displayText = "",
searchableText = ""
)
}
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
return ParsedHeadword(
simplified = trimmed,
displayText = trimmed,
searchableText = trimmed.lowercase()
)
}
val firstCurlyPart = firstCurlyContent(trimmed)
var simplified = ""
val textParts = mutableListOf<String>()
if (firstCurlyPart != null) {
val beforeFirstBracket = substringBeforeFirstBracket(firstCurlyPart).trim()
if (beforeFirstBracket.isNotEmpty()) {
simplified = beforeFirstBracket
textParts.add(beforeFirstBracket)
}
val textOutsideCurly = removeCurlyBraceMatches(trimmed, removeEmpty = false).trim()
if (textOutsideCurly.isNotEmpty()) {
textParts.add(textOutsideCurly)
}
} else {
simplified = trimmed
textParts.add(trimmed)
}
val displayText = buildDisplayText(
reconstructedPrefix = reconstructDisplayPrefix(trimmed),
simplified = simplified,
textParts = textParts
)
val searchableText = createSearchableText(trimmed)
return ParsedHeadword(
simplified = simplified,
displayText = displayText,
searchableText = searchableText
)
}
private fun buildDisplayText(
reconstructedPrefix: String,
simplified: String,
textParts: List<String>
): String {
val reconstructedClean = cleanDisplayFragment(reconstructedPrefix)
if (isMeaningfulHeadword(reconstructedClean)) {
return reconstructedClean
}
val simplifiedClean = cleanDisplayFragment(simplified)
if (isMeaningfulHeadword(simplifiedClean)) {
return simplifiedClean
}
val fallback = textParts
.asSequence()
.map { cleanDisplayFragment(it) }
.firstOrNull { isMeaningfulHeadword(it) }
if (!fallback.isNullOrEmpty()) return fallback
return simplifiedClean.ifEmpty {
textParts
.asSequence()
.map { cleanDisplayFragment(it) }
.firstOrNull { it.isNotEmpty() }
.orEmpty()
}
}
private fun createSearchableText(originalHeadword: String): String {
return removeFormattingTags(
removeCurlyBraceMatches(originalHeadword, removeEmpty = true)
).trim().lowercase()
}
private fun cleanDisplayFragment(value: String): String {
if (value.isBlank()) return ""
return removeCurlyBraceMatches(
removeFormattingTags(value),
removeEmpty = true
).trim()
}
private fun isMeaningfulHeadword(value: String): Boolean {
return value.any { it.isLetterOrDigit() }
}
private fun reconstructDisplayPrefix(value: String): String {
val reconstructed = removeCurlyBraceDelimiters(value)
val formattingTagIndex = firstFormattingTagIndex(reconstructed)
return if (formattingTagIndex >= 0) {
reconstructed.substring(0, formattingTagIndex).trim()
} else {
reconstructed.trim()
}
}
private fun removeCurlyBraceDelimiters(value: String): String {
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
return buildString(value.length) {
value.forEach { char ->
if (char != '{' && char != '}') append(char)
}
}
}
private fun firstFormattingTagIndex(value: String): Int {
var index = 0
while (index < value.length) {
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
return index
}
index++
}
return -1
}
private fun firstCurlyContent(value: String): String? {
var index = 0
while (index < value.length) {
if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1)
if (end > index + 1) {
return value.substring(index + 1, end)
}
}
index++
}
return null
}
private fun substringBeforeFirstBracket(value: String): String {
val bracketIndex = value.indexOf('[')
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
}
private fun removeCurlyBraceMatches(value: String, removeEmpty: Boolean): String {
var builder: StringBuilder? = null
var appendStart = 0
var index = 0
while (index < value.length) {
if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1)
if (end >= 0 && (removeEmpty || end > index + 1)) {
if (builder == null) {
builder = StringBuilder(value.length)
}
builder.append(value, appendStart, index)
index = end + 1
appendStart = index
continue
}
}
index++
}
return builder?.also {
it.append(value, appendStart, value.length)
}?.toString() ?: value
}
private fun removeFormattingTags(value: String): String {
var builder: StringBuilder? = null
var appendStart = 0
var index = 0
while (index < value.length) {
if (value[index] == '[') {
val tagEnd = formattingTagEnd(value, index)
if (tagEnd > index) {
if (builder == null) {
builder = StringBuilder(value.length)
}
builder.append(value, appendStart, index)
index = tagEnd
appendStart = index
continue
}
}
index++
}
return builder?.also {
it.append(value, appendStart, value.length)
}?.toString() ?: value
}
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
val tokenStart = openBracketIndex + 1
if (tokenStart >= value.length) return -1
matchCInTag(value, tokenStart)?.let { return it }
for (tag in FORMATTING_LITERAL_TAGS) {
val afterTag = matchLiteral(value, tokenStart, tag)
if (afterTag >= 0) {
val end = matchFormattingTagClose(value, afterTag)
if (end >= 0) return end
}
}
if (value[tokenStart] == 'm') {
var index = tokenStart + 1
while (index < value.length && value[index] in '0'..'9') {
index++
}
val end = matchFormattingTagClose(value, index)
if (end >= 0) return end
}
return -1
}
private fun matchCInTag(value: String, tokenStart: Int): Int? {
if (value[tokenStart] != 'c') return null
var index = tokenStart + 1
val whitespaceStart = index
while (index < value.length && value[index].isTagWhitespace()) {
index++
}
if (index == whitespaceStart) return null
if (!value.startsWithAt(index, "in")) return null
val end = matchFormattingTagClose(value, index + 2)
return if (end >= 0) end else null
}
private fun matchLiteral(value: String, start: Int, literal: String): Int {
return if (value.startsWithAt(start, literal)) start + literal.length else -1
}
private fun matchFormattingTagClose(value: String, start: Int): Int {
var index = start
while (index < value.length && value[index].isTagWhitespace()) {
index++
}
return if (index < value.length && value[index] == ']') index + 1 else -1
}
private fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) {
if (this[start + i] != literal[i]) return false
}
return true
}
private fun Char.isTagWhitespace(): Boolean {
return this == ' ' ||
this == '\t' ||
this == '\n' ||
this == '\u000B' ||
this == '\u000C' ||
this == '\r'
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,581 @@
package com.example.research.data.parser
import android.content.Context
import android.util.Log
import com.example.research.R
import com.example.research.core.domain.model.ArticleLength
import com.example.research.core.domain.model.ArticleOffset
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.DictionaryError
import com.example.research.core.util.sanitizeCacheKey
import com.example.research.core.util.sanitizeQuery
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.DataInputStream
import java.io.File
import java.io.FileInputStream
import java.io.RandomAccessFile
class IndexSearcher(private val context: Context) {
private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L)
private val resultsCache = ResultsLruCache(maxEntries = 20)
/**
* Pre-allocated reusable buffer for readEntry() to reduce allocations.
* Thread-local to avoid contention in multi-threaded search scenarios.
*/
private val entryBuffer = ThreadLocal.withInitial { ByteArray(4096) }
// Not a data class: holds IntArray/LongArray/Array fields (reference equality);
// never compared with == nor used as a Map/Set key, so generated equals/hashCode
// would be misleading. Kept as a plain class.
internal class IndexMetadata(
val version: Int,
val totalCount: Int,
val sparseIndices: IntArray,
val sparseOffsets: LongArray,
val sparseWords: Array<String>,
val dataStartOffset: Long
)
data class IndexComparisonSummary(
val expectedCount: Int,
val actualCount: Int,
val comparedCount: Int
)
class IndexComparisonException(message: String) : IllegalStateException(message)
suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
try {
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
val count = metadata.totalCount
if (count == 0) return@withContext null
openSource(pathOrUri).use { source ->
val firstMatch = findFirstMatch(source, metadata, normalizedQuery)
if (firstMatch != null) {
val entry = firstMatch.second
if (entry.word == normalizedQuery) return@withContext entry.copy(word = entry.word)
var currentIdx = firstMatch.first + 1
var iterationCount = 0
while (currentIdx < count) {
if (iterationCount % 1000 == 0) yield()
val nextEntry = readEntry(source)
val nextWordLower = nextEntry.word.lowercase()
if (!nextWordLower.startsWith(normalizedQuery)) break
if (nextWordLower == normalizedQuery) return@withContext nextEntry.copy(word = nextWordLower)
currentIdx++
iterationCount++
}
}
null
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "findFirstEntry() failed for $pathOrUri: ${e.message}")
null
}
}
suspend fun search(
pathOrUri: String,
query: String,
includeSubstringMatches: Boolean = true
): List<IndexEntry> = withContext(Dispatchers.Default) {
if (query.isBlank()) {
return@withContext emptyList()
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}"
resultsCache.get(cacheKey)?.let {
return@withContext it
}
try {
val metadata = metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
val count = metadata.totalCount
if (count == 0) return@withContext emptyList()
val results = openSource(pathOrUri).use { source ->
val prefixMatches = mutableListOf<IndexEntry>()
val substringMatches = mutableListOf<IndexEntry>()
val firstMatch = findFirstMatch(source, metadata, normalizedQuery)
if (firstMatch != null) {
prefixMatches.addAll(readMatchingEntries(source, count, firstMatch, normalizedQuery))
}
if (includeSubstringMatches &&
count <= SUBSTRING_SCAN_MAX_ENTRIES &&
prefixMatches.size < 100 &&
normalizedQuery.length >= 2
) {
val prefixMatchWords = prefixMatches.map { it.word }.toSet()
source.seek(metadata.dataStartOffset)
for (i in 0 until count) {
if (i % 1000 == 0) yield()
if (substringMatches.size >= 100) break
val entry = readEntry(source)
val entryWordLower = entry.word.lowercase()
if (entryWordLower.contains(normalizedQuery) && entryWordLower !in prefixMatchWords) {
substringMatches.add(entry.copy(word = entryWordLower))
}
}
}
val combinedResults = prefixMatches + substringMatches
combinedResults.rankBySearchRelevance(
SearchRankingContext(normalizedQuery)
)
}
resultsCache.put(cacheKey, results)
results
} catch (e: CancellationException) {
throw e
} catch (e: OutOfMemoryError) {
Log.e(TAG, "search() - OOM, trimming caches", e)
trimMemory(80)
throw DictionaryError.SearchError.QueryFailed(context.getString(R.string.error_search_failed, query.take(20), pathOrUri.take(20)), e)
} catch (e: Exception) {
Log.e(TAG, "search() - 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 preloadOffsets(pathOrUri: String) {
metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
}
suspend fun compareEntryGroups(
expectedIndexPath: String,
actualIndexPath: String,
onGroup: suspend (word: String, expected: List<IndexEntry>, actual: List<IndexEntry>) -> Unit
): IndexComparisonSummary = withContext(Dispatchers.IO) {
val expectedMetadata = loadMetadata(expectedIndexPath)
val actualMetadata = loadMetadata(actualIndexPath)
if (expectedMetadata.totalCount != actualMetadata.totalCount) {
throw IndexComparisonException(
"Index entry count differs: expected=${expectedMetadata.totalCount}, " +
"actual=${actualMetadata.totalCount}"
)
}
openSource(expectedIndexPath).use { expectedSource ->
openSource(actualIndexPath).use { actualSource ->
expectedSource.seek(expectedMetadata.dataStartOffset)
actualSource.seek(actualMetadata.dataStartOffset)
var expectedNext = readEntryOrNull(expectedSource, expectedMetadata.totalCount > 0)
var actualNext = readEntryOrNull(actualSource, actualMetadata.totalCount > 0)
var comparedCount = 0
while (expectedNext != null && actualNext != null) {
if (expectedNext.word != actualNext.word) {
throw IndexComparisonException(
"Index words differ at entry $comparedCount: " +
"expected=${expectedNext.word}, actual=${actualNext.word}"
)
}
val word = expectedNext.word
val expectedGroup = mutableListOf<IndexEntry>()
val actualGroup = mutableListOf<IndexEntry>()
while (true) {
val current = expectedNext ?: break
if (current.word != word) break
expectedGroup.add(current)
comparedCount++
expectedNext = readEntryOrNull(
expectedSource,
comparedCount < expectedMetadata.totalCount
)
}
var actualReadCount = comparedCount - expectedGroup.size
while (true) {
val current = actualNext ?: break
if (current.word != word) break
actualGroup.add(current)
actualReadCount++
actualNext = readEntryOrNull(
actualSource,
actualReadCount < actualMetadata.totalCount
)
}
if (expectedGroup.size != actualGroup.size) {
throw IndexComparisonException(
"Headword multiplicity differs for '$word': " +
"expected=${expectedGroup.size}, actual=${actualGroup.size}"
)
}
onGroup(word, expectedGroup, actualGroup)
if (comparedCount % 10_000 == 0) yield()
}
if (expectedNext != null || actualNext != null) {
throw IndexComparisonException(
"One index ended before the other at entry $comparedCount"
)
}
IndexComparisonSummary(
expectedCount = expectedMetadata.totalCount,
actualCount = actualMetadata.totalCount,
comparedCount = comparedCount
)
}
}
}
private fun readEntryOrNull(source: RandomAccessSource, shouldRead: Boolean): IndexEntry? =
if (shouldRead) readEntry(source) else null
fun trimMemory(level: Int) {
metadataCache.trim(level)
resultsCache.trim(level)
}
suspend fun isMetadataLoaded(pathOrUri: String): Boolean {
return metadataCache.contains(pathOrUri)
}
private suspend fun loadMetadata(pathOrUri: String): IndexMetadata = withContext(Dispatchers.IO) {
try {
openInputStream(pathOrUri).use { stream ->
DataInputStream(BufferedInputStream(stream)).use { input ->
val version = input.readInt()
val count = input.readInt()
return@withContext if (version >= 2) {
loadMetadataV2(input, version, count)
} else {
loadMetadataV1(input, version, count)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "loadMetadata() - error: ${e.message}", e)
throw DictionaryError.SearchError.IndexCorrupted(
dictionaryName = pathOrUri,
message = context.getString(R.string.error_failed_load_index_metadata, (e.message ?: "").take(20)),
cause = e
)
}
}
// Blocking DataInputStream reads are safe here: the only caller, loadMetadata(),
// runs inside withContext(Dispatchers.IO). The dispatcher is not visible across the
// suspend-call boundary, so the inspection is suppressed deliberately.
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun loadMetadataV2(input: DataInputStream, version: Int, count: Int): IndexMetadata {
val sparseCount = input.readInt()
val sparseIndices = IntArray(sparseCount)
val sparseOffsets = LongArray(sparseCount)
val sparseWords = Array(sparseCount) { "" }
var headerSize = 4 + 4 + 4L
for (i in 0 until sparseCount) {
if (i % 1000 == 0) yield()
sparseIndices[i] = input.readInt()
sparseOffsets[i] = input.readLong()
val wordLen = input.readUnsignedShort()
val wordBytes = ByteArray(wordLen)
input.readFully(wordBytes)
sparseWords[i] = String(wordBytes, Charsets.UTF_8)
headerSize += 4 + 8 + 2 + wordLen
}
return IndexMetadata(
version = version,
totalCount = count,
sparseIndices = sparseIndices,
sparseOffsets = sparseOffsets,
sparseWords = sparseWords,
dataStartOffset = headerSize
)
}
// Blocking DataInputStream reads are safe here: the only caller, loadMetadata(),
// runs inside withContext(Dispatchers.IO). See loadMetadataV2() for details.
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun loadMetadataV1(input: DataInputStream, version: Int, count: Int): IndexMetadata {
val sparseIndices = mutableListOf<Int>()
val sparseOffsets = mutableListOf<Long>()
val sparseWords = mutableListOf<String>()
var currentOffset = 8L
for (i in 0 until count) {
if (i % 1000 == 0) yield()
if (i % SPARSE_INTERVAL_V1 == 0) {
sparseIndices.add(i)
sparseOffsets.add(currentOffset)
}
val wordLen = input.readUnsignedShort()
val wordBytes = ByteArray(wordLen)
input.readFully(wordBytes)
if (i % SPARSE_INTERVAL_V1 == 0) {
sparseWords.add(String(wordBytes, Charsets.UTF_8))
}
val origLen = input.readUnsignedShort()
input.skipBytes(origLen)
input.skipBytes(8 + 4)
currentOffset += 2 + wordLen + 2 + origLen + 12
}
return IndexMetadata(
version = version,
totalCount = count,
sparseIndices = sparseIndices.toIntArray(),
sparseOffsets = sparseOffsets.toLongArray(),
sparseWords = sparseWords.toTypedArray(),
dataStartOffset = 8L
)
}
private suspend fun findFirstMatch(source: RandomAccessSource, metadata: IndexMetadata, query: String): Pair<Int, IndexEntry>? {
val sparseIndices = metadata.sparseIndices
val sparseOffsets = metadata.sparseOffsets
val sparseWords = metadata.sparseWords
if (sparseOffsets.isEmpty() || sparseWords.isEmpty()) {
return null
}
val sparseIdx = findScanStartSparseIndex(sparseWords, query)
if (sparseIdx < 0 || sparseIdx >= sparseOffsets.size) {
return null
}
var currentAbsoluteIdx = sparseIndices[sparseIdx]
source.seek(sparseOffsets[sparseIdx])
var iterationCount = 0
while (currentAbsoluteIdx < metadata.totalCount) {
if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source)
val entryWordLower = entry.word.lowercase()
if (entryWordLower >= query) {
return if (entryWordLower.startsWith(query)) Pair(currentAbsoluteIdx, entry.copy(word = entryWordLower)) else null
}
currentAbsoluteIdx++
iterationCount++
}
return null
}
private fun findScanStartSparseIndex(sparseWords: Array<String>, query: String): Int {
var left = 0
var right = sparseWords.size
// lower_bound: first sparse word >= query
while (left < right) {
val mid = left + (right - left) / 2
if (sparseWords[mid] < query) {
left = mid + 1
} else {
right = mid
}
}
return when {
sparseWords.isEmpty() -> -1
left <= 0 -> 0
left >= sparseWords.size -> sparseWords.size - 1
else -> left - 1
}
}
private suspend fun readMatchingEntries(
source: RandomAccessSource,
totalCount: Int,
firstMatch: Pair<Int, IndexEntry>,
query: String
): List<IndexEntry> {
val results = mutableListOf<IndexEntry>()
results.add(firstMatch.second)
var currentIdx = firstMatch.first + 1
var iterationCount = 0
while (currentIdx < totalCount && results.size < 100) {
if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source)
val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(query)) break
results.add(entry.copy(word = entryWordLower))
currentIdx++
iterationCount++
}
return results
}
private fun readEntry(source: RandomAccessSource): IndexEntry {
val wordLen = source.readUnsignedShort()
val word = if (wordLen <= 4096) {
val buffer = entryBuffer.get() ?: throw IllegalStateException("Entry buffer pool exhausted")
source.readFully(buffer, 0, wordLen)
String(buffer, 0, wordLen, Charsets.UTF_8)
} else {
val bytes = ByteArray(wordLen)
source.readFully(bytes)
String(bytes, Charsets.UTF_8)
}
val origLen = source.readUnsignedShort()
val originalWord = if (origLen <= 4096) {
val buffer = entryBuffer.get() ?: throw IllegalStateException("Entry buffer pool exhausted")
source.readFully(buffer, 0, origLen)
String(buffer, 0, origLen, Charsets.UTF_8)
} else {
val bytes = ByteArray(origLen)
source.readFully(bytes)
String(bytes, Charsets.UTF_8)
}
val offset = ArticleOffset(source.readLong())
val length = ArticleLength(source.readInt())
return IndexEntry(word, originalWord, offset, length)
}
private fun openSource(pathOrUri: String): RandomAccessSource {
val file = File(pathOrUri)
if (!file.exists()) throw DictionaryError.FileSystemError.NotFound(pathOrUri)
return FileSource(file)
}
private fun openInputStream(pathOrUri: String): java.io.InputStream {
val file = File(pathOrUri)
if (!file.exists()) throw DictionaryError.FileSystemError.NotFound(pathOrUri)
return FileInputStream(file)
}
private companion object {
private const val TAG = "IndexSearcher"
private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000
private const val SPARSE_INTERVAL_V1 = 128
}
}
private class ResultsLruCache(private val maxEntries: Int) {
private val mutex = Mutex()
private val map = object : LinkedHashMap<String, List<IndexEntry>>(maxEntries, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<IndexEntry>>): Boolean {
return size > maxEntries
}
}
suspend fun get(key: String): List<IndexEntry>? = mutex.withLock {
map[key]
}
suspend fun put(key: String, value: List<IndexEntry>) = mutex.withLock {
map[key] = value
}
fun trim(level: Int) {
if (level >= 15) {
mutex.tryLock().let { locked ->
if (locked) {
try {
map.clear()
} finally {
mutex.unlock()
}
}
}
}
}
}
private class MetadataLruCache(private val maxBytes: Long) {
private data class Entry(val metadata: IndexSearcher.IndexMetadata, val bytes: Long)
private val mutex = Mutex()
private val map = LinkedHashMap<String, Entry>(8, 0.75f, true)
private var currentBytes: Long = 0
suspend fun getOrPut(key: String, loader: suspend () -> IndexSearcher.IndexMetadata): IndexSearcher.IndexMetadata {
mutex.withLock {
map[key]?.let {
return it.metadata
}
}
val metadata = loader()
val bytes = metadata.sparseIndices.size.toLong() * 4L +
metadata.sparseOffsets.size.toLong() * 8L +
metadata.sparseWords.sumOf { it.length * 2L + 40 }
return mutex.withLock {
map[key]?.metadata ?: run {
putInternal(key, metadata, bytes)
metadata
}
}
}
suspend fun contains(key: String): Boolean = mutex.withLock {
map.containsKey(key)
}
fun trim(level: Int) {
synchronized(map) {
when {
level >= 80 ->
{ map.clear(); currentBytes = 0 }
level >= 20 -> {
val keep = maxOf(1, map.size / 2)
val toRemove = map.keys.toList().dropLast(keep)
toRemove.forEach { map.remove(it)?.let { e -> currentBytes -= e.bytes } }
}
level >= 15 -> {
val keep = maxOf(1, map.size / 4)
val toRemove = map.keys.toList().dropLast(keep)
toRemove.forEach { map.remove(it)?.let { e -> currentBytes -= e.bytes } }
}
}
}
}
private fun putInternal(key: String, metadata: IndexSearcher.IndexMetadata, bytes: Long) {
map[key]?.let { existing ->
currentBytes -= existing.bytes
map.remove(key)
}
map[key] = Entry(metadata, bytes)
currentBytes += bytes
while (currentBytes > maxBytes && map.isNotEmpty()) {
val eldestKey = map.entries.firstOrNull()?.key ?: break
val eldest = map.remove(eldestKey) ?: break
currentBytes -= eldest.bytes
}
}
}
interface RandomAccessSource : Closeable {
fun seek(pos: Long)
fun readUnsignedShort(): Int
fun readInt(): Int
fun readLong(): Long
fun readFully(b: ByteArray)
fun readFully(b: ByteArray, off: Int, len: Int)
}
class FileSource(file: File) : RandomAccessSource {
private val raf = RandomAccessFile(file, "r")
override fun seek(pos: Long) {
raf.seek(pos)
}
override fun readUnsignedShort(): Int = raf.readUnsignedShort()
override fun readInt(): Int = raf.readInt()
override fun readLong(): Long = raf.readLong()
override fun readFully(b: ByteArray) {
raf.readFully(b)
}
override fun readFully(b: ByteArray, off: Int, len: Int) {
raf.readFully(b, off, len)
}
override fun close() {
raf.close()
}
}
@@ -0,0 +1,200 @@
package com.example.research.data.parser
import android.app.ActivityManager
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ResourceManager(private val context: Context) {
data class ResourceInfo(
val availableMemoryMB: Long,
val totalMemoryMB: Long,
val memoryClassMB: Int,
val largeMemoryClassMB: Int,
val availableProcessors: Int,
val isLowMemoryDevice: Boolean
)
data class AdaptiveConfig(
val batchSize: Int,
val yieldInterval: Int,
val maxConcurrentBatches: Int,
val memoryThresholdMB: Long
)
suspend fun getResourceInfo(): ResourceInfo = withContext(Dispatchers.Default) {
val runtime = Runtime.getRuntime()
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
?: return@withContext ResourceInfo(0, 0, 0, 0, 0, false)
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
val totalMemory = runtime.totalMemory()
val freeMemory = runtime.freeMemory()
val maxMemory = runtime.maxMemory()
val usedMemory = totalMemory - freeMemory
val availableMemory = maxMemory - usedMemory
ResourceInfo(
availableMemoryMB = availableMemory / (1024 * 1024),
totalMemoryMB = maxMemory / (1024 * 1024),
memoryClassMB = activityManager.memoryClass,
largeMemoryClassMB = activityManager.largeMemoryClass,
availableProcessors = runtime.availableProcessors(),
isLowMemoryDevice = memoryInfo.lowMemory
)
}
suspend fun calculateAdaptiveConfig(
estimatedEntryCount: Long = 0,
currentMemoryUsageMB: Long = 0
): AdaptiveConfig = withContext(Dispatchers.Default) {
val resourceInfo = getResourceInfo()
val availableMemoryMB = resourceInfo.availableMemoryMB - currentMemoryUsageMB
val isLowMemory = resourceInfo.isLowMemoryDevice || availableMemoryMB < 100
val batchSize = calculateBatchSize(
availableMemoryMB = availableMemoryMB,
isLowMemory = isLowMemory,
estimatedEntryCount = estimatedEntryCount
)
val yieldInterval = calculateYieldInterval(
availableMemoryMB = availableMemoryMB,
isLowMemory = isLowMemory,
estimatedEntryCount = estimatedEntryCount
)
val maxConcurrentBatches = calculateMaxConcurrentBatches(
availableMemoryMB = availableMemoryMB,
processors = resourceInfo.availableProcessors,
batchSize = batchSize
)
val memoryThresholdMB = (availableMemoryMB * 0.8).toLong()
AdaptiveConfig(
batchSize = batchSize,
yieldInterval = yieldInterval,
maxConcurrentBatches = maxConcurrentBatches,
memoryThresholdMB = memoryThresholdMB
)
}
private fun calculateBatchSize(
availableMemoryMB: Long,
isLowMemory: Boolean,
estimatedEntryCount: Long
): Int {
if (isLowMemory) {
return MIN_BATCH_SIZE
}
val baseBatchSize = when {
availableMemoryMB > 1000 -> 180_000
availableMemoryMB > 500 -> 120_000
availableMemoryMB > 200 -> 90_000
availableMemoryMB > 100 -> 45_000
else -> 20_000
}
val adjustedBatchSize = when {
estimatedEntryCount > 100_000_000 -> (baseBatchSize * 0.5).toInt()
estimatedEntryCount > 50_000_000 -> (baseBatchSize * 0.7).toInt()
estimatedEntryCount > 10_000_000 -> (baseBatchSize * 0.9).toInt()
else -> baseBatchSize
}
return adjustedBatchSize.coerceIn(MIN_BATCH_SIZE, MAX_BATCH_SIZE)
}
private fun calculateYieldInterval(
availableMemoryMB: Long,
isLowMemory: Boolean,
estimatedEntryCount: Long
): Int {
if (isLowMemory) {
return MIN_YIELD_INTERVAL
}
val baseInterval = when {
availableMemoryMB > 1000 -> 50_000
availableMemoryMB > 500 -> 30_000
availableMemoryMB > 200 -> 20_000
availableMemoryMB > 100 -> 10_000
else -> 5_000
}
val adjustedInterval = when {
estimatedEntryCount > 100_000_000 -> (baseInterval * 0.5).toInt()
estimatedEntryCount > 50_000_000 -> (baseInterval * 0.7).toInt()
estimatedEntryCount > 10_000_000 -> (baseInterval * 0.9).toInt()
else -> baseInterval
}
return adjustedInterval.coerceIn(MIN_YIELD_INTERVAL, MAX_YIELD_INTERVAL)
}
private fun calculateMaxConcurrentBatches(
availableMemoryMB: Long,
processors: Int,
batchSize: Int
): Int {
val estimatedBatchMemoryMB = (batchSize * ESTIMATED_ENTRY_SIZE_BYTES) / (1024 * 1024)
val maxBatchesByMemory = (availableMemoryMB * 0.3 / estimatedBatchMemoryMB).toInt()
val maxBatchesByProcessors = (processors - 1).coerceAtLeast(1)
return minOf(maxBatchesByMemory, maxBatchesByProcessors, MAX_CONCURRENT_BATCHES)
.coerceAtLeast(MIN_CONCURRENT_BATCHES)
}
suspend fun shouldYield(
config: AdaptiveConfig
): Boolean = withContext(Dispatchers.Default) {
val resourceInfo = getResourceInfo()
val availableMemoryMB = resourceInfo.availableMemoryMB
availableMemoryMB < config.memoryThresholdMB || resourceInfo.isLowMemoryDevice
}
suspend fun adjustConfigIfNeeded(
currentConfig: AdaptiveConfig,
gcFrequency: Int = 0
): AdaptiveConfig? = withContext(Dispatchers.Default) {
val resourceInfo = getResourceInfo()
val availableMemoryMB = resourceInfo.availableMemoryMB
val shouldReduce = availableMemoryMB < currentConfig.memoryThresholdMB ||
resourceInfo.isLowMemoryDevice ||
gcFrequency > GC_FREQUENCY_THRESHOLD
if (!shouldReduce) {
return@withContext null
}
val newBatchSize = (currentConfig.batchSize * 0.7).toInt().coerceAtLeast(MIN_BATCH_SIZE)
val newYieldInterval = (currentConfig.yieldInterval * 0.7).toInt().coerceAtLeast(MIN_YIELD_INTERVAL)
val newMaxConcurrentBatches = (currentConfig.maxConcurrentBatches * 0.8).toInt().coerceAtLeast(MIN_CONCURRENT_BATCHES)
AdaptiveConfig(
batchSize = newBatchSize,
yieldInterval = newYieldInterval,
maxConcurrentBatches = newMaxConcurrentBatches,
memoryThresholdMB = (availableMemoryMB * 0.7).toLong()
)
}
companion object {
private const val MIN_BATCH_SIZE = 10_000
private const val MAX_BATCH_SIZE = 250_000
private const val MIN_YIELD_INTERVAL = 1_000
private const val MAX_YIELD_INTERVAL = 100_000
private const val MIN_CONCURRENT_BATCHES = 1
private const val MAX_CONCURRENT_BATCHES = 2
private const val ESTIMATED_ENTRY_SIZE_BYTES = 768
private const val GC_FREQUENCY_THRESHOLD = 5
}
}
@@ -0,0 +1,38 @@
package com.example.research.data.parser
import com.example.research.core.domain.model.IndexEntry
internal data class SearchRankingContext(
val normalizedQuery: String,
)
private data class SortKey(
val charCount: Int,
val exactMatch: Int,
val startsWithMatch: Int,
val word: String,
val entry: IndexEntry,
)
internal fun List<IndexEntry>.rankBySearchRelevance(
ranking: SearchRankingContext
): List<IndexEntry> {
if (size < 2) return this
return map { entry ->
val word = entry.word
val displayKey = entry.originalWord
.trim()
.ifEmpty { entry.word.trim() }
SortKey(
charCount = displayKey.codePointCount(0, displayKey.length),
exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
startsWithMatch = if (word.startsWith(ranking.normalizedQuery)) 0 else 1,
word = word,
entry = entry,
)
}
.sortedWith(compareBy({ it.charCount }, { it.exactMatch }, { it.startsWithMatch }, { it.word }))
.map { it.entry }
}
@@ -0,0 +1,222 @@
package com.example.research.data.repository
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.example.research.R
import com.example.research.core.domain.model.ArticleLength
import com.example.research.core.domain.model.ArticleOffset
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionaryInfo
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.performance.ReSearchTrace
import com.example.research.data.dictzip.DictZipRandomAccessFile
import com.example.research.data.parser.DslArticleReader
import com.example.research.data.parser.DslCharsetDetector
import com.example.research.data.parser.DslHeaderParser
import com.example.research.data.parser.DslIndexer
import com.example.research.data.parser.IndexSearcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.util.concurrent.CancellationException
import java.util.zip.GZIPInputStream
class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEngine {
private val dslIndexer = DslIndexer(context)
private val indexSearcher = IndexSearcher(context)
private val articleReader = DslArticleReader(context)
override suspend fun indexDictWithProgress(
dictPath: String,
indexPath: String,
onArticlesIndexed: (Int) -> Unit,
onOperationChange: (String) -> Unit,
onFileProgress: (Float) -> Unit,
): DictionaryInfo = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_INDEX) {
val dictFile = File(dictPath)
val dictName = dictFile.name
val indexStartMs = SystemClock.elapsedRealtime()
try {
if (!dictFile.exists()) {
Log.e(TAG, "indexDict() - dictionary file not found: $dictPath")
return@asyncSection DictionaryInfo(
indexError = true,
errorMsg = "Dictionary file not found: $dictPath"
)
}
val indexFile = File(indexPath)
val metadata = readMetadata(dictPath)
val articleCount = withContext(Dispatchers.IO) {
openDslInputStream(dictPath).use { inputStream ->
dslIndexer.createIndex(
dslInputStream = inputStream,
indexFile = indexFile,
onProgress = { count ->
onArticlesIndexed(count)
},
onOperationChange = { operation ->
val localizedOperation = when (operation) {
"Parsing file" -> context.getString(R.string.indexing_operation_parsing)
"Sorting index" -> context.getString(R.string.indexing_operation_sorting)
"Merging batches" -> context.getString(R.string.indexing_operation_merging)
"Writing final index" -> context.getString(R.string.indexing_operation_writing_final)
else -> operation
}
onOperationChange(localizedOperation)
},
onFileProgress = { progress ->
onFileProgress(progress)
},
onCharsetDetected = { charset ->
// Preload charset to avoid repeated detection on article reads
preloadCharset(dictPath, charset)
}
)
}
}
DictionaryInfo(
name = metadata.name.ifEmpty { dictFile.nameWithoutExtension },
articlesCount = articleCount,
indexError = false,
errorMsg = ""
)
} catch (e: CancellationException) {
throw e
} catch (e: OutOfMemoryError) {
Log.e(TAG, "indexDict() - OOM dict=$dictName, durationMs=${SystemClock.elapsedRealtime() - indexStartMs}", e)
trimMemory(80)
DictionaryInfo(
indexError = true,
errorMsg = context.getString(R.string.error_indexing_failed_with_message, "out of memory")
)
} catch (e: Exception) {
Log.e(TAG, "indexDict() - error dict=$dictName, durationMs=${SystemClock.elapsedRealtime() - indexStartMs}", e)
DictionaryInfo(
indexError = true,
errorMsg = context.getString(R.string.error_indexing_failed_with_message, (e.message ?: "").take(20))
)
}
}
suspend fun search(
dictionaries: List<Dictionary>,
query: String,
maxResults: Int
): List<IndexEntry> = withContext(Dispatchers.IO) {
val allResults = mutableListOf<IndexEntry>()
for (dict in dictionaries) {
try {
val indexPath = resolveIndexPath(dict.indexPath)
val entry = indexSearcher.findFirstEntry(indexPath, query)
if (entry != null) {
allResults.add(entry.withDictionary(dict.metadata.name.ifBlank { dict.name }, dict.path))
}
if (allResults.size >= maxResults) break
} catch (e: OutOfMemoryError) {
Log.e(TAG, "search() - OOM for ${dict.indexPath}", e)
trimMemory(80)
break
} catch (e: Exception) {
Log.w(TAG, "search() - error for ${dict.indexPath}: ${e.message}")
}
}
allResults
}
suspend fun loadArticle(
dictPath: String,
offset: Long,
length: Int
): String? {
return try {
articleReader.readArticle(
dictPath,
ArticleOffset(offset),
ArticleLength(length),
)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "loadArticle() - OOM", e)
trimMemory(80)
null
} catch (e: Exception) {
Log.e(TAG, "loadArticle() - error", e)
null
}
}
fun preloadCharset(
dictPath: String,
charset: DslCharsetDetector.DetectedCharset
) {
articleReader.preloadCharset(dictPath, charset)
}
suspend fun preloadIndexMetadata(indexPath: String) {
indexSearcher.preloadOffsets(indexPath)
}
fun getIndexSearcher(): IndexSearcher = indexSearcher
fun trimMemory(level: Int) {
indexSearcher.trimMemory(level)
articleReader.trimMemory(level)
}
private fun readMetadata(dictPath: String): DslHeaderParser.DslMetadata {
return try {
openDslInputStream(dictPath).use { inputStream ->
val bufferedStream = inputStream as? BufferedInputStream
?: BufferedInputStream(inputStream, BUFFER_SIZE)
bufferedStream.mark(BUFFER_SIZE)
val charset = DslCharsetDetector.detect(bufferedStream)
bufferedStream.reset()
DslHeaderParser.parse(bufferedStream, charset)
}
} catch (e: Exception) {
Log.w(TAG, "readMetadata() failed for $dictPath: ${e.message}")
DslHeaderParser.DslMetadata()
}
}
private fun openDslInputStream(pathOrUri: String): InputStream {
val file = File(pathOrUri)
val fileName = file.name.lowercase()
return when {
fileName.endsWith(".dsl.dz") -> {
if (DictZipRandomAccessFile.isDictZip(pathOrUri)) {
DictZipRandomAccessFile.open(pathOrUri).asInputStream()
} else {
BufferedInputStream(GZIPInputStream(FileInputStream(pathOrUri)), BUFFER_SIZE)
}
}
fileName.endsWith(".dsl.gz") -> {
BufferedInputStream(GZIPInputStream(FileInputStream(pathOrUri)), BUFFER_SIZE)
}
else -> {
BufferedInputStream(FileInputStream(pathOrUri), BUFFER_SIZE)
}
}
}
private fun resolveIndexPath(indexArg: String): String {
val candidate = File(indexArg)
if (candidate.exists()) return candidate.absolutePath
val idxCandidate = File("$indexArg.idx")
if (idxCandidate.exists()) return idxCandidate.absolutePath
return indexArg
}
companion object {
private const val TAG = "KotlinDictEngine"
private const val BUFFER_SIZE = 65536
}
}
@@ -0,0 +1,607 @@
package com.example.research.data.repository
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.example.research.R
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.core.performance.ReSearchTrace
import com.example.research.core.util.DictionaryError
import com.example.research.core.util.OperationResult
import com.example.research.core.util.extractDictionaryPrefix
import com.example.research.core.util.sanitizeQuery
import com.example.research.core.util.toDictionaryError
import com.example.research.data.dictzip.DictZipRandomAccessFile
import com.example.research.data.parser.DslCharsetDetector
import com.example.research.data.parser.DslHeaderParser
import com.example.research.data.parser.DslIndexer
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import java.io.BufferedInputStream
import java.io.DataInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import java.util.zip.GZIPInputStream
private class ProgressTracker(files: List<LocalDictionaryRepository.DiscoveredFile>) {
private val totalFiles = files.size
private val fileWeights = files.associate { file ->
val weightKb = (File(file.localPath).length() / 1024L).coerceAtLeast(1L)
file.name to weightKb
}
private val totalWeight = fileWeights.values.sum().coerceAtLeast(totalFiles.toLong())
private val fileProgress = ConcurrentHashMap<String, Float>()
private val aggregateWeightedMicros = AtomicLong(0L)
fun updateFileProgress(fileName: String, progress: Float) {
val clamped = progress.coerceIn(0f, 1f)
val prev = fileProgress[fileName]
val monotonic = if (prev != null) maxOf(prev, clamped) else clamped
fileProgress[fileName] = monotonic
val weight = fileWeights[fileName] ?: 1L
val prevMicros = if (prev != null) (prev * weight * 1_000_000f).toLong() else 0L
val newMicros = (monotonic * weight * 1_000_000f).toLong()
aggregateWeightedMicros.addAndGet(newMicros - prevMicros)
}
/**
* Current weighted sum mapped back to [0f, totalFiles] so the existing
* IndexingProgress model can keep deriving progress as aggregate/total.
*/
fun aggregateSum(): Float {
val weightedProgress = aggregateWeightedMicros.get().toDouble() / (totalWeight.toDouble() * 1_000_000.0)
return (weightedProgress.coerceIn(0.0, 1.0) * totalFiles).toFloat()
}
fun reset() {
fileProgress.clear()
aggregateWeightedMicros.set(0L)
}
}
class LocalDictionaryRepository(
private val context: Context,
private val engine: KotlinDictionaryEngine,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : CoroutineScope {
constructor(context: Context) : this(
context,
KotlinDictionaryEngine(context)
)
override val coroutineContext = Job() + ioDispatcher
private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList())
val dictionaries: StateFlow<List<Dictionary>>
get() = mutableDictionaries
private val mutableIndexingProgress = MutableStateFlow(IndexingProgress())
val indexingProgress: StateFlow<IndexingProgress>
get() = mutableIndexingProgress
private val indexingMutex = Mutex()
private val indexingSemaphore = Semaphore(1)
private val warmupMutex = Mutex()
@Volatile
private var currentIndexingJob: Job? = null
@Volatile
private var progressTracker: ProgressTracker? = null
fun trimMemory(level: Int) {
engine.trimMemory(level)
}
fun cancelIndexing() {
currentIndexingJob?.cancel()
}
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
if (isIndexingInProgress()) {
return@withContext OperationResult.Error(
message = context.getString(R.string.indexing_already_in_progress),
error = DictionaryError.OperationInProgress()
)
}
indexingMutex.withLock {
currentIndexingJob = coroutineContext[Job]
try {
coroutineContext.ensureActive()
val filesToScan = scanLocalDirectory(pathOrUri)
if (filesToScan.isEmpty()) {
mutableDictionaries.value = emptyList()
mutableIndexingProgress.value = IndexingProgress(isIndexing = false)
return@withContext OperationResult.Success(0)
}
val filesToIndex = mutableListOf<DiscoveredFile>()
val existingDictionaries = mutableListOf<Dictionary>()
for (file in filesToScan) {
coroutineContext.ensureActive()
val existing = tryLoadExistingIndex(file)
if (existing != null) {
existingDictionaries.add(existing)
} else {
filesToIndex.add(file)
}
}
if (filesToIndex.isEmpty()) {
mutableDictionaries.value = existingDictionaries.sortedBy { it.name }
engine.getIndexSearcher().trimMemory(80)
return@withContext OperationResult.Success(existingDictionaries.size)
}
mutableDictionaries.value = existingDictionaries.sortedBy { it.name }
val totalArticlesIndexed = AtomicInteger(existingDictionaries.sumOf { it.articleCount })
progressTracker = ProgressTracker(filesToScan)
existingDictionaries.forEach { dict ->
progressTracker?.updateFileProgress(dict.name, 1.0f)
}
mutableIndexingProgress.emit(
IndexingProgress(
isIndexing = true,
currentFile = context.getString(R.string.indexing_label),
totalFiles = filesToScan.size,
currentIndex = filesToScan.size - filesToIndex.size,
aggregateSum = progressTracker?.aggregateSum() ?: -1f,
)
)
val lastEmittedPercent = AtomicInteger(-1)
val lastProgressEmitMs = AtomicLong(0L)
supervisorScope {
filesToIndex.mapIndexed { index, file ->
async {
indexingSemaphore.withPermit {
coroutineContext.ensureActive()
yield()
val res = indexDictionary(file) { _, op, prog ->
if (prog >= 0f) {
progressTracker?.updateFileProgress(file.name, prog)
}
val tracker = progressTracker
val aggregate = tracker?.aggregateSum() ?: -1f
val newPercent = if (filesToScan.isNotEmpty() && aggregate >= 0f) {
((aggregate / filesToScan.size) * 100f).toInt().coerceIn(0, 100)
} else -1
val currentIdx = filesToScan.size - filesToIndex.size + index + 1
val previous = mutableIndexingProgress.value
val now = SystemClock.elapsedRealtime()
val percentChanged = newPercent >= 0 && newPercent != lastEmittedPercent.get()
val fileChanged = previous.currentFile != file.name
val completed = newPercent >= 100 || prog >= 1f
val intervalElapsed =
now - lastProgressEmitMs.get() >= MIN_PROGRESS_EMIT_INTERVAL_MS
val shouldEmit = fileChanged || completed || (percentChanged && intervalElapsed)
if (shouldEmit) {
if (newPercent >= 0) lastEmittedPercent.set(newPercent)
lastProgressEmitMs.set(now)
mutableIndexingProgress.update { p ->
p.copy(
currentFile = file.name,
currentIndex = currentIdx,
currentFileProgress = prog,
label = op.ifEmpty { p.label },
aggregateSum = aggregate,
)
}
}
}
if (res is OperationResult.Success) {
progressTracker?.updateFileProgress(file.name, 1.0f)
totalArticlesIndexed.addAndGet(res.data.articleCount)
mutableDictionaries.update { current ->
(current + res.data)
.distinctBy { it.path }
.sortedBy { it.name }
}
res.data
} else null
}
}
}.awaitAll().filterNotNull()
}
engine.getIndexSearcher().trimMemory(80)
OperationResult.Success(mutableDictionaries.value.size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
OperationResult.Error(
message = context.getString(R.string.error_scan_directory_failed),
cause = e,
error = e.toDictionaryError()
)
} finally {
progressTracker?.reset()
progressTracker = null
currentIndexingJob = null
mutableIndexingProgress.value = IndexingProgress(isIndexing = false)
}
}
}
private suspend fun tryLoadExistingIndex(file: DiscoveredFile): Dictionary? {
val dictFile = File(file.localPath)
val indexFile = File(file.indexPath)
if (!indexFile.exists() || indexFile.length() == 0L) return null
if (!dictFile.exists()) return null
val header = readIndexHeader(indexFile)
if (header == null || header.version != DslIndexer.INDEX_VERSION) {
try { indexFile.delete() } catch (_: Exception) {}
return null
}
val fileName = file.name.substringBeforeLast(".")
val prefix = fileName.extractDictionaryPrefix()
// Load metadata (DSL #NAME, #INDEX_LANGUAGE, #CONTENTS_LANGUAGE) immediately
// so dictionary cards display the correct title without waiting for async backfill
val metadata = readMetadataSafe(dictFile.absolutePath)
return Dictionary(
name = prefix,
path = dictFile.absolutePath,
indexPath = indexFile.absolutePath,
articleCount = header.count,
metadata = metadata
)
}
private suspend fun indexDictionary(
file: DiscoveredFile,
onUpdate: (count: Int, op: String, prog: Float) -> Unit
): OperationResult<Dictionary> {
val dictPath = file.localPath
val targetIndexPath = file.indexPath
val indexFile = File(targetIndexPath)
return try {
val result = engine.indexDictWithProgress(
dictPath = dictPath,
indexPath = indexFile.absolutePath,
onArticlesIndexed = { count -> onUpdate(count, "", -1f) },
onOperationChange = { op -> onUpdate(0, op, -1f) },
onFileProgress = { prog -> onUpdate(0, "", prog) }
)
if (!result.indexError) {
val fileName = File(dictPath).name
val prefix = fileName.extractDictionaryPrefix()
val dict = Dictionary(
name = prefix,
path = dictPath,
indexPath = targetIndexPath,
articleCount = result.articlesCount,
metadata = DslHeaderParser.DslMetadata(result.name, "", "")
)
OperationResult.Success(dict)
} else {
indexFile.delete()
OperationResult.Error(result.errorMsg, null)
}
} catch(e: Exception) {
indexFile.delete()
if (e is CancellationException) throw e
OperationResult.Error(e.message ?: context.getString(R.string.error_indexing), e)
}
}
private fun scanLocalDirectory(path: String): List<DiscoveredFile> {
val dir = File(path)
if (!dir.exists() || !dir.isDirectory) return emptyList()
return dir.listFiles { f ->
f.isFile && (f.name.endsWith(".dsl") || f.name.endsWith(".dsl.dz") || f.name.endsWith(".dsl.gz"))
}?.map { file ->
DiscoveredFile(
name = file.name,
localPath = file.absolutePath,
indexPath = File(file.parent, "${file.nameWithoutExtension}.idx").absolutePath
)
} ?: emptyList()
}
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
val activeDicts = mutableDictionaries.value
val sanitizedQuery = query.sanitizeQuery()
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_DIRECT) {
try {
val results = engine.search(activeDicts, sanitizedQuery, 100)
OperationResult.Success(results)
} catch (e: Exception) {
OperationResult.Error("Search failed", e)
}
}
}
suspend fun searchInDictionary(
dictionaryPath: String,
query: String,
): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
val dictionary = mutableDictionaries.value.firstOrNull {
it.isActive && it.path == dictionaryPath
} ?: return@withContext OperationResult.Success(emptyList())
val sanitizedQuery = query.sanitizeQuery()
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_DIRECT) {
try {
OperationResult.Success(engine.search(listOf(dictionary), sanitizedQuery, 100))
} catch (e: Exception) {
OperationResult.Error("Search failed", e)
}
}
}
suspend fun getArticleFromEntry(entry: IndexEntry): OperationResult<String> = withContext(ioDispatcher) {
try {
val content = engine.loadArticle(entry.dictionaryPath, entry.offset.value, entry.length.value)
if (content != null) {
OperationResult.Success(content)
} else {
OperationResult.Error("Article not found", null)
}
} catch (e: Exception) {
OperationResult.Error("Failed to load article", e)
}
}
fun getIndexSearcher(): com.example.research.data.parser.IndexSearcher {
return engine.getIndexSearcher()
}
fun toggleDictionaryActive(dictionaryPath: String) {
mutableDictionaries.update { currentList ->
currentList.map { dict ->
if (dict.path == dictionaryPath) {
dict.withActiveStatus(!dict.isActive)
} else {
dict
}
}
}
}
fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
try {
mutableDictionaries.update { current ->
current.filter { it.path != dictionary.path }
}
launch(ioDispatcher) {
try {
val deleteErrors = mutableListOf<String>()
val dictFile = File(dictionary.path)
if (dictFile.exists()) {
if (!dictFile.delete()) {
deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}")
}
}
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
File(dictionary.indexPath)
} else {
File("${dictionary.indexPath}.idx")
}
if (indexFile.exists()) {
if (!indexFile.delete()) {
deleteErrors.add("Failed to delete index file: ${indexFile.absolutePath}")
}
}
try {
engine.getIndexSearcher().trimMemory(80)
} catch (e: Exception) {
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
}
if (deleteErrors.isNotEmpty()) {
val message = deleteErrors.joinToString("; ")
Log.w(TAG, "Dictionary deletion completed with errors: $message")
}
} catch (e: Exception) {
Log.e(TAG, "Background deletion failed: ${e.message}", e)
}
}
return OperationResult.Success(Unit)
} catch (e: Exception) {
return OperationResult.Error("Failed to delete dictionary", e)
}
}
suspend fun warmupIndexes() = withContext(defaultDispatcher) {
if (isIndexingInProgress() || !warmupMutex.tryLock()) return@withContext
try {
val dictionaries = mutableDictionaries.value
if (dictionaries.isEmpty()) return@withContext
for (dict in dictionaries) {
try {
val indexPath = resolveIndexPath(dict.indexPath)
val indexSearcher = engine.getIndexSearcher()
if (!indexSearcher.isMetadataLoaded(indexPath)) {
engine.preloadIndexMetadata(indexPath)
}
yield()
} catch (_: Exception) { /* Ignore preload failure */ }
}
} finally {
warmupMutex.unlock()
}
}
private fun resolveIndexPath(indexArg: String): String {
val candidate = File(indexArg)
if (candidate.exists()) return candidate.absolutePath
val idxCandidate = File("$indexArg.idx")
if (idxCandidate.exists()) return idxCandidate.absolutePath
return indexArg
}
private fun readIndexHeader(indexFile: File): IndexHeader? {
return try {
if (indexFile.length() < LEGACY_INDEX_HEADER_BYTES) return null
FileInputStream(indexFile).use { input ->
DataInputStream(BufferedInputStream(input)).use { dataInput ->
val version = dataInput.readInt()
val count = dataInput.readInt()
if (count < 0) return null
if (version >= 2) {
if (indexFile.length() < INDEX_V2_HEADER_BYTES) return null
val sparseCount = dataInput.readInt()
if (sparseCount < 0) return null
var headerBytes = INDEX_V2_HEADER_BYTES
repeat(sparseCount) {
dataInput.readInt()
dataInput.readLong()
val wordLength = dataInput.readUnsignedShort()
dataInput.skipFully(wordLength)
headerBytes += SPARSE_ENTRY_FIXED_BYTES + wordLength
if (headerBytes > indexFile.length()) return null
}
}
IndexHeader(version, count)
}
}
} catch (e: Exception) {
Log.w(TAG, "readIndexHeader() failed: ${e.message}")
null
}
}
private data class IndexHeader(
val version: Int,
val count: Int
)
private fun DataInputStream.skipFully(byteCount: Int) {
var remaining = byteCount
while (remaining > 0) {
val skipped = skipBytes(remaining)
if (skipped <= 0) throw java.io.EOFException()
remaining -= skipped
}
}
suspend fun loadMetadataAsync() = withContext(ioDispatcher) {
val current = mutableDictionaries.value
if (current.isEmpty()) return@withContext
val updated = current.map { dict ->
if (!dict.metadata.isEmpty) dict
else dict.copy(metadata = readMetadataSafe(dict.path))
}
mutableDictionaries.value = updated
}
private suspend fun readMetadataSafe(dictPath: String): DslHeaderParser.DslMetadata = withContext(ioDispatcher) {
return@withContext try {
val file = File(dictPath)
if (!file.exists()) return@withContext DslHeaderParser.DslMetadata()
val inputStream = openDslInputStream(dictPath)
inputStream.use { stream ->
val buffered = BufferedInputStream(stream)
val detected = DslCharsetDetector.detect(buffered)
DslHeaderParser.parse(buffered, detected)
}
} catch (_: Exception) {
DslHeaderParser.DslMetadata()
}
}
private fun openDslInputStream(pathOrUri: String): InputStream {
val file = File(pathOrUri)
val fileName = file.name.lowercase()
return when {
fileName.endsWith(".dsl.dz") && DictZipRandomAccessFile.isDictZip(pathOrUri) -> {
DictZipRandomAccessFile.open(pathOrUri).asInputStream()
}
fileName.endsWith(".dsl.dz") || fileName.endsWith(".dsl.gz") -> {
GZIPInputStream(FileInputStream(pathOrUri))
}
else -> {
FileInputStream(pathOrUri)
}
}
}
data class DiscoveredFile(
val name: String,
val localPath: String,
val indexPath: String
)
private companion object {
const val TAG = "LocalDictionaryRepository"
const val MIN_PROGRESS_EMIT_INTERVAL_MS = 200L
const val LEGACY_INDEX_HEADER_BYTES = 8L
const val INDEX_V2_HEADER_BYTES = 12
const val SPARSE_ENTRY_FIXED_BYTES = 14
}
}
@@ -0,0 +1,11 @@
package com.example.research.data.repository
import com.example.research.core.domain.model.DictionaryInfo
interface ProgressIndexingEngine {
suspend fun indexDictWithProgress(
dictPath: String,
indexPath: String,
onArticlesIndexed: (Int) -> Unit = {},
onOperationChange: (String) -> Unit = {},
onFileProgress: (Float) -> Unit = {},
): DictionaryInfo
}
@@ -0,0 +1,216 @@
package com.example.research.feature.download
import android.util.Log
import com.example.research.core.performance.ReSearchTrace
import com.example.research.feature.download.model.DownloadProgressState
import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.download.repository.DictionaryRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.*
import kotlin.time.Duration.Companion.seconds
class DownloadManager(
private val dictionaryRepository: DictionaryRepository,
private val unknownErrorMessage: String,
dispatcher: CoroutineDispatcher = Dispatchers.IO,
externalScope: CoroutineScope? = null
) {
private val supervisorJob = SupervisorJob()
private val downloadScope: CoroutineScope = externalScope ?: CoroutineScope(supervisorJob + dispatcher)
private val mutableDownloadState = MutableStateFlow<DownloadState>(DownloadState.Idle)
val downloadState: StateFlow<DownloadState> = mutableDownloadState.asStateFlow()
private val mutableDownloadProgressState = MutableStateFlow(DownloadProgressState())
val downloadProgressState: StateFlow<DownloadProgressState> =
mutableDownloadProgressState.asStateFlow()
private val mutex = Mutex()
private var downloadJob: Job? = null
companion object {
private const val TAG = "DownloadManager"
private const val DOWNLOAD_PROGRESS_STEP_PERCENT = 3
private const val EXTRACT_PROGRESS_STEP_PERCENT = 5
private val TERMINAL_STATE_DURATION = 2.seconds
}
private fun updateDownloadProgressState(
state: DownloadState = downloadProgressState.value.state,
progress: Float = downloadProgressState.value.progress
) {
val clampedProgress = progress.coerceIn(0f, 1f)
mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress)
mutableDownloadState.value = state
}
fun startDownload() {
if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return
downloadJob = downloadScope.launch {
mutex.withLock {
try {
updateDownloadProgressState(DownloadState.Loading, 0f)
val hasEnabledSources = try {
dictionaryRepository.hasEnabledSources()
} catch (_: Exception) {
false
}
if (!hasEnabledSources) {
dictionaryRepository.performAllCleanup()
updateDownloadProgressState(DownloadState.Success, 1f)
return@withLock
}
var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadDictionaries { progress ->
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedProgressBucket) {
lastReportedProgressBucket = progressBucket
updateDownloadProgressState(progress = progress)
}
}
}
if (result.isFailure) {
throw result.exceptionOrNull() ?: Exception(unknownErrorMessage)
}
updateDownloadProgressState(DownloadState.Extracting, 0f)
var lastReportedExtractBucket = -1
val extractResult = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_EXTRACT) {
dictionaryRepository.extractArchives { progress ->
val progressBucket = progressBucket(progress, EXTRACT_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedExtractBucket) {
lastReportedExtractBucket = progressBucket
updateDownloadProgressState(progress = progress)
}
}
}
if (extractResult.isFailure) {
throw extractResult.exceptionOrNull() ?: Exception(unknownErrorMessage)
}
dictionaryRepository.performAllCleanup()
updateDownloadProgressState(DownloadState.Success, 1f)
delay(TERMINAL_STATE_DURATION)
updateDownloadProgressState(DownloadState.Idle, 0f)
} catch (e: Exception) {
if (e is CancellationException) {
updateDownloadProgressState(DownloadState.Cancelled, 0f)
downloadScope.launch {
try {
dictionaryRepository.deleteUnprocessedFiles()
} catch (cleanupException: Exception) {
Log.e(TAG, "Error during cleanup after cancellation", cleanupException)
}
}
throw e
} else {
updateDownloadProgressState(DownloadState.Error(e.message ?: unknownErrorMessage), 0f)
downloadScope.launch {
try {
dictionaryRepository.deleteUnprocessedFiles()
} catch (cleanupException: Exception) {
Log.e(TAG, "Error during cleanup after error", cleanupException)
}
}
}
} finally {
downloadJob = null
}
}
}
}
fun startDownloadForSources(sourceUrls: List<String>) {
if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return
downloadJob = downloadScope.launch {
mutex.withLock {
try {
updateDownloadProgressState(DownloadState.Loading, 0f)
if (sourceUrls.isEmpty()) {
updateDownloadProgressState(DownloadState.Success, 1f)
return@withLock
}
var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSpecificSources(sourceUrls) { progress ->
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedProgressBucket) {
lastReportedProgressBucket = progressBucket
updateDownloadProgressState(progress = progress)
}
}
}
if (result.isFailure) {
throw result.exceptionOrNull() ?: Exception(unknownErrorMessage)
}
updateDownloadProgressState(DownloadState.Extracting, 0f)
var lastReportedExtractBucket = -1
val extractResult = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_EXTRACT) {
dictionaryRepository.extractArchives { progress ->
val progressBucket = progressBucket(progress, EXTRACT_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedExtractBucket) {
lastReportedExtractBucket = progressBucket
updateDownloadProgressState(progress = progress)
}
}
}
if (extractResult.isFailure) {
throw extractResult.exceptionOrNull() ?: Exception(unknownErrorMessage)
}
dictionaryRepository.performAllCleanup()
updateDownloadProgressState(DownloadState.Success, 1f)
delay(TERMINAL_STATE_DURATION)
updateDownloadProgressState(DownloadState.Idle, 0f)
} catch (e: Exception) {
if (e is CancellationException) {
updateDownloadProgressState(DownloadState.Cancelled, 0f)
downloadScope.launch {
try {
dictionaryRepository.deleteUnprocessedFiles()
} catch (cleanupException: Exception) {
Log.e(TAG, "Error during cleanup after cancellation", cleanupException)
}
}
throw e
} else {
updateDownloadProgressState(DownloadState.Error(e.message ?: unknownErrorMessage), 0f)
downloadScope.launch {
try {
dictionaryRepository.deleteUnprocessedFiles()
} catch (cleanupException: Exception) {
Log.e(TAG, "Error during cleanup after error", cleanupException)
}
}
}
} finally {
downloadJob = null
}
}
}
}
fun cancelDownload() {
downloadJob?.cancel()
updateDownloadProgressState(DownloadState.Cancelled, 0f)
downloadScope.launch {
delay(TERMINAL_STATE_DURATION)
if (downloadState.value == DownloadState.Cancelled) {
updateDownloadProgressState(DownloadState.Idle, 0f)
}
}
}
private fun progressBucket(progress: Float, stepPercent: Int): Int {
val percent = (progress.coerceIn(0f, 1f) * 100f).toInt().coerceIn(0, 100)
return if (percent >= 100) {
100
} else {
percent / stepPercent
}
}
}
@@ -0,0 +1,18 @@
package com.example.research.feature.download.config
object DictionaryConfig {
const val DABKRS_PREFIX = "dabkrs"
const val DABRUKS_PREFIX = "dabruks"
const val GZ_EXTENSION = ".gz"
const val DSL_EXTENSION = ".dsl"
const val DICTZIP_EXTENSION = ".dsl.dz"
const val BUFFER_SIZE = 8192
fun isDabkrsDictionary(fileName: String): Boolean {
val lowerName = fileName.lowercase()
val hasValidPrefix = lowerName.startsWith(DABKRS_PREFIX) || lowerName.startsWith(DABRUKS_PREFIX)
val hasValidExtension = lowerName.endsWith(DSL_EXTENSION) ||
lowerName.endsWith(DICTZIP_EXTENSION) ||
lowerName.endsWith(".dsl.gz")
return hasValidPrefix && hasValidExtension
}
}
@@ -0,0 +1,6 @@
package com.example.research.feature.download.model
data class DownloadProgressState(
val state: DownloadState = DownloadState.Idle,
val progress: Float = 0f,
)
@@ -0,0 +1,9 @@
package com.example.research.feature.download.model
sealed class DownloadState {
data object Idle : DownloadState()
data object Loading : DownloadState()
data object Extracting : DownloadState()
data object Success : DownloadState()
data class Error(val message: String) : DownloadState()
data object Cancelled : DownloadState()
}
@@ -0,0 +1,17 @@
package com.example.research.feature.download.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class DownloadCancelReceiver : BroadcastReceiver() {
companion object {
const val ACTION_CANCEL_DOWNLOAD = "com.example.research.CANCEL_DOWNLOAD"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_CANCEL_DOWNLOAD) {
val stopIntent = Intent(context, com.example.research.feature.download.service.DictionaryForegroundService::class.java).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_STOP
}
context.startService(stopIntent)
}
}
}
@@ -0,0 +1,108 @@
package com.example.research.feature.download.repository
import android.net.Uri
import android.util.Log
import com.example.research.common.util.DateUtils
import com.example.research.common.util.FileStorageManager
import com.example.research.common.util.DocumentFileInfo
import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
class DictionaryChecker(
private val fileStorageManager: FileStorageManager,
private val client: OkHttpClient
) {
private fun List<String>.hasFileForSource(source: DictionarySource, date: String): Boolean {
val template = source.urlTemplate
if (!DictionarySource.hasDatePlaceholder(template)) {
val fileNameInUrl = template.substringAfterLast('/')
val dateInUrl = DateUtils.extractDateFromFileName(fileNameInUrl)
if (dateInUrl != null && dateInUrl != date) return false
}
val expectedFileName = DictionarySource.extractFileName(template, date)
val baseNameWithoutExt = expectedFileName
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
return any { it.startsWith(baseNameWithoutExt) }
}
private suspend fun getDictFiles(folderUri: Uri, files: List<DocumentFileInfo>?): List<String> {
val fileList = files ?: fileStorageManager.listFilesInFolder(folderUri)
return fileList.map { it.name }.filter { name ->
name.endsWith(DictionaryConfig.DSL_EXTENSION) ||
name.endsWith(DictionaryConfig.DICTZIP_EXTENSION) ||
name.endsWith(".dsl.gz") ||
name.endsWith(".gz")
}
}
suspend fun areDictionariesUpToDate(
folderUri: Uri,
sources: List<DictionarySource>,
files: List<DocumentFileInfo>? = null
): Boolean = withContext(Dispatchers.IO) {
try {
val enabledSources = sources.filter { it.isEnabled }
if (enabledSources.isEmpty()) {
return@withContext true
}
val currentDate = DateUtils.getCurrentDateString()
val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files)
val allHaveToday = enabledSources.all { source ->
dictFiles.hasFileForSource(source, currentDate)
}
if (allHaveToday) {
return@withContext true
}
val allHaveYesterday = enabledSources.all { source ->
dictFiles.hasFileForSource(source, previousDate)
}
if (allHaveYesterday) {
val serverHasToday = enabledSources.any { source ->
isSourceAvailableOnServer(source, currentDate)
}
return@withContext !serverHasToday
}
false
} catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e)
false
}
}
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean {
return try {
val url = DictionarySource.buildUrl(source.urlTemplate, date)
val request = Request.Builder()
.url(url)
.head()
.build()
client.newCall(request).execute().use { response ->
response.isSuccessful
}
} catch (e: Exception) {
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
false
}
}
private companion object {
private const val TAG = "DictionaryChecker"
}
}
@@ -0,0 +1,179 @@
package com.example.research.feature.download.repository
import android.content.Context
import android.net.Uri
import com.example.research.common.util.ArchiveUtils
import com.example.research.common.util.DateUtils
import com.example.research.common.util.DocumentFileInfo
import com.example.research.common.util.FileStorageManager
import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DictionaryCleaner(
private val context: Context,
private val fileStorageManager: FileStorageManager
) {
companion object {
private const val TAG = "DictionaryCleaner"
}
suspend fun deleteOldFiles(
folderUri: Uri,
prefixes: Set<String>,
files: List<DocumentFileInfo>? = null
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val fileList = files ?: fileStorageManager.listFilesInFolder(folderUri)
val toDelete = computeNamesToDelete(fileList.map { it.name }, prefixes)
fileList.asSequence()
.filter { it.name in toDelete }
.forEach { file -> ArchiveUtils.deleteFile(context, file.uri) }
Result.success(Unit)
} catch (e: Exception) {
android.util.Log.e(TAG, "Error deleting old files", e)
Result.failure(e)
}
}
internal fun computeNamesToDelete(fileNames: List<String>, prefixes: Set<String>): Set<String> {
val parsed = fileNames.mapNotNull { parseManagedName(it, prefixes) }
if (parsed.isEmpty()) return emptySet()
val parsedByPrefix = parsed.groupBy { it.prefix }
val dslDatesByPrefix = parsed
.filter { it.ext == "dsl" || it.ext == "dsl.dz" || it.ext == "dsl.gz" }
.groupBy { it.prefix }
.mapValues { (_, items) -> items.map { it.date }.toSet() }
val latestDslDateByPrefix = dslDatesByPrefix.mapValues { (_, dates) ->
dates.maxWithOrNull { d1, d2 ->
when {
DateUtils.isDateBefore(d1, d2) -> -1
DateUtils.isDateBefore(d2, d1) -> 1
else -> 0
}
}
}
val toDelete = mutableSetOf<String>()
for ((prefix, items) in parsedByPrefix) {
val latestDsl = latestDslDateByPrefix[prefix] ?: continue
val dslDates = dslDatesByPrefix[prefix].orEmpty()
for (item in items) {
if (DateUtils.isDateBefore(item.date, latestDsl)) {
toDelete.add(item.name)
continue
}
if ((item.ext == "idx" || item.ext == "dsl.idx") && item.date != latestDsl) {
toDelete.add(item.name)
continue
}
if ((item.ext == "idx" || item.ext == "dsl.idx") && item.date !in dslDates) {
toDelete.add(item.name)
}
}
}
return toDelete
}
private data class ManagedFileName(
val name: String,
val prefix: String,
val date: String,
val ext: String,
)
private val validExtensions = setOf("dsl", "dsl.dz", "dsl.gz", "gz", "idx", "dsl.idx")
private fun parseManagedName(name: String, prefixes: Set<String>): ManagedFileName? {
val ext = when {
name.endsWith(".dsl.dz") -> "dsl.dz"
name.endsWith(".dsl.gz") -> "dsl.gz"
name.endsWith(".dsl.idx") -> "dsl.idx"
else -> name.substringAfterLast('.', "")
}
if (ext !in validExtensions) return null
val prefix = prefixes.firstOrNull { name.startsWith(it + "_") } ?: return null
val date = DateUtils.extractDateFromFileName(name) ?: return null
return ManagedFileName(name = name, prefix = prefix, date = date, ext = ext)
}
suspend fun deleteUnprocessedFiles(folderUri: Uri, files: List<DocumentFileInfo>? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val fileList = files ?: fileStorageManager.listFilesInFolder(folderUri)
val toDelete = fileList.filter { file ->
val name = file.name
val lowerName = name.lowercase()
val hasInvalidExtension = lowerName.endsWith("${DictionaryConfig.GZ_EXTENSION}${DictionaryConfig.GZ_EXTENSION}") ||
lowerName.endsWith("${DictionaryConfig.DSL_EXTENSION}.txt") ||
lowerName.endsWith("${DictionaryConfig.GZ_EXTENSION}.txt")
val isUnprocessedGz = lowerName.endsWith(DictionaryConfig.GZ_EXTENSION) &&
!lowerName.endsWith("${DictionaryConfig.DSL_EXTENSION}${DictionaryConfig.GZ_EXTENSION}") &&
!lowerName.endsWith("${DictionaryConfig.GZ_EXTENSION}${DictionaryConfig.GZ_EXTENSION}")
val isCorruptedDsl = lowerName.endsWith(DictionaryConfig.DSL_EXTENSION) && isFileEmptyOrCorrupted(file.uri)
hasInvalidExtension || isUnprocessedGz || isCorruptedDsl
}
toDelete.forEach { file ->
ArchiveUtils.deleteFile(context, file.uri)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deleteCorruptedArchives(folderUri: Uri, files: List<DocumentFileInfo>? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val fileList = files ?: fileStorageManager.listFilesInFolder(folderUri)
fileList.filter { file ->
val name = file.name
if (!name.endsWith(DictionaryConfig.GZ_EXTENSION)) return@filter false
val documentFile = fileStorageManager.getDocumentFile(file.uri)
?.takeIf { it.exists() } ?: return@filter false
val fileLength = documentFile.length()
fileLength == 0L || isArchiveCorrupted(file.uri, fileLength)
}.forEach { file ->
ArchiveUtils.deleteFile(context, file.uri)
}
Result.success(Unit)
} catch (e: Exception) {
android.util.Log.e(TAG, "Error deleting corrupted archives", e)
Result.failure(e)
}
}
suspend fun performAllCleanup(folderUri: Uri, prefixes: Set<String>, files: List<DocumentFileInfo>? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val fileList = files ?: fileStorageManager.listFilesInFolder(folderUri)
deleteCorruptedArchives(folderUri, fileList).getOrDefault(Unit)
deleteUnprocessedFiles(folderUri, fileList).getOrDefault(Unit)
deleteOldFiles(folderUri, prefixes, fileList).getOrDefault(Unit)
Result.success(Unit)
} catch (e: Exception) {
android.util.Log.e(TAG, "Error performing all cleanup", e)
Result.failure(e)
}
}
private suspend fun isArchiveCorrupted(uri: Uri, fileLength: Long): Boolean = withContext(Dispatchers.IO) {
if (fileLength < 100) {
return@withContext true
}
try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext true
inputStream.use { stream ->
val header = ByteArray(2)
val bytesRead = stream.read(header)
if (bytesRead < 2) {
return@withContext true
}
val gzipMagic = (header[0].toInt() and 0xFF) == 0x1F && (header[1].toInt() and 0xFF) == 0x8B
!gzipMagic
}
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to check archive corruption: ${e.message}")
true
}
}
private suspend fun isFileEmptyOrCorrupted(uri: Uri): Boolean = withContext(Dispatchers.IO) {
try {
val file = fileStorageManager.getDocumentFile(uri)
file != null && file.exists() && file.length() == 0L
} catch (_: Exception) {
true
}
}
}
@@ -0,0 +1,330 @@
package com.example.research.feature.download.repository
import android.content.Context
import android.net.Uri
import android.util.Log
import com.example.research.R
import com.example.research.common.util.DateUtils
import com.example.research.common.util.FileStorageManager
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.util.isHttp2StreamResetError
import com.example.research.core.util.isNetworkError
import com.example.research.core.util.isSslHandshakeError
import com.example.research.core.util.isTimeoutError
import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.CancellationException
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class HttpException(val statusCode: Int, message: String) : IOException(message)
class DictionaryDownloader(
private val context: Context,
private val client: OkHttpClient,
private val fileStorageManager: FileStorageManager
) {
companion object {
private const val TAG = "DictionaryDownloader"
private const val DEFAULT_MAX_RETRIES = 3
private val RETRY_DELAY_BASE = 1.seconds
private val MAX_RETRY_DELAY = 10.seconds
private val FILE_CREATION_RETRY_DELAY = 200.milliseconds
}
private suspend fun deletePartialFileSafely(
fileUri: Uri?,
folderUri: Uri,
fileName: String
) {
if (fileUri == null) return
runCatching {
val fileToDelete = fileStorageManager.getDocumentFile(fileUri)
if (fileToDelete != null && fileToDelete.exists()) {
val deleted = fileStorageManager.forceDeleteFile(fileToDelete, fileName)
if (!deleted) {
fileStorageManager.forceDeleteFileByName(folderUri, fileName)
}
}
}.onFailure { e ->
Log.e(TAG, "Failed to delete partial file: $fileUri", e)
runCatching {
fileStorageManager.forceDeleteFileByName(folderUri, fileName)
}.onFailure { deleteError ->
Log.e(TAG, "Failed to delete file by name: $fileName", deleteError)
}
}
}
suspend fun downloadDictionaries(
folderUri: Uri,
sources: List<DictionarySource>,
onProgress: (Float) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val enabledSources = sources.filter { it.isEnabled }
if (enabledSources.isEmpty()) {
onProgress(1f)
return@withContext Result.success(Unit)
}
val currentDate = DateUtils.getCurrentDateString()
val fallbackDate = DateUtils.getPreviousDateString()
val progressMap = mutableMapOf<String, Float>()
fun updateOverallProgress() {
val total = progressMap.values.sum() / enabledSources.size
onProgress(total)
}
val downloads = enabledSources.map { source ->
async {
downloadSourceWithDateFallback(
source = source,
folderUri = folderUri,
currentDate = currentDate,
fallbackDate = fallbackDate,
onProgress = { progress ->
progressMap[source.id] = progress
updateOverallProgress()
}
)
}
}
val results = downloads.awaitAll()
val firstFailure = results.firstOrNull { it.isFailure }
if (firstFailure != null) {
val exception = firstFailure.exceptionOrNull()
?: return@withContext Result.failure(IllegalStateException("Unknown download failure"))
return@withContext Result.failure(exception)
}
onProgress(1f)
Result.success(Unit)
}
private suspend fun downloadSourceWithDateFallback(
source: DictionarySource,
folderUri: Uri,
currentDate: String,
fallbackDate: String,
onProgress: (Float) -> Unit
): Result<Uri> = withContext(Dispatchers.IO) {
val hasDatePlaceholder = DictionarySource.hasDatePlaceholder(source.urlTemplate)
val currentUrl = DictionarySource.buildUrl(source.urlTemplate, currentDate)
val currentFileName = DictionarySource.extractFileName(source.urlTemplate, currentDate)
val currentResult = downloadFile(
url = currentUrl,
folderUri = folderUri,
fileName = currentFileName,
onProgress = onProgress
)
if (currentResult.isSuccess) {
return@withContext currentResult
}
val error = currentResult.exceptionOrNull()
if (hasDatePlaceholder && error is HttpException && error.statusCode == 404) {
val fallbackUrl = DictionarySource.buildUrl(source.urlTemplate, fallbackDate)
val fallbackFileName = DictionarySource.extractFileName(source.urlTemplate, fallbackDate)
return@withContext downloadFile(
url = fallbackUrl,
folderUri = folderUri,
fileName = fallbackFileName,
onProgress = onProgress
)
}
currentResult
}
private suspend fun downloadFile(
url: String,
folderUri: Uri,
fileName: String,
onProgress: (Float) -> Unit
): Result<Uri> = withContext(Dispatchers.IO) {
deletePartialFileIfExists(folderUri, fileName)
downloadFileWithRetry(url, folderUri, fileName, onProgress, maxRetries = DEFAULT_MAX_RETRIES)
}
private suspend fun deletePartialFileIfExists(folderUri: Uri, fileName: String) {
withContext(Dispatchers.IO) {
runCatching {
val existingFile = fileStorageManager.findFile(folderUri, fileName)
if (existingFile != null && existingFile.exists()) {
fileStorageManager.forceDeleteFile(existingFile, fileName)
}
val tempBinFile = fileStorageManager.findFile(folderUri, "$fileName.bin")
if (tempBinFile != null && tempBinFile.exists()) {
fileStorageManager.forceDeleteFile(tempBinFile, "$fileName.bin")
}
}.onFailure { e ->
Log.w(TAG, "Failed to delete partial file: ${e.message}")
}
}
}
private suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
}
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) { _, _, _ ->
response.close()
}
}
override fun onFailure(call: Call, e: IOException) {
if (!continuation.isCancelled) {
continuation.resumeWithException(e)
}
}
})
}
private suspend fun downloadFileWithRetry(
url: String,
folderUri: Uri,
fileName: String,
onProgress: (Float) -> Unit,
maxRetries: Int,
currentRetry: Int = 0
): Result<Uri> = withContext(Dispatchers.IO) {
var response: Response? = null
var outputStream: java.io.OutputStream? = null
var fileUri: Uri? = null
var call: Call? = null
try {
ensureActive()
val request = Request.Builder()
.url(url)
.header("Cache-Control", "no-cache, no-store, must-revalidate")
.build()
call = client.newCall(request)
response = call.await()
if (!response.isSuccessful) {
response.close()
val errorMessage = when (response.code) {
404 -> context.getString(R.string.download_file_not_found)
403 -> context.getString(R.string.download_file_forbidden)
500, 502, 503, 504 -> context.getString(R.string.download_server_error)
else -> context.getString(R.string.download_file_error, response.code)
}
return@withContext Result.failure(HttpException(response.code, errorMessage))
}
ensureActive()
val body = response.body
val contentLength = body.contentLength()
fileUri = fileStorageManager.createFileInFolder(folderUri, fileName, overwrite = true)
if (fileUri == null) {
response.close()
fileStorageManager.forceDeleteFileByName(folderUri, fileName)
delay(FILE_CREATION_RETRY_DELAY)
fileUri = fileStorageManager.createFileInFolder(folderUri, fileName, overwrite = true)
if (fileUri == null) {
return@withContext Result.failure(IOException(context.getString(R.string.download_failed_create_file, fileName)))
}
}
outputStream = context.contentResolver.openOutputStream(fileUri, "wt")
?: run {
response.close()
return@withContext Result.failure(IOException(context.getString(R.string.download_failed_open_output_stream, fileName)))
}
outputStream.use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(DictionaryConfig.BUFFER_SIZE)
var totalBytesRead = 0L
var bytesRead: Int
var lastReportedProgressPercent = -1
var lastCheckBytes = 0L
val checkInterval = 256 * 1024L
while (input.read(buffer).also { bytesRead = it } != -1) {
ensureActive()
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) {
val progress = totalBytesRead.toFloat() / contentLength
val progressPercent = (progress * 100).toInt()
if (progressPercent != lastReportedProgressPercent || progressPercent >= 100) {
onProgress(progress)
lastReportedProgressPercent = progressPercent
}
}
if (totalBytesRead - lastCheckBytes >= checkInterval) {
yield()
lastCheckBytes = totalBytesRead
}
}
}
}
response.close()
val file = fileStorageManager.getDocumentFile(fileUri)
if (file != null && file.exists()) {
val fileLength = file.length()
if (fileLength == 0L) {
file.delete()
return@withContext Result.failure(IOException(context.getString(R.string.download_file_empty)))
}
if (contentLength > 0 && fileLength < contentLength) {
file.delete()
return@withContext Result.failure(IOException(context.getString(R.string.download_file_incomplete)))
}
}
Result.success(fileUri)
} catch (e: Exception) {
call?.cancel()
response?.close()
outputStream?.close()
if (e is CancellationException) {
deletePartialFileIfExists(folderUri, fileName)
deletePartialFileSafely(fileUri, folderUri, fileName)
throw e
}
if (e.isNetworkError() && currentRetry < maxRetries) {
deletePartialFileIfExists(folderUri, fileName)
deletePartialFileSafely(fileUri, folderUri, fileName)
val retryDelay = (RETRY_DELAY_BASE * (1 shl currentRetry))
.coerceAtMost(MAX_RETRY_DELAY)
delay(retryDelay)
return@withContext downloadFileWithRetry(url, folderUri, fileName, onProgress, maxRetries, currentRetry + 1)
}
Log.e(TAG, "Download failed for $url", e)
deletePartialFileIfExists(folderUri, fileName)
deletePartialFileSafely(fileUri, folderUri, fileName)
val errorMessage = when {
e.isTimeoutError() ->
context.getString(R.string.download_timeout)
e.isSslHandshakeError() ->
context.getString(R.string.download_connection_timeout)
e.isHttp2StreamResetError() ->
context.getString(R.string.download_connection_reset)
e is SecurityException ->
context.getString(R.string.download_no_write_permission)
else ->
context.getString(R.string.download_error)
}
Result.failure(IOException(errorMessage, e))
}
}
}
@@ -0,0 +1,225 @@
package com.example.research.feature.download.repository
import android.content.Context
import android.net.Uri
import android.os.SystemClock
import android.util.Log
import com.example.research.common.util.ArchiveUtils
import com.example.research.common.util.DocumentFileInfo
import com.example.research.common.util.FileStorageManager
import com.example.research.data.dictzip.DictZipRandomAccessFile
import com.example.research.data.dictzip.DictZipWriter
import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DictionaryExtractor(
private val context: Context,
private val fileStorageManager: FileStorageManager
) {
suspend fun extractArchives(
folderUri: Uri,
files: List<DocumentFileInfo>? = null,
onProgress: ((Float) -> Unit)? = null
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val fileList = files ?: fileStorageManager.listFilesInFolder(folderUri)
val gzFiles = fileList.filter {
it.name.lowercase().endsWith(DictionaryConfig.GZ_EXTENSION)
}
val rawDslFiles = fileList.filter {
val lowerName = it.name.lowercase()
lowerName.endsWith(DictionaryConfig.DSL_EXTENSION) &&
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!lowerName.endsWith(".dsl.gz")
}
val totalFiles = gzFiles.size + rawDslFiles.size
var processedIndex = 0
fun reportProgress(progress: Float) {
if (totalFiles > 0) {
onProgress?.invoke(progress.coerceIn(0f, 1f))
}
}
fun markFileProcessed() {
processedIndex++
reportProgress(processedIndex.toFloat() / totalFiles)
}
for (file in gzFiles) {
val fileName = file.name
val baseName = fileName.dropLast(DictionaryConfig.GZ_EXTENSION.length)
val lowerBaseName = baseName.lowercase()
if (baseName.isBlank() ||
lowerBaseName.endsWith(DictionaryConfig.GZ_EXTENSION) ||
lowerBaseName.endsWith(".txt")) {
ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed()
continue
}
val dslBaseName = if (lowerBaseName.endsWith(DictionaryConfig.DSL_EXTENSION)) {
baseName.dropLast(DictionaryConfig.DSL_EXTENSION.length)
} else {
baseName
}
val dslFileName = "$dslBaseName${DictionaryConfig.DSL_EXTENSION}"
val existingDslFile = fileStorageManager.findFile(folderUri, dslFileName)
if (existingDslFile != null && existingDslFile.exists()) {
ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed()
continue
}
val dslUri = fileStorageManager.createFileInFolder(folderUri, dslFileName, overwrite = false)
?: continue
val extractResult = ArchiveUtils.extractGzWithBom(context, file.uri, dslUri)
if (extractResult.isSuccess) {
val extractedFile = fileStorageManager.getDocumentFile(dslUri)
if (extractedFile != null && extractedFile.exists()) {
if (extractedFile.length() == 0L) {
ArchiveUtils.deleteFile(context, dslUri)
ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed()
continue
}
val fileBaseProgress = processedIndex.toFloat() / totalFiles
val fileProgressWeight = 1f / totalFiles
reportProgress(fileBaseProgress)
val compressResult = ensureDslDictZip(dslUri, dslFileName, folderUri) { compressProgress ->
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
}
if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, dslUri)
}
}
ArchiveUtils.deleteFile(context, file.uri)
} else {
Log.e("DictionaryExtractor", "File extraction error: $fileName")
ArchiveUtils.deleteFile(context, dslUri)
}
markFileProcessed()
}
for (file in rawDslFiles) {
val dslFileName = file.name
val dzFileName = "$dslFileName.dz"
val existingDz = fileStorageManager.findFile(folderUri, dzFileName)
if (existingDz != null && existingDz.exists()) {
ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed()
continue
}
val fileBaseProgress = processedIndex.toFloat() / totalFiles
val fileProgressWeight = 1f / totalFiles
reportProgress(fileBaseProgress)
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri) { compressProgress ->
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
}
if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, file.uri)
}
markFileProcessed()
}
// Final safety pass: ensure every remaining .dsl in the folder has a valid .dsl.dz pair.
val remainingFiles = fileStorageManager.listFilesInFolder(folderUri)
val remainingRawDslFiles = remainingFiles.filter {
val lowerName = it.name.lowercase()
lowerName.endsWith(DictionaryConfig.DSL_EXTENSION) &&
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!lowerName.endsWith(".dsl.gz")
}
for (file in remainingRawDslFiles) {
val dslFileName = file.name
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri, onProgress = null)
if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, file.uri)
}
}
onProgress?.invoke(1f)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
private suspend fun ensureDslDictZip(
dslUri: Uri,
dslFileName: String,
folderUri: Uri,
onProgress: ((Float) -> Unit)? = null
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val dzFileName = "$dslFileName.dz"
val existingDz = fileStorageManager.findFile(folderUri, dzFileName)
if (existingDz != null && existingDz.exists()) {
val existingDzPath = existingDz.uri.path
val isValid = existingDzPath != null && try {
DictZipRandomAccessFile.isValidDictZip(existingDzPath)
} catch (e: Exception) {
Log.w("DictionaryExtractor", "Failed to validate DictZip for $dzFileName: ${e.message}")
false
}
if (isValid) {
return@withContext Result.success(Unit)
} else {
ArchiveUtils.deleteFile(context, existingDz.uri)
}
}
val dzUri = fileStorageManager.createFileInFolder(folderUri, dzFileName, overwrite = false)
?: return@withContext Result.failure(Exception("Failed to create .dz file"))
val inputStream = context.contentResolver.openInputStream(dslUri)
?: return@withContext Result.failure(Exception("Failed to open input stream"))
val result = inputStream.use { input ->
val outputStream = context.contentResolver.openOutputStream(dzUri, "wt")
?: return@withContext Result.failure(Exception("Failed to open output stream"))
val dslFile = fileStorageManager.getDocumentFile(dslUri)
val fileSize = dslFile?.length() ?: 0L
var lastReportedPercent = -1
var lastLoggedPercent = -25
var lastProgressTimeMs = 0L
outputStream.use { output ->
DictZipWriter.compressStream(
input = input,
output = output,
chunkSize = DictZipWriter.DEFAULT_CHUNK_SIZE,
totalSize = fileSize,
filename = dslFileName,
onProgress = { progress ->
val currentPercent = (progress * 100).toInt()
val nowMs = SystemClock.elapsedRealtime()
val shouldReportProgress = currentPercent >= 100 ||
currentPercent >= lastReportedPercent + 5 ||
nowMs - lastProgressTimeMs >= 250L
if (shouldReportProgress) {
lastReportedPercent = currentPercent
lastProgressTimeMs = nowMs
onProgress?.invoke(progress)
}
if (currentPercent >= 100 || currentPercent >= lastLoggedPercent + 25) {
lastLoggedPercent = currentPercent
}
}
)
}
}
if (result.isSuccess) {
Result.success(Unit)
} else {
ArchiveUtils.deleteFile(context, dzUri)
Result.failure(result.exceptionOrNull() ?: Exception("Compression failed"))
}
} catch (e: Exception) {
Log.e("DictionaryExtractor", "Error compressing to DictZip", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,206 @@
package com.example.research.feature.download.repository
import android.content.Context
import android.net.Uri
import android.util.Log
import java.io.File
import com.example.research.core.domain.model.DictionarySource
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.common.util.FileStorageManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
class DictionaryRepository(
context: Context,
private val preferencesManager: PreferencesManager,
private val fileStorageManager: FileStorageManager,
client: OkHttpClient
) {
private val checker = DictionaryChecker(fileStorageManager, client)
private val downloader = DictionaryDownloader(context, client, fileStorageManager)
private val extractor = DictionaryExtractor(context, fileStorageManager)
private val cleaner = DictionaryCleaner(context, fileStorageManager)
private var filesBeforeDownload = mutableSetOf<String>()
private var downloadedPrefixes = mutableSetOf<String>()
companion object {
private const val TAG = "DictionaryRepository"
}
suspend fun areDictionariesUpToDate(
sources: List<DictionarySource>? = null
): Boolean = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first()
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
}
suspend fun hasEnabledSources(): Boolean = withContext(Dispatchers.IO) {
preferencesManager.dictionarySources.first().any { it.isEnabled }
}
suspend fun downloadDictionaries(
onProgress: (Float) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload = files.map { it.name }.toMutableSet()
}
val sources = preferencesManager.dictionarySources.first()
val enabledSources = sources.filter { it.isEnabled }
synchronized(downloadedPrefixes) {
downloadedPrefixes = enabledSources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries(
folderUri = uri,
sources = enabledSources,
onProgress = onProgress
)
}
suspend fun downloadSpecificSources(
sourceUrls: List<String>,
onProgress: (Float) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload = files.map { it.name }.toMutableSet()
}
val allSources = preferencesManager.dictionarySources.first()
val normalizedSourceUrls = sourceUrls.map { DictionarySource.normalizeTemplate(it) }
val sourcesToDownload = allSources.filter { it.urlTemplate in normalizedSourceUrls && it.isEnabled }
if (sourcesToDownload.isEmpty()) {
return@withContext Result.success(Unit)
}
synchronized(downloadedPrefixes) {
downloadedPrefixes = sourcesToDownload.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries(
folderUri = uri,
sources = sourcesToDownload,
onProgress = onProgress
)
}
suspend fun extractArchives(
onProgress: ((Float) -> Unit)? = null
): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
extractor.extractArchives(uri, files, onProgress)
}
suspend fun deleteUnprocessedFiles(): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteUnprocessedFiles(uri, files)
val filesAfterStandardCleanup = fileStorageManager.listFilesInFolder(uri)
val existingFiles = synchronized(filesBeforeDownload) {
filesBeforeDownload.toSet()
}
val prefixes = synchronized(downloadedPrefixes) {
downloadedPrefixes.toSet()
}
val finalPrefixes = prefixes.ifEmpty {
val sources = preferencesManager.dictionarySources.first()
sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
}
val filesToDelete = if (existingFiles.isEmpty()) {
filesAfterStandardCleanup.filter { file ->
val fileName = file.name
finalPrefixes.any { prefix -> fileName.startsWith("${prefix}_") }
}
} 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")
} else {
deletedCount++
}
}
}
}
}
synchronized(filesBeforeDownload) {
if (filesBeforeDownload.isNotEmpty()) {
filesBeforeDownload.clear()
}
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
Result.success(Unit)
}
suspend fun performAllCleanup(): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
val sources = preferencesManager.dictionarySources.first()
val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
val result = cleaner.performAllCleanup(uri, prefixes, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload.clear()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
result
}
}
@@ -0,0 +1,305 @@
package com.example.research.feature.download.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import androidx.core.app.ServiceCompat
import com.example.research.ReSearchApplication
import com.example.research.common.progress.renderTitle
import com.example.research.common.util.NotificationHelper
import com.example.research.core.util.OperationResult
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class DictionaryForegroundService : Service() {
companion object {
const val ACTION_START = "com.example.research.START_DOWNLOAD"
const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD"
const val ACTION_IMPORT = "com.example.research.START_IMPORT"
private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID
}
private lateinit var downloadManager: DownloadManager
private lateinit var localDictionaryRepository: LocalDictionaryRepository
private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager
private lateinit var progressStateHolder: com.example.research.common.progress.DictionaryProgressStateHolder
private val notificationHelper by lazy {
NotificationHelper(this)
}
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@Volatile
private var isHandlingSuccess = false
@Volatile
private var latestStartId = 0
override fun onCreate() {
super.onCreate()
val app = application as? ReSearchApplication
?: throw IllegalStateException("Application must be ReSearchApplication")
downloadManager = app.downloadManager
localDictionaryRepository = app.localDictionaryRepository
dictionaryImportManager = app.dictionaryImportManager
progressStateHolder = app.dictionaryProgressStateHolder
observeDownloadState()
observeImportState()
observeUnifiedProgress()
}
private fun observeDownloadState() {
serviceScope.launch {
downloadManager.downloadState.collect { state ->
when (state) {
is DownloadState.Success -> handleSuccess()
is DownloadState.Error -> {
notificationHelper.showErrorNotification()
stopForegroundService(removeNotification = false)
}
is DownloadState.Cancelled -> {
notificationHelper.cancelNotification()
stopForegroundService()
}
else -> {}
}
}
}
}
private fun observeImportState() {
serviceScope.launch {
dictionaryImportManager.importState.collect { state ->
when (state) {
is com.example.research.ui.settings.ImportState.Success -> handleSuccess()
is com.example.research.ui.settings.ImportState.Error -> {
notificationHelper.showErrorNotification()
stopForegroundService(removeNotification = false)
}
else -> {}
}
}
}
}
private fun observeUnifiedProgress() {
serviceScope.launch {
progressStateHolder.progressSnapshot
.collect { snapshot ->
if (snapshot != null) {
notificationHelper.showUnifiedProgressNotification(
title = snapshot.renderTitle(this@DictionaryForegroundService),
contentText = "${snapshot.percent}%",
progressPercent = snapshot.percent,
)
}
}
}
}
private suspend fun triggerReindexing(isImportFlow: Boolean) = withContext(Dispatchers.IO) {
try {
waitForIndexingCompletion()
delay(1.seconds)
val app = application as? ReSearchApplication
?: return@withContext
val path = app.preferencesManager.dictionaryPath
if (isImportFlow) {
dictionaryImportManager.updateExtractionProgress(0f)
}
app.downloadDictionaryRepository.extractArchives(onProgress = { progress ->
if (isImportFlow) {
dictionaryImportManager.updateExtractionProgress(progress)
}
})
val result = localDictionaryRepository.scanDirectory(path)
if (result is OperationResult.Success && result.data > 0) {
localDictionaryRepository.warmupIndexes()
app.downloadDictionaryRepository.performAllCleanup()
}
if (isImportFlow) {
dictionaryImportManager.markImportPipelineSuccess()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e("DictionaryForegroundService", "triggerReindexing() failed: ${e.message}", e)
}
}
private suspend fun waitForIndexingCompletion(): Boolean {
val isIndexing = localDictionaryRepository.indexingProgress.first().isIndexing
if (!isIndexing) {
return true
}
var waitCount = 0
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < 100) {
delay(200.milliseconds)
waitCount++
}
return waitCount < 100
}
private fun handleSuccess() {
if (isHandlingSuccess) return
isHandlingSuccess = true
serviceScope.launch {
try {
val isImportFlow = dictionaryImportManager.importState.value !is com.example.research.ui.settings.ImportState.Idle
if (isImportFlow) {
val app = application as? ReSearchApplication
val path = app?.preferencesManager?.dictionaryPath
val dictionariesDir = path?.let { java.io.File(it) }
val filesBeforeReindex = dictionariesDir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
try {
triggerReindexing(true)
dictionaryImportManager.getAndClearImportedFiles()
} catch (e: CancellationException) {
val filesToCleanup = dictionaryImportManager.getAndClearImportedFiles()
filesToCleanup.forEach { file ->
try { if (file.exists()) file.delete() } catch (_: Exception) { /* Ignore cleanup failure */ }
}
if (dictionariesDir != null) {
cleanupNewFiles(dictionariesDir, filesBeforeReindex)
}
throw e
}
} else {
triggerReindexing(false)
}
if (isImportFlow) {
notificationHelper.showImportSuccessNotification()
dictionaryImportManager.clearImportState()
} else {
notificationHelper.showSuccessNotification()
}
} catch (e: Exception) {
if (e !is CancellationException) {
Log.e("DownloadForegroundService", "handleSuccess() failed: ${e.message}", e)
notificationHelper.showErrorNotification()
}
} finally {
isHandlingSuccess = false
stopForegroundService(removeNotification = false)
}
}
}
private fun cleanupNewFiles(dir: java.io.File, filesBeforeSnapshot: Set<String>) {
try {
dir.listFiles()?.forEach { file ->
if (file.name !in filesBeforeSnapshot) {
try { file.delete() } catch (_: Exception) { /* Ignore delete failure */ }
}
}
} catch (_: Exception) { /* Ignore directory listing failure */ }
}
private fun startForegroundService() {
val notification = notificationHelper.createForegroundNotification()
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
0
)
}
}
private fun stopForegroundService(removeNotification: Boolean = true) {
val stopFlag = if (removeNotification) {
ServiceCompat.STOP_FOREGROUND_REMOVE
} else {
ServiceCompat.STOP_FOREGROUND_DETACH
}
ServiceCompat.stopForeground(this, stopFlag)
if (latestStartId != 0) {
stopSelfResult(latestStartId)
} else {
stopSelf()
}
}
override fun onTimeout(startId: Int) {
stopForTimeout(startId)
}
override fun onTimeout(startId: Int, fgsType: Int) {
stopForTimeout(startId)
}
private fun stopForTimeout(startId: Int) {
serviceScope.coroutineContext[Job]?.cancelChildren()
if (localDictionaryRepository.isIndexingInProgress()) {
localDictionaryRepository.cancelIndexing()
}
downloadManager.cancelDownload()
dictionaryImportManager.cancelImport()
notificationHelper.cancelNotification()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelfResult(startId)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
latestStartId = startId
when (intent?.action) {
ACTION_START -> {
startForegroundService()
val sourceUrls = intent.getStringArrayListExtra("source_urls")
if (!sourceUrls.isNullOrEmpty()) {
downloadManager.startDownloadForSources(sourceUrls)
} else {
downloadManager.startDownload()
}
}
ACTION_IMPORT -> {
startForegroundService()
}
ACTION_STOP -> {
try {
startForegroundService()
} catch (_: Exception) {
// Service may already be stopped
}
serviceScope.coroutineContext[Job]?.cancelChildren()
if (localDictionaryRepository.isIndexingInProgress()) {
localDictionaryRepository.cancelIndexing()
}
downloadManager.cancelDownload()
dictionaryImportManager.cancelImport()
notificationHelper.cancelNotification()
stopForegroundService()
}
}
return START_NOT_STICKY
}
override fun onDestroy() {
serviceScope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,194 @@
package com.example.research.feature.import
import android.app.Application
import android.net.Uri
import com.example.research.R
import com.example.research.common.util.SafeFileName
import com.example.research.core.performance.ReSearchTrace
import com.example.research.ui.settings.ImportState
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.ensureActive
import java.io.File
import java.util.Collections
class DictionaryImportManager(
private val application: Application
) {
private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle)
val importState: StateFlow<ImportState> = mutableImportState.asStateFlow()
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var importJob: Job? = null
private val importedFiles = Collections.synchronizedList(mutableListOf<File>())
fun importDictionaries(uris: List<Uri>) {
importJob?.cancel()
importJob = managerScope.launch {
mutableImportState.value = ImportState.Idle
performImport(uris)
}
}
private suspend fun performImport(uris: List<Uri>) =
ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_IMPORT) {
val context = application
val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries")
val totalFiles = uris.size.coerceAtLeast(1)
importedFiles.clear()
if (!dictionariesDir.exists()) {
dictionariesDir.mkdirs()
}
try {
for ((index, uri) in uris.withIndex()) {
currentCoroutineContext().ensureActive()
val fileName = getFileName(uri) ?: continue
if (SafeFileName.validate(fileName) == null) {
mutableImportState.value = ImportState.Error(
context.getString(R.string.import_invalid_file_name, fileName)
)
continue
}
val lowerFileName = fileName.lowercase()
if (!lowerFileName.endsWith(".dsl") &&
!lowerFileName.endsWith(".dsl.dz") &&
!lowerFileName.endsWith(".gz")
) continue
val fileIndex = index + 1
val fileWeightBase = index.toFloat() / totalFiles
mutableImportState.value = ImportState.Importing(
progress = fileWeightBase,
)
val destFile = File(dictionariesDir, fileName)
if (destFile.exists()) {
mutableImportState.value = ImportState.Error(
context.getString(R.string.import_file_exists, fileName)
)
continue
}
try {
withContext(Dispatchers.IO) {
currentCoroutineContext().ensureActive()
val fileSize = getFileSize(uri)
var copiedBytes = 0L
var lastProgress = -1f
context.contentResolver.openInputStream(uri)?.use { inputStream ->
destFile.outputStream().use { outputStream ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
currentCoroutineContext().ensureActive()
outputStream.write(buffer, 0, bytesRead)
copiedBytes += bytesRead
val fileProgress = if (fileSize > 0L) {
(copiedBytes.toFloat() / fileSize).coerceIn(0f, 1f)
} else {
0f
}
val overallProgress = (index + fileProgress) / totalFiles.toFloat()
if (overallProgress - lastProgress >= 0.01f || fileProgress >= 1f) {
mutableImportState.value = ImportState.Importing(
progress = overallProgress.coerceIn(0f, 1f),
)
lastProgress = overallProgress
}
}
}
} ?: throw IllegalStateException("Cannot open input stream for $uri")
}
importedFiles.add(destFile)
mutableImportState.value = ImportState.Importing(
progress = (fileIndex.toFloat() / totalFiles).coerceIn(0f, 1f),
)
} catch (e: Exception) {
destFile.takeIf { it.exists() }?.delete()
throw e
}
}
currentCoroutineContext().ensureActive()
mutableImportState.value = ImportState.Success
} catch (e: CancellationException) {
cleanupImportedFiles()
mutableImportState.value = ImportState.Idle
throw e
} catch (e: Exception) {
mutableImportState.value = ImportState.Error(
context.getString(R.string.import_error, e.message ?: "Unknown error")
)
}
}
private fun getFileName(uri: Uri): String? {
var fileName: String? = null
application.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0) {
fileName = cursor.getString(nameIndex)
}
}
}
return fileName
}
private fun getFileSize(uri: Uri): Long {
var size = -1L
application.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
size = cursor.getLong(sizeIndex)
}
}
}
return size
}
fun clearImportState() {
mutableImportState.value = ImportState.Idle
}
fun updateExtractionProgress(progress: Float) {
mutableImportState.update { current ->
if (current is ImportState.Idle || current is ImportState.Error) current
else ImportState.Extracting(progress.coerceIn(0f, 1f))
}
}
fun markImportPipelineSuccess() {
mutableImportState.update { current ->
if (current is ImportState.Idle || current is ImportState.Error) current
else ImportState.Success
}
}
fun cancelImport() {
importJob?.cancel()
importJob = null
cleanupImportedFiles()
mutableImportState.value = ImportState.Idle
}
fun getAndClearImportedFiles(): List<File> {
val files = importedFiles.toList()
importedFiles.clear()
return files
}
private fun cleanupImportedFiles() {
for (file in importedFiles) {
try { if (file.exists()) file.delete() } catch (_: Exception) { /* Ignore cleanup failure */ }
}
importedFiles.clear()
}
}
@@ -0,0 +1,308 @@
package com.example.research.feature.search
import android.app.Application
import android.util.Log
import androidx.lifecycle.*
import androidx.paging.*
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.OperationResult
import com.example.research.core.util.onError
import com.example.research.core.util.onSuccess
import com.example.research.core.util.sanitizeQuery
import com.example.research.data.paging.IndexEntryPagingSource
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.ui.article.ArticleState
import com.example.research.ui.article.ArticleTextWarmUp
import com.example.research.ui.article.DslBlock
import com.example.research.ui.article.DslAnnotatedParser
import com.example.research.R
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.time.Duration.Companion.milliseconds
import androidx.lifecycle.SavedStateHandle
sealed interface SearchAction {
data class QueryChanged(val query: String) : SearchAction
data object ClearQuery : SearchAction
data class ArticleClicked(val entry: IndexEntry) : SearchAction
data object ClearArticle : SearchAction
data class SearchAndLoadArticle(val word: String) : SearchAction
data class HandleExternalSearch(val text: String) : SearchAction
}
class SearchViewModel(
application: Application,
private val repository: LocalDictionaryRepository,
private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
private val mutableSearchQuery = MutableStateFlow(
savedStateHandle.get<String>(KEY_SEARCH_QUERY) ?: ""
)
val searchQuery: StateFlow<String> = mutableSearchQuery.asStateFlow()
private val mutableSelectedArticle = MutableStateFlow<ArticleState?>(null)
val selectedArticle: StateFlow<ArticleState?> = mutableSelectedArticle.asStateFlow()
private val mutableScrollPosition = MutableStateFlow(0 to 0)
val scrollPosition: StateFlow<Pair<Int, Int>> = mutableScrollPosition.asStateFlow()
private val mutableErrorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow()
private val indexSearcher by lazy { repository.getIndexSearcher() }
private var indexWarmupJob: Job? = null
private var debounceSaveQueryJob: Job? = null
private val parseCache = java.util.LinkedHashMap<String, List<DslBlock>>(PARSE_CACHE_SIZE, 0.75f, true)
private val articleHistory = ArrayDeque<ArticleState.Success>()
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
repository.dictionaries.map { dictList ->
dictList.filter { it.isActive }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
init {
repository.dictionaries
.map { dicts ->
dicts
.filter { it.isActive && it.articleCount > 0 }
.map { it.indexPath }
}
.distinctUntilChanged()
.onEach { indexPaths ->
if (indexPaths.isNotEmpty()) {
warmupIndexesInBackground()
}
}
.launchIn(viewModelScope)
}
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val searchResults: Flow<PagingData<IndexEntry>> = combine(
searchQuery.debounce(300.milliseconds).distinctUntilChanged(),
activeDictionaries
) { query, dictionaries ->
query to dictionaries
}.flatMapLatest { (query, dictionaries) ->
if (query.isBlank()) {
flowOf(PagingData.empty())
} else {
Pager(
config = PagingConfig(
pageSize = 12,
prefetchDistance = 1,
enablePlaceholders = false,
initialLoadSize = 12,
jumpThreshold = 1
),
pagingSourceFactory = {
IndexEntryPagingSource(
indexSearcher = indexSearcher,
dictionaries = dictionaries,
query = query
)
}
).flow
}
}.cachedIn(viewModelScope)
fun onAction(action: SearchAction) {
when (action) {
is SearchAction.QueryChanged -> onSearchQueryChanged(action.query)
SearchAction.ClearQuery -> {
mutableSearchQuery.value = ""
savedStateHandle[KEY_SEARCH_QUERY] = ""
}
is SearchAction.ArticleClicked -> {
articleHistory.clear()
loadArticle(action.entry)
}
SearchAction.ClearArticle -> clearArticle()
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
}
}
fun onSearchQueryChanged(query: String) {
val sanitized = query.sanitizeQuery().trim()
mutableSearchQuery.value = sanitized
// Debounce SavedStateHandle writes to reduce disk I/O
debounceSaveQueryJob?.cancel()
debounceSaveQueryJob = viewModelScope.launch {
delay(500.milliseconds) // Wait 500ms after last keystroke
savedStateHandle[KEY_SEARCH_QUERY] = sanitized
}
}
private fun warmupIndexesInBackground() {
indexWarmupJob?.cancel()
indexWarmupJob = viewModelScope.launch(Dispatchers.Default) {
repository.warmupIndexes()
}
}
fun clearError() {
mutableErrorMessage.value = null
}
fun saveScrollPosition(index: Int, offset: Int) {
mutableScrollPosition.value = index to offset
}
fun updateArticleScrollPosition(index: Int, offset: Int) {
val current = mutableSelectedArticle.value as? ArticleState.Success ?: return
mutableSelectedArticle.value = current.copy(
scrollIndex = index,
scrollOffset = offset
)
}
fun loadArticle(entry: IndexEntry) {
viewModelScope.launch {
mutableSelectedArticle.update {
ArticleState.Loading(entry.originalWord)
}
val cacheKey = "${entry.dictionaryPath}:${entry.offset.value}:${entry.length.value}"
val cached = synchronized(parseCache) { parseCache[cacheKey] }
if (cached != null) {
mutableSelectedArticle.update {
ArticleState.Success(
title = entry.originalWord,
dslContent = cached,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
}
return@launch
}
repository.getArticleFromEntry(entry)
.onSuccess { dslContent ->
val annotatedStringList = withContext(Dispatchers.Default) {
DslAnnotatedParser.parse(
dsl = dslContent,
colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME,
)
}
synchronized(parseCache) {
if (parseCache.size >= PARSE_CACHE_SIZE) {
val iterator = parseCache.entries.iterator()
if (iterator.hasNext()) {
iterator.next().run { iterator.remove() }
}
}
parseCache[cacheKey] = annotatedStringList
}
viewModelScope.launch(Dispatchers.Default) {
ArticleTextWarmUp.warmUp(getApplication(), annotatedStringList)
}
mutableSelectedArticle.update {
ArticleState.Success(
title = entry.originalWord,
dslContent = annotatedStringList,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
}
}
.onError { message, exception ->
Log.e(TAG, "loadArticle() - error: $message", exception)
mutableSelectedArticle.update {
ArticleState.Error(
title = entry.originalWord,
message = getApplication<Application>().getString(R.string.error_loading_article, message.take(20))
)
}
}
}
}
fun clearArticle() {
articleHistory.clear()
mutableSelectedArticle.value = null
}
fun clearCaches() {
synchronized(parseCache) {
parseCache.clear()
}
DslAnnotatedParser.clearRefCache()
}
override fun onCleared() {
// Ensure query is persisted before ViewModel is destroyed
debounceSaveQueryJob?.cancel()
savedStateHandle[KEY_SEARCH_QUERY] = mutableSearchQuery.value
clearCaches()
}
fun navigateBackFromArticle(): Boolean {
val previous = articleHistory.removeLastOrNull() ?: return false
mutableSelectedArticle.value = previous
return true
}
fun searchAndLoadArticle(word: String) {
viewModelScope.launch {
val sanitizedWord = word.sanitizeQuery()
val currentArticle = mutableSelectedArticle.value as? ArticleState.Success
val sourceDictionaryPath = currentArticle
?.dictionaryPath
?.takeIf(String::isNotBlank)
var result = sourceDictionaryPath
?.let { path -> repository.searchInDictionary(path, sanitizedWord) }
?: repository.search(sanitizedWord)
if (sourceDictionaryPath != null &&
result.exactEntry(sanitizedWord) == null
) {
result = repository.search(sanitizedWord)
}
result.onSuccess { results ->
val exactEntry = results.firstOrNull {
it.originalWord.equals(sanitizedWord, ignoreCase = true)
}
val entry = if (sourceDictionaryPath != null) {
exactEntry
} else {
exactEntry ?: results.firstOrNull()
}
entry?.let {
currentArticle?.let(articleHistory::addLast)
loadArticle(it)
}
}.onError { message, exception ->
Log.e(TAG, "searchAndLoadArticle() - error: $message", exception)
}
}
}
fun handleExternalSearch(text: String) {
viewModelScope.launch {
val sanitizedText = text.sanitizeQuery()
mutableSearchQuery.value = sanitizedText
searchAndLoadArticle(sanitizedText)
}
}
companion object {
private const val TAG = "SearchViewModel"
// Parse cache: stores parsed DslBlock lists to avoid re-parsing articles
// Size: 50 articles - typical user navigation depth in a session
// Memory estimate: ~50KB per article average (text + formatting)
// Total: ~2.5MB worst case
private const val PARSE_CACHE_SIZE = 50
private const val KEY_SEARCH_QUERY = "search_query"
}
}
private fun OperationResult<List<IndexEntry>>.exactEntry(word: String): IndexEntry? =
(this as? OperationResult.Success)
?.data
?.firstOrNull { it.originalWord.equals(word, ignoreCase = true) }
@@ -0,0 +1,83 @@
package com.example.research.ui.about
import android.content.Context
import androidx.annotation.RawRes
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
internal data class AboutLibrariesData(
val libraries: List<AboutLibrary> = emptyList(),
val licenses: Map<String, AboutLicense> = emptyMap(),
)
@Serializable
internal data class AboutLibrary(
val uniqueId: String,
val artifactVersion: String? = null,
val name: String,
val description: String? = null,
val website: String? = null,
val developers: List<AboutDeveloper> = emptyList(),
val organization: AboutOrganization? = null,
val scm: AboutScm? = null,
val licenses: List<String> = emptyList(),
) {
val author: String = run {
val developerNames = developers
.asSequence()
.map(AboutDeveloper::name)
.filter(String::isNotBlank)
.distinct()
.filterNot { it == GENERIC_ANDROID_AUTHOR }
.joinToString()
developerNames.ifBlank {
organization?.name
?.takeUnless { it == GENERIC_ANDROID_AUTHOR }
.orEmpty()
}
}
private companion object {
private const val GENERIC_ANDROID_AUTHOR = "The Android Open Source Project"
}
}
@Serializable
internal data class AboutDeveloper(
val name: String = "",
)
@Serializable
internal data class AboutOrganization(
val name: String = "",
)
@Serializable
internal data class AboutScm(
val url: String? = null,
)
@Serializable
internal data class AboutLicense(
val name: String,
val url: String? = null,
val content: String? = null,
@SerialName("spdxId")
val spdxId: String? = null,
)
internal object AboutLibrariesParser {
private val json = Json {
ignoreUnknownKeys = true
}
fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData =
context.resources.openRawResource(resourceId)
.bufferedReader()
.use { reader -> json.decodeFromString(reader.readText()) }
fun decode(source: String): AboutLibrariesData = json.decodeFromString(source)
}
@@ -0,0 +1,319 @@
package com.example.research.ui.about
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AboutScreen(
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val packageInfo = remember(context) {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
}
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
val uriHandler = LocalUriHandler.current
val librariesData = remember(context) {
AboutLibrariesParser.read(context, R.raw.aboutlibraries)
}
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.about_app_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
contentWindowInsets = AppWindowInsets.fullScreen
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.testTag("about_libraries_list")
.semantics { testTagsAsResourceId = true }
.padding(padding)
) {
item(key = "about_header") {
AboutHeader(versionName = versionName)
}
items(
items = librariesData.libraries,
key = AboutLibrary::uniqueId,
) { library ->
AboutLibraryRow(
library = library,
licenses = librariesData.licenses,
expanded = expandedLibraryId == library.uniqueId,
onToggle = {
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
null
} else {
library.uniqueId
}
},
onLicenseClick = { dialogLicense = it },
)
}
}
}
dialogLicense?.let { license ->
LicenseDialog(
license = license,
onDismiss = { dialogLicense = null },
onOpenWebsite = { url -> uriHandler.openUri(url) },
)
}
}
@Composable
private fun AboutHeader(versionName: String) {
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.version_format, versionName),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedChoiceButton(
text = stringResource(R.string.website),
onClick = { uriHandler.openUri("https://oneway.asia/") },
fillWidth = false
)
OutlinedChoiceButton(
text = stringResource(R.string.changelog),
onClick = { uriHandler.openUri("https://git.oneway.asia/OneWay/ReSearch") },
fillWidth = false
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AboutLibraryRow(
library: AboutLibrary,
licenses: Map<String, AboutLicense>,
expanded: Boolean,
onToggle: () -> Unit,
onLicenseClick: (AboutLicense) -> Unit,
) {
val uriHandler = LocalUriHandler.current
val website = library.website?.takeIf(String::isNotBlank)
val source = library.scm?.url?.takeIf(String::isNotBlank)
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = if (expanded) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surface
},
onClick = onToggle
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp)
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = library.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
library.artifactVersion?.takeIf(String::isNotBlank)?.let { version ->
Text(
text = version,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
if (library.author.isNotBlank()) {
Text(
text = library.author,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
library.description?.takeIf(String::isNotBlank)?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
if (library.licenses.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
library.licenses.forEach { licenseId ->
val license = licenses[licenseId] ?: return@forEach
OutlinedChoiceButton(
text = license.name,
onClick = { onLicenseClick(license) },
fillWidth = false,
highlighted = true,
enforceMinimumInteractiveSize = false,
modifier = Modifier.testTag("about_license_button")
)
}
}
}
if (expanded && (website != null || source != null)) {
FlowRow(
modifier = Modifier
.padding(top = 2.dp)
.testTag("about_library_expanded_actions")
.semantics { testTagsAsResourceId = true },
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
source?.let { url ->
OutlinedChoiceButton(
text = stringResource(R.string.source),
onClick = { uriHandler.openUri(url) },
fillWidth = false,
enforceMinimumInteractiveSize = false
)
}
website?.let { url ->
OutlinedChoiceButton(
text = stringResource(R.string.website),
onClick = { uriHandler.openUri(url) },
fillWidth = false,
enforceMinimumInteractiveSize = false
)
}
}
}
}
}
}
@Composable
private fun LicenseDialog(
license: AboutLicense,
onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(license.name) },
text = {
Text(
text = license.content ?: stringResource(R.string.license_text_unavailable),
modifier = Modifier.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_close))
}
},
dismissButton = {
license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website))
}
}
}
)
}
@@ -0,0 +1,253 @@
package com.example.research.ui.article
import android.accessibilityservice.AccessibilityServiceInfo
import android.view.accessibility.AccessibilityManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.core.content.getSystemService
import com.example.research.BuildConfig
internal val LocalForceStockArticleLinks = compositionLocalOf { false }
internal val ArticleReferenceAtOffsetAction =
SemanticsPropertyKey<(Int) -> Boolean>("ArticleReferenceAtOffsetAction")
internal var SemanticsPropertyReceiver.articleReferenceAtOffsetAction
by ArticleReferenceAtOffsetAction
internal data class ArticleReferenceTouchTarget(
val word: String,
val center: Offset,
)
internal val ArticleReferenceTouchTargets =
SemanticsPropertyKey<List<ArticleReferenceTouchTarget>>("ArticleReferenceTouchTargets")
internal var SemanticsPropertyReceiver.articleReferenceTouchTargets
by ArticleReferenceTouchTargets
@Composable
internal fun ArticleLinkText(
block: DslBlock,
linksEnabled: Boolean,
style: TextStyle,
onRefClick: (String) -> Unit,
onTextLayout: (TextLayoutResult) -> Unit,
modifier: Modifier = Modifier,
preferDirectHitTesting: Boolean = false,
) {
val accessibilityEnabled = rememberAssistiveAccessibilityEnabled()
val inputMode = LocalInputModeManager.current.inputMode
val forceStockLinks = LocalForceStockArticleLinks.current
val useDirectLinkHitTesting =
linksEnabled &&
block.lazyRefs.isNotEmpty() &&
(preferDirectHitTesting ||
block.lazyRefs.size >= DIRECT_LINK_HIT_TEST_THRESHOLD) &&
!accessibilityEnabled &&
inputMode != InputMode.Keyboard &&
!forceStockLinks
val currentOnRefClick by rememberUpdatedState(onRefClick)
val layoutResult = remember { arrayOfNulls<TextLayoutResult>(1) }
var debugTouchTargets by remember(block) {
mutableStateOf(emptyList<ArticleReferenceTouchTarget>())
}
// Use stable key for remember to avoid unnecessary recomposition
val text = if (linksEnabled && !useDirectLinkHitTesting) {
remember(block, System.identityHashCode(onRefClick)) {
DslAnnotatedParser.applyLazyRefs(block, onRefClick)
}
} else {
block.text
}
val linkModifier = if (useDirectLinkHitTesting) {
Modifier.pointerInput(block) {
detectReferenceTaps(
block = block,
layoutResult = { layoutResult[0] },
onRefClick = { currentOnRefClick(it) },
)
}
} else {
Modifier
}
val debugSemanticsModifier =
if (BuildConfig.DEBUG && linksEnabled && !forceStockLinks && block.lazyRefs.isNotEmpty()) {
Modifier.semantics {
articleReferenceAtOffsetAction = { offset ->
DslAnnotatedParser.referenceAt(block, offset)
?.also { currentOnRefClick(it.word) } != null
}
articleReferenceTouchTargets = debugTouchTargets
}
} else {
Modifier
}
BasicText(
text = text,
style = style,
onTextLayout = { result ->
layoutResult[0] = result
if (BuildConfig.DEBUG && linksEnabled && !forceStockLinks) {
val targets = block.lazyRefs
.mapNotNull { reference ->
reference.startIndex
.takeIf { it in result.layoutInput.text.indices }
?.let { offset ->
ArticleReferenceTouchTarget(
word = reference.word,
center = result.getBoundingBox(offset).center,
)
}
}
if (debugTouchTargets != targets) {
debugTouchTargets = targets
}
}
onTextLayout(result)
},
modifier = modifier
.then(linkModifier)
.then(debugSemanticsModifier),
)
}
@Composable
private fun rememberAssistiveAccessibilityEnabled(): Boolean {
val context = LocalContext.current
val manager = remember(context) { context.getSystemService<AccessibilityManager>() }
var enabled by remember(manager) {
mutableStateOf(manager?.hasAssistiveServiceEnabled() == true)
}
DisposableEffect(manager) {
if (manager == null) {
onDispose { }
} else {
val updateState = {
enabled = manager.hasAssistiveServiceEnabled()
}
val accessibilityListener =
AccessibilityManager.AccessibilityStateChangeListener {
updateState()
}
val touchExplorationListener =
AccessibilityManager.TouchExplorationStateChangeListener {
updateState()
}
manager.addAccessibilityStateChangeListener(accessibilityListener)
manager.addTouchExplorationStateChangeListener(touchExplorationListener)
updateState()
onDispose {
manager.removeAccessibilityStateChangeListener(accessibilityListener)
manager.removeTouchExplorationStateChangeListener(touchExplorationListener)
}
}
}
return enabled
}
private fun AccessibilityManager.hasAssistiveServiceEnabled(): Boolean =
isTouchExplorationEnabled ||
getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK).isNotEmpty()
private suspend fun PointerInputScope.detectReferenceTaps(
block: DslBlock,
layoutResult: () -> TextLayoutResult?,
onRefClick: (String) -> Unit,
) {
awaitEachGesture {
val down = awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Final,
)
val pointerId = down.id
val downPosition = down.position
val downTime = down.uptimeMillis
var canceled = false
var tapPosition: Offset? = null
while (true) {
val event = awaitPointerEvent(PointerEventPass.Final)
val change = event.changes.firstOrNull { it.id == pointerId }
if (change == null) {
break
}
if (change.isConsumed ||
(change.position - downPosition).getDistance() > viewConfiguration.touchSlop
) {
canceled = true
}
if (!change.pressed) {
if (!canceled &&
change.uptimeMillis - downTime < viewConfiguration.longPressTimeoutMillis
) {
tapPosition = change.position
}
break
}
}
val position = tapPosition ?: return@awaitEachGesture
val result = layoutResult() ?: return@awaitEachGesture
result.referenceAtPosition(block, position, viewConfiguration.touchSlop / 2f)
?.let { onRefClick(it.word) }
}
}
private fun TextLayoutResult.referenceAtPosition(
block: DslBlock,
position: Offset,
hitSlop: Float,
): DslAnnotatedParser.LazyRef? {
if (layoutInput.text.isEmpty() ||
position.x < 0f ||
position.y < 0f ||
position.x > size.width ||
position.y > size.height
) {
return null
}
val text = layoutInput.text
val cursor = getOffsetForPosition(position)
for (candidate in intArrayOf(cursor, cursor - 1)) {
if (candidate < 0 || candidate > text.lastIndex) continue
val glyphStart = if (text[candidate].isLowSurrogate()) candidate - 1 else candidate
if (glyphStart < 0) continue
val reference = DslAnnotatedParser.referenceAt(block, glyphStart) ?: continue
val bounds = getBoundingBox(glyphStart)
if (position.x >= bounds.left - hitSlop &&
position.x <= bounds.right + hitSlop &&
position.y >= bounds.top - hitSlop &&
position.y <= bounds.bottom + hitSlop
) {
return reference
}
}
return null
}
private const val DIRECT_LINK_HIT_TEST_THRESHOLD = 100
@@ -0,0 +1,301 @@
package com.example.research.ui.article
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R
import com.example.research.feature.search.SearchAction
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
fun ArticleContent(
articleState: ArticleState?,
modifier: Modifier = Modifier,
onNavigateBack: (() -> Unit)? = null,
onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) {
when (articleState) {
is ArticleState.Loading -> {
Box(modifier = modifier.fillMaxSize())
}
is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
)
val indentStep = 16.dp
// Use scroll position from ArticleState if available (for back navigation)
val initialScrollIndex = articleState.scrollIndex
val initialScrollOffset = articleState.scrollOffset
val savedScrollIndex = rememberSaveable(articleSaveKey) {
mutableIntStateOf(initialScrollIndex)
}
val savedScrollOffset = rememberSaveable(articleSaveKey) {
mutableIntStateOf(initialScrollOffset)
}
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = savedScrollIndex.intValue,
initialFirstVisibleItemScrollOffset = savedScrollOffset.intValue,
)
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
val currentOnRefClick = rememberUpdatedState(onRefClick)
val currentOnScrollPositionChanged = rememberUpdatedState(onScrollPositionChanged)
val refClickHandler = remember {
{ word: String ->
currentOnRefClick.value?.invoke(word)
Unit
}
}
LaunchedEffect(listState, articleSaveKey) {
var sawScrollInProgress = false
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
if (isScrolling) {
sawScrollInProgress = true
} else if (sawScrollInProgress) {
val index = listState.firstVisibleItemIndex
val offset = listState.firstVisibleItemScrollOffset
savedScrollIndex.intValue = index
savedScrollOffset.intValue = offset
currentOnScrollPositionChanged.value?.invoke(index, offset)
sawScrollInProgress = false
}
}
}
LaunchedEffect(viewportSize, articleSaveKey) {
if (viewportSize != IntSize.Zero &&
(savedScrollIndex.intValue != 0 ||
savedScrollOffset.intValue != 0)
) {
withFrameNanos { }
listState.scrollToItem(
savedScrollIndex.intValue,
savedScrollOffset.intValue,
)
}
}
val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
.testTag("article_content")
.semantics {
testTagsAsResourceId = true
}
) {
itemsIndexed(
items = articleState.dslContent,
key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index"
},
contentType = { _, _ -> "article_block" }
) { index, block ->
val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable {
Box(
modifier = Modifier
.padding(
start = Spacing.iconStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) {
ArticleLinkText(
block = block,
linksEnabled = true,
preferDirectHitTesting = isHeavyArticle,
style = baseTextStyle,
onRefClick = refClickHandler,
onTextLayout = {},
modifier = Modifier
.testTag("article_block_$index")
.semantics {
testTagsAsResourceId = true
}
)
}
}
if (isHeavyArticle) {
textContent()
} else {
SelectionContainer { textContent() }
}
}
}
}
is ArticleState.Error -> {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = articleState.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onNavigateBack ?: {}) {
Text(stringResource(R.string.article_return_to_search))
}
}
}
null -> {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.article_no_selected),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
@Composable
fun ArticleTitleBar(
articleState: ArticleState?,
showBackButton: Boolean,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier
) {
val title = when (articleState) {
is ArticleState.Loading -> articleState.title
is ArticleState.Success -> articleState.title
is ArticleState.Error -> articleState.title
null -> stringResource(R.string.article_no_selected)
}
val backIcon = painterResource(R.drawable.ic_arrow_back)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.padding(
top = Spacing.topBarPadding,
bottom = Spacing.topBarPadding
)
.height(Spacing.topBarHeight)
) {
if (showBackButton) {
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.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)
)
}
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
.testTag("article_title")
.semantics { testTagsAsResourceId = true }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ArticleScreen(
articleState: ArticleState?,
onNavigateBack: () -> Unit,
onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) {
Scaffold(
topBar = {
ArticleTitleBar(
articleState = articleState,
showBackButton = true,
onNavigateBack = onNavigateBack
)
},
contentWindowInsets = AppWindowInsets.contentWithNavigation
) { padding ->
ArticleContent(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = onRefClick,
onScrollPositionChanged = onScrollPositionChanged,
modifier = Modifier.padding(padding)
)
}
}
@@ -0,0 +1,46 @@
package com.example.research.ui.article
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
/**
* A parsed DSL block with its text content and indent level.
* indent = 0 means no indent ([m1] or no m-tag),
* indent = 1 means single indent ([m2] or [m3]),
* indent = 2 means double indent ([m4] or [m5]).
*
* continuesPrevious marks a chunk produced by splitting an oversized source
* block: it belongs to the same logical paragraph as the block before it, so
* the UI renders them without inter-block spacing.
*/
@Immutable
data class DslBlock(
val text: AnnotatedString,
val indent: Int = 0,
val lazyRefs: List<DslAnnotatedParser.LazyRef> = emptyList(),
val continuesPrevious: Boolean = false,
)
/**
* Sealed class representing the state of article display.
* Success state is marked as @Immutable for Compose optimization.
*/
@Stable
sealed class ArticleState {
@Immutable
data class Loading(val title: String) : ArticleState()
@Immutable
data class Success(
val title: String,
val dslContent: List<DslBlock>,
val dictionaryName: String = "",
val dictionaryPath: String = "",
val scrollIndex: Int = 0,
val scrollOffset: Int = 0,
) : ArticleState()
@Immutable
data class Error(
val title: String,
val message: String
) : ArticleState()
}
@@ -0,0 +1,42 @@
package com.example.research.ui.article
import android.content.Context
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.example.research.common.ui.theme.Typography
import com.example.research.core.performance.ReSearchTrace
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100
object ArticleTextWarmUp {
suspend fun warmUp(context: Context, blocks: List<DslBlock>) {
if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT) return
val density = Density(context)
val measurer = TextMeasurer(
defaultFontFamilyResolver = createFontFamilyResolver(context),
defaultDensity = density,
defaultLayoutDirection = LayoutDirection.Ltr,
cacheSize = 0,
)
val widthPx = with(density) { WARM_UP_WIDTH.toPx().toInt() }
ReSearchTrace.asyncSection(ReSearchTrace.ARTICLE_PRE_MEASURE) {
blocks.forEach { block ->
currentCoroutineContext().ensureActive()
measurer.measure(
text = block.text,
style = Typography.bodyLarge,
constraints = Constraints(maxWidth = widthPx),
)
}
}
}
private val WARM_UP_WIDTH = 360.dp
}
@@ -0,0 +1,352 @@
package com.example.research.ui.article
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.example.research.core.performance.ReSearchTrace
/**
* DSL parser that keeps reference targets separate from rendered text.
*
* References are stored as lazy refs (word + char range) without creating LinkAnnotation objects.
* LinkAnnotation objects are created only when blocks become visible, reducing initial render cost.
*/
object DslAnnotatedParser {
// Pre-allocated set for O(1) tag validation
private val VALID_TAGS = setOf(
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
)
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
data class ColorScheme(
val secondaryText: Color,
val labelText: Color,
val linkText: Color,
)
data class LazyRef(
val word: String,
val startIndex: 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) {
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
}
fun parse(
dsl: String,
colorScheme: ColorScheme = defaultColorScheme(),
): List<DslBlock> = ReSearchTrace.section(ReSearchTrace.ARTICLE_PARSE) {
val result = mutableListOf<DslBlock>()
var builder = AnnotatedString.Builder()
val stack = ArrayDeque<SpanStyle>()
val refStack = ArrayDeque<Pair<Int, StringBuilder>>()
val lazyRefs = mutableListOf<LazyRef>()
var lastChar: Char? = null
var accentNext = false
var currentIndent = 0
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText)
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
fun push(style: SpanStyle) {
builder.pushStyle(style)
stack.addLast(style)
}
fun pop() {
if (stack.isNotEmpty()) {
builder.pop()
stack.removeLast()
}
}
fun flushBlock() {
if (builder.length > 0) {
result.add(DslBlock(
text = builder.toAnnotatedString(),
indent = currentIndent,
lazyRefs = lazyRefs.toList()
))
builder = AnnotatedString.Builder()
stack.forEach { builder.pushStyle(it) }
lazyRefs.clear()
}
}
fun marginIndent(tagName: String): Int {
if (tagName == "m" || tagName == "m0" || tagName == "m1") return 0
val margin = tagName.drop(1).toIntOrNull() ?: return 0
return margin / 2
}
var i = 0
val length = dsl.length
while (i < length) {
when (val char = dsl[i]) {
'\\' -> {
if (i + 1 < length) {
val next = dsl[i + 1]
if (next in ESCAPED_CHARS) {
builder.append(next)
if (refStack.isNotEmpty()) {
refStack.last().second.append(next)
}
lastChar = next
i += 2
continue
}
}
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
i++
}
'[' -> {
val end = dsl.indexOf(']', i + 1)
if (end != -1) {
val tagStart = i + 1
var tagEnd = end
while (tagEnd > tagStart && dsl[tagEnd - 1].isWhitespace()) tagEnd--
val fullTag = dsl.substring(tagStart, tagEnd)
val isClosing = fullTag.isNotEmpty() && fullTag[0] == '/'
val tagWithParams = if (isClosing) fullTag.substring(1) else fullTag
val spaceIdx = tagWithParams.indexOfFirst { it == ' ' || it == '\t' }
val tagName = if (spaceIdx >= 0) tagWithParams.substring(0, spaceIdx) else tagWithParams
if (tagName == "*") {
i = end + 1
continue
}
if (tagName in VALID_TAGS) {
if (isClosing) {
if (tagName == "ref" && refStack.isNotEmpty()) {
val (refStart, refText) = refStack.removeLast()
lazyRefs.add(
LazyRef(
word = refText.toString(),
startIndex = refStart,
endIndex = builder.length,
)
)
} else if (tagName == "'") {
accentNext = false
}
pop()
} else {
when (tagName) {
"'" -> {
accentNext = true
push(SpanStyle())
}
"br" -> {
if (refStack.isEmpty()) {
flushBlock()
} else {
builder.append("\n")
}
lastChar = '\n'
}
"p" -> {
push(labelStyle)
}
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
if (lastChar != '\n' && lastChar != null) {
if (refStack.isEmpty()) flushBlock() else builder.append("\n")
}
lastChar = '\n'
currentIndent = marginIndent(tagName)
push(exampleStyle)
}
"i" -> push(SpanStyle(fontStyle = FontStyle.Italic))
"ref" -> {
push(linkStyle)
refStack.addLast(builder.length to StringBuilder())
}
"ex", "e" -> push(exampleStyle)
"c", "t", "trn", "lang", "sup" -> push(SpanStyle())
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
}
}
i = end + 1
continue
}
}
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
i++
}
else -> {
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
if (accentNext) {
builder.append('\u0301')
if (refStack.isNotEmpty()) {
refStack.last().second.append('\u0301')
}
accentNext = false
}
lastChar = char
i++
}
}
}
if (builder.length > 0) {
result.add(DslBlock(
text = builder.toAnnotatedString(),
indent = currentIndent,
lazyRefs = lazyRefs.toList()
))
}
splitOversizedBlocks(result)
}
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
return blocks.flatMap { block ->
if (block.text.length <= MAX_BLOCK_TEXT_LENGTH) listOf(block) else splitBlock(block)
}
}
private fun splitBlock(block: DslBlock): List<DslBlock> {
val text = block.text
val refs = block.lazyRefs
val cuts = mutableListOf<Int>()
var chunkStart = 0
var i = 0
while (i < text.length) {
if (text[i] == '\n' &&
i - chunkStart >= TARGET_CHUNK_TEXT_LENGTH &&
referenceAt(block, i) == null
) {
cuts.add(i)
chunkStart = i + 1
}
i++
}
if (cuts.isEmpty()) return listOf(block)
val chunks = ArrayList<DslBlock>(cuts.size + 1)
var start = 0
var refFrom = 0
fun addChunk(end: Int) {
var refTo = refFrom
while (refTo < refs.size && refs[refTo].startIndex < end) refTo++
chunks.add(
DslBlock(
text = text.subSequence(start, end),
indent = block.indent,
lazyRefs = refs.subList(refFrom, refTo).map {
it.copy(startIndex = it.startIndex - start, endIndex = it.endIndex - start)
},
continuesPrevious = start > 0,
)
)
refFrom = refTo
}
for (cut in cuts) {
addChunk(cut)
start = cut + 1
}
if (start < text.length) addChunk(text.length)
return chunks
}
fun applyLazyRefs(
block: DslBlock,
onRefClick: (String) -> Unit
): AnnotatedString {
if (block.lazyRefs.isEmpty()) return block.text
// Use block and onRefClick hashCode as cache key
val cacheKey = block to System.identityHashCode(onRefClick)
refCache[cacheKey]?.let { return it }
// Build AnnotatedString outside of cache lock
val builder = AnnotatedString.Builder(block.text)
val listener = LinkInteractionListener { link ->
val tag = (link as? LinkAnnotation.Clickable)?.tag ?: ""
if (tag.isNotEmpty()) onRefClick(tag)
}
block.lazyRefs.groupBy { it.word }.forEach { (word, refs) ->
val annotation = LinkAnnotation.Clickable(
tag = word,
linkInteractionListener = listener,
)
refs.forEach { ref ->
builder.addLink(annotation, ref.startIndex, ref.endIndex)
}
}
val result = builder.toAnnotatedString()
// LruCache handles eviction automatically
refCache.put(cacheKey, result)
return result
}
fun referenceAt(block: DslBlock, offset: Int): LazyRef? {
var low = 0
var high = block.lazyRefs.lastIndex
while (low <= high) {
val middle = (low + high).ushr(1)
val reference = block.lazyRefs[middle]
when {
offset < reference.startIndex -> high = middle - 1
offset >= reference.endIndex -> low = middle + 1
else -> return reference
}
}
return null
}
fun defaultColorScheme(): ColorScheme {
return ColorScheme(
secondaryText = Color.Unspecified,
labelText = Color(0xFF9E9E9E),
linkText = Color(0xFF1976D2)
)
}
val ARTICLE_COLOR_SCHEME = ColorScheme(
secondaryText = Color.Unspecified,
labelText = Color(0xFF4CAF50),
linkText = Color(0xFF1E88E5),
)
// Allow external cache clearing (e.g., when dictionaries change)
fun clearRefCache() {
refCache.evictAll()
}
private const val MAX_BLOCK_TEXT_LENGTH = 600
private const val TARGET_CHUNK_TEXT_LENGTH = 400
}
@@ -0,0 +1,135 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.*
import com.example.research.core.domain.model.IndexEntry
import com.example.research.ui.article.ArticleContent
import com.example.research.ui.article.ArticleTitleBar
import com.example.research.ui.article.ArticleState
import com.example.research.ui.main.components.MainTopBar
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMainScreen(
searchState: TextFieldState,
canSearch: Boolean,
isLandscape: Boolean,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
articleState: ArticleState?,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
onNavigateToSettings: () -> Unit,
onArticleClick: (IndexEntry) -> Unit,
onNavigateBackFromArticle: () -> Unit,
onRefClick: (String) -> Unit,
) {
var scaffoldState by remember {
mutableStateOf(
MutableThreePaneScaffoldState(
ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
)
)
}
val isQueryEmpty = searchState.text.isEmpty()
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
val isTwoPane =
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
Column(modifier = Modifier.fillMaxSize()) {
MainTopBar(
searchState = searchState,
enabled = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
onNavigateToSettings = onNavigateToSettings
)
ListDetailPaneScaffold(
directive = PaneScaffoldDirective.Default.copy(
maxHorizontalPartitions = 2,
defaultPanePreferredWidth = if (isLandscape) 260.dp else 300.dp,
),
scaffoldState = scaffoldState,
modifier = Modifier
.weight(1f)
.consumeWindowInsets(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
),
listPane = {
SearchResultsPane(
searchState = searchState,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onAction,
onArticleClick = onArticleClick,
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
)
.padding(start = 32.dp, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
)
},
detailPane = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 16.dp)
) {
ArticleTitleBar(
articleState = articleState,
showBackButton = isShowingDetail,
onNavigateBack = onNavigateBackFromArticle,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
ArticleContent(
articleState = articleState,
onNavigateBack = onNavigateBackFromArticle,
onRefClick = onRefClick,
)
}
},
)
}
LaunchedEffect(articleState, isQueryEmpty) {
val targetValue = when {
articleState == null -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
isQueryEmpty -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Hidden,
tertiary = PaneAdaptedValue.Hidden,
)
else -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
}
scaffoldState.animateTo(targetValue)
}
}
@@ -0,0 +1,8 @@
package com.example.research.ui.main
sealed interface MainAction {
data class QueryChanged(val query: String) : MainAction
data object ClearQuery : MainAction
data object ClearError : MainAction
data class SaveScrollPosition(val index: Int, val offset: Int) : MainAction
}
@@ -0,0 +1,175 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.research.ui.theme.AppWindowInsets
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.core.domain.model.IndexEntry
import com.example.research.feature.download.config.DictionaryConfig
import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
import com.example.research.ui.main.components.MainTopBar
import com.example.research.ui.settings.SettingsViewModel
import com.example.research.ui.settings.ImportState
@Composable
fun MainRoute(
searchViewModel: SearchViewModel,
settingsViewModel: SettingsViewModel,
onNavigateToSettings: () -> Unit,
onArticleClick: (IndexEntry) -> Unit
) {
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val scrollPosition = remember(searchViewModel) {
searchViewModel.scrollPosition.value
}
val searchState = remember(searchViewModel) {
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) {
if (searchState.text.toString() != searchQuery) {
searchState.edit { replace(0, length, searchQuery) }
}
}
val canSearch = remember(settingsState) {
val hasSearchableDictionary = settingsState.dictionaries.any { dictionary ->
dictionary.isActive && dictionary.articleCount > 0
}
settingsState.downloadState is DownloadState.Idle &&
!settingsState.indexingProgress.isIndexing &&
settingsState.importState !is ImportState.Importing &&
settingsState.importState !is ImportState.Extracting &&
hasSearchableDictionary
}
val showDictionaryName = remember(settingsState) {
val activeDictionaries = settingsState.dictionaries.filter { it.isActive }
if (activeDictionaries.isEmpty()) {
false
} else {
activeDictionaries.any { dict ->
val fileName = dict.path.substringAfterLast("/")
!DictionaryConfig.isDabkrsDictionary(fileName)
}
}
}
BoxWithConstraints {
val isWide = maxWidth >= 600.dp
val isLandscape = maxWidth > maxHeight
if (isWide) {
AdaptiveMainScreen(
searchState = searchState,
canSearch = canSearch,
isLandscape = isLandscape,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
articleState = articleState,
scrollPosition = scrollPosition,
onAction = onMainAction,
onNavigateToSettings = onNavigateToSettings,
onArticleClick = { entry ->
searchViewModel.onAction(SearchAction.ArticleClicked(entry))
},
onNavigateBackFromArticle = {
if (!searchViewModel.navigateBackFromArticle()) {
searchViewModel.onAction(SearchAction.ClearArticle)
}
},
onRefClick = { word ->
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
},
)
} else {
MainScreen(
searchState = searchState,
canSearch = canSearch,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onMainAction,
onArticleClick = onArticleClick,
onNavigateToSettings = onNavigateToSettings
)
}
}
}
@Composable
internal fun MainScreen(
searchState: TextFieldState,
canSearch: Boolean,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
onArticleClick: (IndexEntry) -> Unit,
onNavigateToSettings: () -> Unit
) {
Scaffold(
topBar = {
MainTopBar(
searchState = searchState,
enabled = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
onNavigateToSettings = onNavigateToSettings
)
},
contentWindowInsets = AppWindowInsets.navigationOnly
) { paddingValues ->
SearchResultsPane(
searchState = searchState,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onAction,
onArticleClick = onArticleClick,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = 32.dp, end = 16.dp),
)
}
}
@@ -0,0 +1,130 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.example.research.core.domain.model.IndexEntry
import com.example.research.ui.main.components.ErrorMessageCard
import androidx.compose.foundation.lazy.rememberLazyListState
import com.example.research.ui.main.components.SearchResultsList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlin.time.Duration.Companion.milliseconds
@OptIn(FlowPreview::class)
@Composable
internal fun SearchResultsPane(
searchState: TextFieldState,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier,
listModifier: Modifier = Modifier,
) {
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition.first,
initialFirstVisibleItemScrollOffset = scrollPosition.second,
)
var restoredScrollPosition by remember { mutableStateOf(false) }
var pendingScrollToTop by remember { mutableStateOf(false) }
var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp()
}
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
.debounce(100.milliseconds)
.collect { (index, offset) ->
onAction(MainAction.SaveScrollPosition(index, offset))
}
}
LaunchedEffect(searchState, listState) {
snapshotFlow { searchState.text.toString() }
.distinctUntilChanged()
.drop(1)
.collectLatest { query ->
restoredScrollPosition = true
pendingScrollToTop = query.isNotBlank()
sawRefreshForPendingScroll = false
}
}
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
sawRefreshForPendingScroll = true
}
}
LaunchedEffect(
results.loadState.refresh,
pendingScrollToTop,
sawRefreshForPendingScroll,
listState,
) {
if (
pendingScrollToTop &&
sawRefreshForPendingScroll &&
results.loadState.refresh is LoadState.NotLoading
) {
snapshotFlow { listState.isScrollInProgress }.first { isScrollInProgress -> !isScrollInProgress }
listState.scrollToItem(0)
pendingScrollToTop = false
sawRefreshForPendingScroll = false
}
}
LaunchedEffect(results.loadState.refresh, restoredScrollPosition) {
if (!restoredScrollPosition && results.loadState.refresh is LoadState.NotLoading) {
if (scrollPosition.first > 0) {
listState.scrollToItem(scrollPosition.first, scrollPosition.second)
}
restoredScrollPosition = true
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
errorMessage?.let { message ->
ErrorMessageCard(
message = message,
onDismiss = { onAction(MainAction.ClearError) },
)
}
SearchResultsList(
results = results,
onArticleClick = onArticleClick,
modifier = listModifier.weight(1f),
listState = listState,
showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding),
)
}
}
@@ -0,0 +1,47 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.research.R
import androidx.compose.ui.res.stringResource
@Composable
fun ErrorMessageCard(
message: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_ok))
}
}
}
}
@@ -0,0 +1,179 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import com.example.research.ui.theme.AppWindowInsets
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.example.research.R
import kotlinx.coroutines.flow.distinctUntilChanged
private val TopBarHeight = 56.dp
private val TopBarShape = RoundedCornerShape(28.dp)
@Composable
fun MainTopBar(
searchState: TextFieldState,
enabled: Boolean,
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
onNavigateToSettings: () -> Unit
) {
val searchIcon = painterResource(R.drawable.ic_search)
val closeIcon = painterResource(R.drawable.ic_close)
val settingsIcon = painterResource(R.drawable.ic_settings)
val clearLabel = stringResource(R.string.clear_search)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
SearchField(
searchState = searchState,
enabled = enabled,
onQueryChange = onQueryChange,
onClearQuery = onClearQuery,
searchIcon = searchIcon,
closeIcon = closeIcon,
clearLabel = clearLabel,
modifier = Modifier.weight(1f)
)
FilledIconButton(
onClick = onNavigateToSettings,
modifier = Modifier
.size(TopBarHeight)
.testTag("settings_button")
.semantics { testTagsAsResourceId = true },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
painter = settingsIcon,
contentDescription = stringResource(R.string.settings_title),
modifier = Modifier.size(24.dp)
)
}
}
}
}
@Composable
private fun SearchField(
searchState: TextFieldState,
enabled: Boolean,
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
searchIcon: androidx.compose.ui.graphics.painter.Painter,
closeIcon: androidx.compose.ui.graphics.painter.Painter,
clearLabel: String,
modifier: Modifier = Modifier
) {
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
val contentColor = MaterialTheme.colorScheme.onSurface
val placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant
val currentOnQueryChange by rememberUpdatedState(onQueryChange)
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.distinctUntilChanged()
.collect { query ->
currentOnQueryChange(query)
}
}
Surface(
modifier = modifier.height(TopBarHeight),
color = containerColor,
shape = TopBarShape
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = searchIcon,
contentDescription = null,
tint = placeholderColor,
modifier = Modifier.size(24.dp)
)
BasicTextField(
state = searchState,
enabled = enabled,
textStyle = MaterialTheme.typography.titleLarge.copy(color = contentColor),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
lineLimits = TextFieldLineLimits.SingleLine,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.testTag("search_field")
.semantics { testTagsAsResourceId = true }
)
if (searchState.text.isNotEmpty()) {
IconButton(
onClick = {
searchState.edit { replace(0, length, "") }
onClearQuery()
},
modifier = Modifier
.size(40.dp)
.testTag("clear_search")
.semantics { testTagsAsResourceId = true }
) {
Icon(
painter = closeIcon,
contentDescription = clearLabel,
tint = placeholderColor,
modifier = Modifier.size(24.dp)
)
}
} else {
Box(modifier = Modifier.size(40.dp))
}
}
}
}
@@ -0,0 +1,121 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.*
import com.example.research.R
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes
import com.example.research.ui.theme.Spacing
@Composable
fun SearchResultsList(
results: LazyPagingItems<IndexEntry>,
onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
showDictionaryName: Boolean = true,
contentPadding: PaddingValues = PaddingValues()
) {
LazyColumn(
modifier = modifier
.testTag("search_results")
.semantics { testTagsAsResourceId = true },
state = listState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
count = results.itemCount,
key = results.itemKey { entry -> "${entry.dictionaryPath}_${entry.offset.value}_${entry.word}" },
contentType = results.itemContentType { "search_result" }
) { index ->
results[index]?.let { entry ->
SearchResultItem(
entry = entry,
resultIndex = index,
onArticleClick = onArticleClick,
showDictionaryName = showDictionaryName
)
}
}
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 -> {}
}
}
}
@Composable
private fun SearchResultItem(
entry: IndexEntry,
resultIndex: Int,
onArticleClick: (IndexEntry) -> Unit,
showDictionaryName: Boolean = true
) {
// Cache text styles to avoid recreation on every composition
val titleStyle = MaterialTheme.typography.titleLarge
val subtitleStyle = MaterialTheme.typography.bodySmall
val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
Column(
modifier = Modifier
.fillMaxWidth()
.testTag("search_result")
.semantics { testTagsAsResourceId = true }
.clickable { onArticleClick(entry) }
.padding(vertical = Spacing.listItemVertical)
) {
Text(
text = entry.originalWord,
modifier = Modifier
.testTag("search_result_row_$resultIndex")
.semantics { testTagsAsResourceId = true },
style = titleStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (showDictionaryName) {
// Cache dictionary name to avoid repeated suffix removal
val dictionaryDisplayName = remember(entry.dictionaryName) {
entry.dictionaryName.removeDictionarySuffixes()
}
Text(
text = dictionaryDisplayName,
style = subtitleStyle,
color = subtitleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -0,0 +1,173 @@
package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.*
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.SearchViewModel
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.about.AboutScreen
import com.example.research.ui.article.ArticleRoute
import com.example.research.ui.main.MainRoute
import com.example.research.ui.settings.ImportState
import com.example.research.ui.settings.SettingsRoute
import com.example.research.ui.settings.SettingsViewModel
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
private data class NavigationState(
val shouldShowSettings: Boolean,
val showBackButtonInSettings: Boolean,
val isOperationActive: Boolean,
)
enum class Screen {
Home, Settings, Article, About
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AppNavigation(
searchViewModel: SearchViewModel,
settingsViewModel: SettingsViewModel
) {
val screenStack = rememberSaveable(
saver = listSaver(
save = { stateList -> stateList.map { it.name } },
restore = { names ->
val restored = names.mapNotNull { name ->
runCatching { Screen.valueOf(name) }.getOrNull()
}
mutableStateListOf<Screen>().apply {
if (restored.isNotEmpty()) addAll(restored) else add(Screen.Home)
}
}
)
) {
mutableStateListOf(Screen.Home)
}
val currentScreen = screenStack.lastOrNull() ?: Screen.Home
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val progressSnapshot by settingsViewModel.progressSnapshot.collectAsStateWithLifecycle()
val isPipelineActiveByState = remember(
progressSnapshot,
settingsState.indexingProgress.isIndexing,
settingsState.downloadState,
settingsState.importState,
) {
progressSnapshot != null ||
settingsState.indexingProgress.isIndexing ||
settingsState.downloadState is DownloadState.Loading ||
settingsState.downloadState is DownloadState.Extracting ||
settingsState.downloadState is DownloadState.Success ||
settingsState.importState is ImportState.Importing ||
settingsState.importState is ImportState.Extracting ||
settingsState.importState is ImportState.Success
}
var stickyPipelineActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(isPipelineActiveByState) {
if (isPipelineActiveByState) {
stickyPipelineActive = true
} else {
delay(1500.milliseconds)
stickyPipelineActive = false
}
}
val navState by remember {
derivedStateOf {
val isOperationActive = stickyPipelineActive
val hasConfirmedNoDictionaries =
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
NavigationState(
shouldShowSettings = hasConfirmedNoDictionaries || isOperationActive,
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
isOperationActive = isOperationActive,
)
}
}
val shouldShowSettings = navState.shouldShowSettings
val showBackButtonInSettings = navState.showBackButtonInSettings
val isOperationActive = navState.isOperationActive
val isWideScreen = currentWindowAdaptiveInfo()
.windowSizeClass
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) {
if (currentScreen == Screen.Article) {
if (searchViewModel.navigateBackFromArticle()) {
return@BackHandler
}
searchViewModel.onAction(SearchAction.ClearArticle)
}
if (screenStack.isNotEmpty()) {
screenStack.removeAt(screenStack.lastIndex)
}
}
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
val effectiveScreen = when {
currentScreen == Screen.About -> Screen.About
currentScreen == Screen.Article && articleState == null -> if (shouldShowSettings) Screen.Settings else Screen.Home
shouldShowSettings -> Screen.Settings
else -> currentScreen
}
when (effectiveScreen) {
Screen.Home -> {
MainRoute(
searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel,
onNavigateToSettings = { screenStack.add(Screen.Settings) },
onArticleClick = { entry ->
searchViewModel.onAction(SearchAction.ArticleClicked(entry))
if (!isWideScreen) {
screenStack.add(Screen.Article)
}
}
)
}
Screen.Settings -> {
SettingsRoute(
viewModel = settingsViewModel,
showBackButton = showBackButtonInSettings,
onNavigateBack = {
if (!isOperationActive && !shouldShowSettings) {
if (screenStack.size > 1) {
screenStack.removeAt(screenStack.lastIndex)
} else {
screenStack.clear()
screenStack.add(Screen.Home)
}
}
},
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 -> {
AboutScreen(
onNavigateBack = {
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
}
)
}
}
}
@@ -0,0 +1,9 @@
package com.example.research.ui.settings
sealed interface ImportState {
data object Idle : ImportState
data class Importing(val progress: Float) : ImportState
data class Extracting(val progress: Float) : ImportState
data object Success : ImportState
data class Error(val message: String) : ImportState
}
@@ -0,0 +1,281 @@
package com.example.research.ui.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.SectionCard
import com.example.research.ui.settings.components.DictionaryManagement
import com.example.research.ui.settings.components.LanguageSelectionSection
import com.example.research.ui.settings.components.ThemeSelectionSection
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsRoute(
viewModel: SettingsViewModel,
onNavigateBack: () -> Unit,
showBackButton: Boolean = true,
onNavigateToAbout: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
val snackbarHostState = remember { SnackbarHostState() }
val filePickerMutex = remember { Mutex(locked = false) }
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
if (filePickerMutex.isLocked) {
filePickerMutex.unlock()
}
if (uris.isNotEmpty()) {
viewModel.onEvent(SettingsUiEvent.ImportDictionaries(uris))
}
}
LaunchedEffect(viewModel, lifecycleOwner, snackbarHostState) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.effect.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
}
SettingsScreen(
uiState = uiState,
progressSnapshotFlow = viewModel.progressSnapshot,
snackbarHostState = snackbarHostState,
showBackButton = showBackButton,
onNavigateBack = onNavigateBack,
onEvent = viewModel::onEvent,
onImportClick = {
if (filePickerMutex.tryLock()) {
filePickerLauncher.launch(arrayOf("application/octet-stream", "*/*"))
}
},
onNavigateToAbout = onNavigateToAbout
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
uiState: SettingsUiState,
progressSnapshotFlow: StateFlow<com.example.research.common.progress.DictionaryProgressModel.Snapshot?>,
snackbarHostState: SnackbarHostState,
showBackButton: Boolean,
onNavigateBack: () -> Unit,
onEvent: (SettingsUiEvent) -> Unit,
onImportClick: () -> Unit,
onNavigateToAbout: () -> Unit
) {
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = {
if (showBackButton) {
IconButton(onClick = onNavigateBack) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back)
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.appTestTag("settings_screen")
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item(key = "about", contentType = "about") {
SectionCard(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.testTag("about_card")
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNavigateToAbout()
}
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_app_info),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Text(
text = "ReSearch",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
item(key = "dictionaries", contentType = "dictionaries") {
DictionaryManagement(
dictionaries = uiState.dictionaries,
sources = uiState.dictionarySources,
downloadState = uiState.downloadState,
dictionaryStatus = uiState.dictionaryStatus,
importState = uiState.importState,
isIndexing = uiState.indexingProgress.isIndexing,
isExpanded = uiState.isDictionariesExpanded,
progressSnapshotFlow = progressSnapshotFlow,
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("dictionaries", !uiState.isDictionariesExpanded)) },
onAddSources = { onEvent(SettingsUiEvent.AddDictionarySources(it)) },
onStartDownload = { onEvent(SettingsUiEvent.StartDownload) },
onCancelDownload = { onEvent(SettingsUiEvent.CancelDownload) },
onToggleDictionary = { onEvent(SettingsUiEvent.DictionaryToggled(it)) },
onDeleteDictionary = { onEvent(SettingsUiEvent.DictionaryDeleted(it)) },
onImportClick = onImportClick,
onShowError = { msg ->
scope.launch {
snackbarHostState.showSnackbar(msg)
}
}
)
}
item(key = "theme", contentType = "settings-section") {
CollapsibleSettingsSection(
title = stringResource(R.string.theme),
iconRes = R.drawable.ic_palette,
testTag = "theme_section",
isExpanded = uiState.isThemeExpanded,
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("theme", !uiState.isThemeExpanded)) },
) {
ThemeSelectionSection(
currentTheme = uiState.theme,
onThemeSelected = { theme ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onEvent(SettingsUiEvent.ThemeChanged(theme))
}
)
}
}
item(key = "language", contentType = "settings-section") {
CollapsibleSettingsSection(
title = stringResource(R.string.language),
iconRes = R.drawable.ic_language,
testTag = "language_section",
isExpanded = uiState.isLanguageExpanded,
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("language", !uiState.isLanguageExpanded)) },
) {
LanguageSelectionSection(
currentLanguage = uiState.language,
onLanguageChanged = { lang ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onEvent(SettingsUiEvent.LanguageChanged(lang))
}
)
}
}
item(key = "bottom-spacer", contentType = "spacer") {
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
private fun CollapsibleSettingsSection(
title: String,
iconRes: Int,
testTag: String,
isExpanded: Boolean,
onToggleExpanded: () -> Unit,
content: @Composable () -> Unit
) {
SectionCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
CollapsibleHeader(
title = title,
iconRes = iconRes,
isExpanded = isExpanded,
onToggle = onToggleExpanded,
testTag = testTag
)
if (isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
content()
}
}
}
}
}
@@ -0,0 +1,38 @@
package com.example.research.ui.settings
import android.net.Uri
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.DictionaryStatus
import com.example.research.feature.download.model.DownloadState
data class SettingsUiState(
val theme: AppTheme = AppTheme.SYSTEM,
val language: String = "system",
val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false,
val isDictionariesExpanded: Boolean = true,
val dictionaries: List<Dictionary> = emptyList(),
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
val indexingProgress: IndexingProgress = IndexingProgress(),
val downloadState: DownloadState = DownloadState.Idle,
val importState: ImportState = ImportState.Idle,
val dictionarySources: List<DictionarySource> = emptyList(),
val hasCompletedStartupScan: Boolean = false,
val appVersion: String = "1.0",
)
sealed interface SettingsUiEvent {
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
data class LanguageChanged(val language: String) : SettingsUiEvent
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
data class DictionaryToggled(val path: String) : SettingsUiEvent
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
data class ImportDictionaries(val uris: List<Uri>) : SettingsUiEvent
data object StartDownload : SettingsUiEvent
data object CancelDownload : SettingsUiEvent
data class AddDictionarySources(val urlTemplates: List<String>) : SettingsUiEvent
}
@@ -0,0 +1,553 @@
package com.example.research.ui.settings
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.research.DictionaryStatus
import com.example.research.R
import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.core.domain.usecase.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.download.repository.DictionaryRepository
import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private data class DictionaryStateInputs(
val theme: AppTheme,
val dictionarySources: List<DictionarySource>,
val dictionaries: List<Dictionary>,
val indexingProgress: IndexingProgress,
)
private data class OperationStateInputs(
val downloadState: DownloadState,
val language: String,
val importState: ImportState,
val dictionaryStatus: DictionaryStatus,
val hasCompletedStartupScan: Boolean,
val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean,
val isDictionariesExpanded: Boolean,
)
class SettingsViewModel(
application: Application,
private val preferencesManager: PreferencesManager,
private val dictionaryRepository: DictionaryRepository,
private val downloadManager: DownloadManager,
private val localDictionaryRepository: LocalDictionaryRepository,
private val dictionaryImportManager: DictionaryImportManager,
progressStateHolder: DictionaryProgressStateHolder,
appVersion: String,
) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
preferencesManager = preferencesManager,
validator = DictionarySourceValidator(),
)
private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion))
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
val progressSnapshot = progressStateHolder.progressSnapshot
private val effectChannel =
kotlinx.coroutines.channels.Channel<String>(kotlinx.coroutines.channels.Channel.BUFFERED)
val effect = effectChannel.receiveAsFlow()
private val dictionaryStatus = MutableStateFlow<DictionaryStatus>(DictionaryStatus.Unknown)
private val language = MutableStateFlow("system")
private val hasCompletedStartupScan = MutableStateFlow(false)
private val pendingSourceUrls = mutableSetOf<String>()
private var isDownloadInProgress = false
init {
setupStateObservation()
initialize()
}
private fun setupStateObservation() {
viewModelScope.launch {
var wasIndexing = false
val dictionaryStateInputs = combine(
preferencesManager.themeMode.map { mode ->
try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM }
},
preferencesManager.dictionarySources,
localDictionaryRepository.dictionaries,
localDictionaryRepository.indexingProgress,
) { theme, dictionarySources, dictionaries, indexingProgress ->
DictionaryStateInputs(
theme = theme,
dictionarySources = dictionarySources,
dictionaries = dictionaries,
indexingProgress = indexingProgress,
)
}
val operationStateInputs = combine(
downloadManager.downloadState,
language,
dictionaryImportManager.importState,
dictionaryStatus,
hasCompletedStartupScan,
preferencesManager.isThemeExpanded,
preferencesManager.isLanguageExpanded,
preferencesManager.isDictionariesExpanded,
) { params: Array<Any> ->
@Suppress("UNCHECKED_CAST")
OperationStateInputs(
downloadState = params[0] as DownloadState,
language = params[1] as String,
importState = params[2] as ImportState,
dictionaryStatus = params[3] as DictionaryStatus,
hasCompletedStartupScan = params[4] as Boolean,
isThemeExpanded = params[5] as Boolean,
isLanguageExpanded = params[6] as Boolean,
isDictionariesExpanded = params[7] as Boolean,
)
}
combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState ->
// Handle indexing completion side effect
val isIndexing = dictionaryState.indexingProgress.isIndexing
if (wasIndexing && !isIndexing) {
if (isDownloadInProgress) {
isDownloadInProgress = false
dictionaryStatus.value = DictionaryStatus.UpToDate
}
}
wasIndexing = isIndexing
when (operationState.downloadState) {
is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true
}
is DownloadState.Success -> {
pendingSourceUrls.clear()
}
is DownloadState.Error -> {
isDownloadInProgress = false
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else {
DictionaryStatus.UpToDate
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find {
it.urlTemplate == urlTemplate
}
if (source != null) {
val hasDictionary = dictionaryState.dictionaries.any { dict ->
DictionarySource.matchesDictionaryFile(
urlTemplate,
File(dict.path).name
)
}
if (!hasDictionary) {
manageDictionarySourcesUseCase.removeSource(source.id)
}
}
}
pendingSourceUrls.clear()
}
}
is DownloadState.Cancelled -> {
isDownloadInProgress = false
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else {
DictionaryStatus.UpToDate
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find {
it.urlTemplate == urlTemplate
}
source?.let { manageDictionarySourcesUseCase.removeSource(it.id) }
}
pendingSourceUrls.clear()
}
}
else -> {}
}
SettingsUiState(
theme = dictionaryState.theme,
dictionarySources = dictionaryState.dictionarySources,
dictionaries = dictionaryState.dictionaries,
indexingProgress = IndexingProgress(isIndexing = isIndexing),
downloadState = operationState.downloadState,
language = operationState.language,
importState = operationState.importState,
dictionaryStatus = operationState.dictionaryStatus,
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
isThemeExpanded = operationState.isThemeExpanded,
isLanguageExpanded = operationState.isLanguageExpanded,
isDictionariesExpanded = operationState.isDictionariesExpanded,
appVersion = _uiState.value.appVersion,
)
}.collect { newState ->
_uiState.value = newState
}
}
}
private fun initialize() {
viewModelScope.launch {
try {
scanDirectoryInternal(preferencesManager.dictionaryPath)
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Initial scan failed", e)
dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
DictionaryStatus.Error(
e.message ?: getApplication<Application>().getString(R.string.error_scan_directory_failed)
)
} else {
DictionaryStatus.UpToDate
}
} finally {
try {
language.value = preferencesManager.language.first()
} catch (e: Exception) {
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
}
hasCompletedStartupScan.value = true
}
}
}
fun onEvent(event: SettingsUiEvent) {
when (event) {
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
is SettingsUiEvent.ImportDictionaries -> importDictionaries(event.uris)
SettingsUiEvent.StartDownload -> startDownload()
SettingsUiEvent.CancelDownload -> cancelDownload()
is SettingsUiEvent.AddDictionarySources -> addDictionarySources(event.urlTemplates)
}
}
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
return try {
val actualDictionaries = localDictionaryRepository.dictionaries.first()
val hasDictionaries = actualDictionaries.isNotEmpty()
if (!hasDictionaries) {
return DictionaryStatus.Empty
}
var sources = preferencesManager.dictionarySources.first()
if (downloadManager.downloadState.value !is DownloadState.Loading &&
downloadManager.downloadState.value !is DownloadState.Extracting
) {
val installedSources = installedSources(sources, actualDictionaries)
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id)
val staleSourceIds = sources
.filterNot { it.id in installedSourceIds }
.map(DictionarySource::id)
if (staleSourceIds.isNotEmpty()) {
preferencesManager.removeDictionarySources(staleSourceIds)
sources = installedSources
}
}
val enabledSources = installedEnabledSources(sources, actualDictionaries)
if (enabledSources.isEmpty()) {
return DictionaryStatus.UpToDate
}
val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources)
return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
return DictionaryStatus.UpToDate
}
DictionaryStatus.Error(e.message ?: getApplication<Application>().getString(R.string.error_checking_dictionaries))
}
}
private fun setTheme(theme: AppTheme) {
viewModelScope.launch {
preferencesManager.saveThemeMode(theme.name)
}
}
private fun saveSectionExpanded(key: String, isExpanded: Boolean) {
viewModelScope.launch {
preferencesManager.saveSectionExpanded(key, isExpanded)
}
}
private fun saveLanguage(languageCode: String) {
viewModelScope.launch {
preferencesManager.saveLanguage(languageCode)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
val locales = if (languageCode == "system") {
android.os.LocaleList.getEmptyLocaleList()
} else {
android.os.LocaleList.forLanguageTags(languageCode)
}
getApplication<Application>()
.getSystemService(android.app.LocaleManager::class.java)
?.applicationLocales = locales
}
language.value = languageCode
}
}
private fun toggleDictionaryActive(dictionaryPath: String) {
localDictionaryRepository.toggleDictionaryActive(dictionaryPath)
}
private fun deleteDictionary(dictionary: Dictionary) {
viewModelScope.launch {
localDictionaryRepository.deleteDictionary(dictionary)
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary)
val remainingDictionaries = localDictionaryRepository.dictionaries.first()
if (remainingDictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
}
}
}
private fun addDictionarySources(urlTemplates: List<String>) {
viewModelScope.launch {
try {
val validUrls = mutableListOf<String>()
urlTemplates.forEach { urlTemplate ->
val trimmed = urlTemplate.trim()
if (trimmed.isEmpty() || trimmed.length > 2048) {
return@forEach
}
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
validUrls.add(trimmed)
}
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
// Validation failed, skip this source
}
}
}
if (validUrls.isNotEmpty()) {
startDownloadForSources(validUrls)
}
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
}
}
}
private fun startDownloadForSources(sourceUrls: List<String>) {
viewModelScope.launch {
if (localDictionaryRepository.isIndexingInProgress()) {
effectChannel.send(
getApplication<Application>().getString(R.string.indexing_already_in_progress)
)
return@launch
}
dictionaryStatus.value = DictionaryStatus.Checking
isDownloadInProgress = true
val intent = android.content.Intent(
getApplication(),
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_START
putStringArrayListExtra("source_urls", ArrayList(sourceUrls))
}
getApplication<Application>().startForegroundService(intent)
}
}
private fun startDownload() {
viewModelScope.launch {
if (localDictionaryRepository.isIndexingInProgress()) {
effectChannel.send(
getApplication<Application>().getString(R.string.indexing_already_in_progress)
)
return@launch
}
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
if (status !is DictionaryStatus.NeedsUpdate) return@launch
val installedSources = installedEnabledSources(
preferencesManager.dictionarySources.first(),
localDictionaryRepository.dictionaries.first()
)
if (installedSources.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.UpToDate
return@launch
}
isDownloadInProgress = true
val intent = android.content.Intent(
getApplication(),
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_START
putStringArrayListExtra(
"source_urls",
ArrayList(installedSources.map(DictionarySource::urlTemplate))
)
}
getApplication<Application>().startForegroundService(intent)
}
}
private fun installedEnabledSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>
): List<DictionarySource> = installedSources(sources, dictionaries)
.filter(DictionarySource::isEnabled)
private fun installedSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>
): List<DictionarySource> {
val installedFileNames = dictionaries.map { File(it.path).name }
return sources.filter { source ->
installedFileNames.any { fileName ->
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
}
}
}
private fun cancelDownload() {
// Cancel the ViewModel-side scan job so no further status recomputes
localDictionaryRepository.cancelIndexing()
// Delegate the actual cancellation to the foreground service via
// ACTION_STOP. The service cancels its coroutine scope *before*
// telling DownloadManager / DictionaryImportManager / the repository
// to clear their state — that ordering is what prevents an in-flight
// extract/index callback from racing the cancel and republishing
// a stale progress value (which used to leave the bar stuck until
val stopIntent = android.content.Intent(
getApplication(),
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_STOP
}
try {
getApplication<Application>().startService(stopIntent)
} catch (e: Exception) {
// If the service can't be started for any reason (e.g. background
// restrictions), fall back to local cancellation so the UI still
android.util.Log.w("SettingsViewModel", "Failed to start stop service: ${e.message}")
dictionaryImportManager.cancelImport()
downloadManager.cancelDownload()
getApplication<Application>().stopService(stopIntent)
}
viewModelScope.launch {
val dictionaries = localDictionaryRepository.dictionaries.first()
dictionaryStatus.value = if (dictionaries.isNotEmpty()) {
DictionaryStatus.UpToDate
} else {
DictionaryStatus.Empty
}
}
}
private suspend fun scanDirectoryInternal(path: String): OperationResult<Int> {
val result = localDictionaryRepository.scanDirectory(path)
when (result) {
is OperationResult.Success -> {
val dictionaries = localDictionaryRepository.dictionaries.first()
if (dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
isDownloadInProgress = false
} else {
if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
}
if (result.data > 0) {
viewModelScope.launch(Dispatchers.Default) {
localDictionaryRepository.warmupIndexes()
}
viewModelScope.launch(Dispatchers.IO) {
localDictionaryRepository.loadMetadataAsync()
}
}
}
else -> {
isDownloadInProgress = false
}
}
return result
}
private fun importDictionaries(uris: List<Uri>) {
viewModelScope.launch {
if (localDictionaryRepository.isIndexingInProgress()) {
effectChannel.send(
getApplication<Application>().getString(R.string.indexing_already_in_progress)
)
return@launch
}
isDownloadInProgress = true
val intent = android.content.Intent(
getApplication(),
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_IMPORT
}
getApplication<Application>().startForegroundService(intent)
dictionaryImportManager.importDictionaries(uris)
}
}
}
@@ -0,0 +1,45 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
@Composable
fun DictionaryActionButtons(
onAddSource: () -> Unit,
onImportClick: () -> Unit,
isImportEnabled: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally)
) {
TextButton(
onClick = onAddSource,
modifier = Modifier.testTag("add_source_button"),
) {
Text(
text = stringResource(R.string.dictionary_source_add_button),
style = MaterialTheme.typography.labelLarge,
maxLines = 1
)
}
TextButton(
onClick = onImportClick,
enabled = isImportEnabled
) {
Text(
text = stringResource(R.string.import_dictionary_button),
style = MaterialTheme.typography.labelLarge,
maxLines = 1
)
}
}
}
@@ -0,0 +1,261 @@
package com.example.research.ui.settings.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
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.core.domain.model.Dictionary
import kotlinx.coroutines.launch
private val ItemHorizontalPadding: Dp = 12.dp
private val ItemContentSpacing: Dp = 12.dp
private val SwipeSettledOffset: Dp = 48.dp
private val DictionaryItemHeight: Dp = 64.dp
private val SwipeThreshold: Dp = 40.dp
private val IconSize: Dp = 24.dp
private val IconButtonSize: Dp = 48.dp
private val DeleteIconColor = Color(0xFFB3261E)
private const val SwitchScale: Float = 0.8f
@Stable
private enum class DictionaryFileType(val label: String) {
DictZip("DictZip"),
Dsl("DSL"),
Other("File");
companion object {
fun fromPath(path: String): DictionaryFileType = when {
path.endsWith(".dsl.dz") -> DictZip
path.endsWith(".dsl") -> Dsl
else -> Other
}
}
}
@Composable
fun DictionaryListItem(
dictionary: Dictionary,
onToggle: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val maxSwipePx = remember(density) { with(density) { SwipeSettledOffset.toPx() } }
val swipeThresholdPx = remember(density) { with(density) { SwipeThreshold.toPx() } }
val offsetAnim = remember { Animatable(0f) }
var rawOffset by remember { mutableFloatStateOf(0f) }
val isDeleteRevealed by remember { derivedStateOf { rawOffset <= -swipeThresholdPx } }
LaunchedEffect(isDeleteRevealed) {
if (isDeleteRevealed) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
val currentOnDelete by rememberUpdatedState(onDelete)
val currentOnToggle by rememberUpdatedState(onToggle)
val fileType = remember(dictionary.path) { DictionaryFileType.fromPath(dictionary.path) }
val displayName = remember(dictionary.metadata.name, dictionary.name) {
dictionary.metadata.name.ifBlank { dictionary.name }
}
val switchTestTag = remember(dictionary.path) {
val fileName = dictionary.path.substringAfterLast('/')
val dictionaryId = fileName
.removeSuffix(".dsl.dz")
.removeSuffix(".dsl")
.removeSuffix(".gz")
.replace(Regex("[^A-Za-z0-9_]"), "_")
"dictionary_switch_$dictionaryId"
}
Box(
modifier = modifier
.fillMaxWidth()
.testTag("dictionary_item")
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(DictionaryItemHeight)
.background(MaterialTheme.colorScheme.surfaceVariant)
.graphicsLayer { translationX = offsetAnim.value }
.pointerInput(maxSwipePx, swipeThresholdPx) {
detectHorizontalDragGestures(
onDragStart = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
onDragEnd = {
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target
scope.launch {
offsetAnim.animateTo(
target,
animationSpec = spring(stiffness = Spring.StiffnessMedium)
)
}
},
onDragCancel = {
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target
scope.launch {
offsetAnim.animateTo(
target,
animationSpec = spring(stiffness = Spring.StiffnessMedium)
)
}
},
onHorizontalDrag = { _, dragAmount ->
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
rawOffset = newOffset
scope.launch { offsetAnim.snapTo(newOffset) }
}
)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ItemContentSpacing)
) {
Box(
modifier = Modifier.size(IconButtonSize),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.ic_book),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(IconSize)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = fileType.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1
)
if (dictionary.articleCount > 0) {
Text(
text = pluralStringResource(
R.plurals.dictionary_entries_count,
dictionary.articleCount,
dictionary.articleCount
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Text(
text = stringResource(R.string.dictionary_not_indexed),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
if (isDeleteRevealed) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnDelete()
scope.launch { offsetAnim.snapTo(0f) }
rawOffset = 0f
},
modifier = Modifier
.padding(end = ItemHorizontalPadding)
.graphicsLayer { translationX = -offsetAnim.value }
.size(IconButtonSize)
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.dictionary_delete),
tint = DeleteIconColor,
modifier = Modifier.size(IconSize)
)
}
} else {
Switch(
checked = dictionary.isActive,
onCheckedChange = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnToggle()
},
enabled = dictionary.articleCount > 0,
modifier = Modifier
.padding(end = ItemHorizontalPadding)
.scale(SwitchScale)
.testTag(switchTestTag)
.semantics { testTagsAsResourceId = true }
)
}
}
}
}
@@ -0,0 +1,263 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.DictionaryStatus
import com.example.research.R
import com.example.research.common.progress.DictionaryProgressModel
import com.example.research.common.progress.renderTitle
import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.EmptyState
import com.example.research.common.ui.components.SectionCard
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState
import kotlinx.coroutines.flow.StateFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DictionaryManagement(
dictionaries: List<Dictionary>,
sources: List<DictionarySource>,
downloadState: DownloadState,
dictionaryStatus: DictionaryStatus,
importState: ImportState,
isIndexing: Boolean,
isExpanded: Boolean,
progressSnapshotFlow: StateFlow<DictionaryProgressModel.Snapshot?>,
onToggleExpanded: () -> Unit,
onAddSources: (List<String>) -> Unit,
onStartDownload: () -> Unit,
onCancelDownload: () -> Unit,
onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit,
onImportClick: () -> Unit,
onShowError: (String) -> Unit,
modifier: Modifier = Modifier
) {
val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val haptic = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current
val isInProgress = isIndexing ||
downloadState is DownloadState.Loading ||
downloadState is DownloadState.Extracting ||
downloadState is DownloadState.Success ||
importState is ImportState.Importing ||
importState is ImportState.Extracting ||
importState is ImportState.Success
val isImporting by remember(importState) {
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
}
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
SectionCard(
modifier = modifier
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
val headerSubtitle = if (!isExpanded && dictionaries.isNotEmpty()) {
pluralStringResource(
R.plurals.active_dictionaries_count,
dictionaries.count { it.isActive },
dictionaries.count { it.isActive }
)
} else null
CollapsibleHeader(
title = stringResource(R.string.dictionary_management_title),
iconRes = R.drawable.ic_dictionary_management,
isExpanded = isExpanded,
onToggle = onToggleExpanded,
subtitle = headerSubtitle,
testTag = "dictionary_header"
)
if (isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
DictionaryProgressSectionHost(
visible = isInProgress,
progressSnapshotFlow = progressSnapshotFlow,
onCancel = onCancelDownload,
)
LaunchedEffect(downloadState, dictionaryStatus, importState) {
val errorMessage = when {
downloadState is DownloadState.Error -> downloadState.message
dictionaryStatus is DictionaryStatus.Error -> dictionaryStatus.message
importState is ImportState.Error -> importState.message
else -> null
}
errorMessage?.let { onShowError(it) }
}
// Disable accessibility on background content during import to improve performance.
// The progress dialog remains accessible for cancellation.
val backgroundModifier = if (isInProgress) {
Modifier.semantics(mergeDescendants = true) { }
} else {
Modifier
}
if (dictionaries.isEmpty() && !isInProgress) {
Column(
modifier = backgroundModifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
EmptyState(
message = stringResource(R.string.dictionaries_not_downloaded),
description = stringResource(R.string.dictionaries_empty_description)
)
DictionaryActionButtons(
onAddSource = {
setShowUrlBottomSheet(true)
},
onImportClick = onImportClick,
isImportEnabled = true
)
}
} else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) {
DictionaryUpdateCard(
onStartDownload = onStartDownload,
modifier = backgroundModifier
)
}
DictionaryListSection(
dictionaries = dictionaries,
onToggleDictionary = onToggleDictionary,
onDeleteDictionary = onDeleteDictionary,
modifier = Modifier
.fillMaxWidth()
.then(backgroundModifier)
)
if (!isInProgress) {
DictionaryActionButtons(
onAddSource = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
setShowUrlBottomSheet(true)
},
onImportClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onImportClick()
},
isImportEnabled = !isImporting
)
}
}
}
}
}
}
if (showUrlBottomSheet) {
ModalBottomSheet(
onDismissRequest = {
focusManager.clearFocus(force = true)
setShowUrlBottomSheet(false)
},
sheetState = sheetState
) {
DictionaryUrlInputContent(
existingSources = sources,
onAddSources = { urls ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onAddSources(urls)
setShowUrlBottomSheet(false)
},
onCancel = {
focusManager.clearFocus(force = true)
setShowUrlBottomSheet(false)
}
)
}
}
}
@Composable
private fun DictionaryProgressSectionHost(
visible: Boolean,
progressSnapshotFlow: StateFlow<DictionaryProgressModel.Snapshot?>,
onCancel: () -> Unit,
) {
val progressSnapshot by progressSnapshotFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val targetProgress = progressSnapshot?.progress ?: 0f
val label = remember(progressSnapshot, context) {
val s = progressSnapshot ?: return@remember ""
s.renderTitle(context)
}
val progressTestTag = if (progressSnapshot?.titleRes == R.string.notification_indexing_title) {
"indexing_progress"
} else {
"download_progress"
}
DictionaryProgressSection(
visible = visible,
progressProvider = { targetProgress },
label = label,
onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0,
testTag = progressTestTag,
)
}
@Composable
private fun DictionaryListSection(
dictionaries: List<Dictionary>,
onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
dictionaries.forEach { dictionary ->
DictionaryListItem(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) }
)
}
}
}
@@ -0,0 +1,89 @@
package com.example.research.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
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.*
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
@Composable
fun DictionaryProgressSection(
visible: Boolean,
progressProvider: () -> Float,
label: String,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
percent: Int,
testTag: String,
) {
val targetProgress = progressProvider().coerceIn(0f, 1f)
var monotonicTargetProgress by remember { mutableFloatStateOf(0f) }
LaunchedEffect(visible, targetProgress) {
monotonicTargetProgress = if (visible) {
maxOf(monotonicTargetProgress, targetProgress)
} else {
0f
}
}
val animatedProgress by animateFloatAsState(
targetValue = monotonicTargetProgress,
animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing),
label = "dictionary-progress"
)
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = modifier.testTag(testTag)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "$percent%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(MaterialTheme.shapes.small),
)
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(painterResource(R.drawable.ic_close), null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.action_cancel), style = MaterialTheme.typography.labelMedium)
}
}
}
}
@@ -0,0 +1,51 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.*
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
@Composable
fun DictionaryUpdateCard(
onStartDownload: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
onClick = onStartDownload
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_cloud_download),
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.dictionaries_update_available),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.dictionaries_tap_to_update),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
}
@@ -0,0 +1,291 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.core.domain.model.DictionarySource
private data class UrlValidationContext(
val url: String,
val existingSources: List<DictionarySource>,
val existingPrefixes: Set<String>,
val urlsInForm: List<String>,
val urlPrefix: String?,
val errorMessages: ErrorMessages
)
private data class ErrorMessages(
val empty: String,
val duplicateInForm: String,
val duplicate: String,
val duplicatePrefix: String,
val invalidProtocol: String,
val invalidExtension: String,
val tooLong: String,
val invalidUrl: String,
val suspiciousCharacters: String
)
private fun validateDictionaryUrl(context: UrlValidationContext): String? {
with(context) {
return when {
url.isEmpty() -> null
urlsInForm.count { it == url } > 1 -> errorMessages.duplicateInForm
existingSources.any { it.urlTemplate == url } -> errorMessages.duplicate
urlPrefix != null && existingPrefixes.contains(urlPrefix) -> errorMessages.duplicatePrefix
else -> when (val result = DictionarySource.validateTemplate(url)) {
is DictionarySource.ValidationResult.Valid -> null
is DictionarySource.ValidationResult.Error -> when (result.error) {
DictionarySource.ValidationError.EMPTY -> null
DictionarySource.ValidationError.INVALID_PROTOCOL -> errorMessages.invalidProtocol
DictionarySource.ValidationError.INVALID_EXTENSION -> errorMessages.invalidExtension
DictionarySource.ValidationError.TOO_LONG -> errorMessages.tooLong
DictionarySource.ValidationError.INVALID_URL_STRUCTURE -> errorMessages.invalidUrl
DictionarySource.ValidationError.SUSPICIOUS_CHARACTERS -> errorMessages.suspiciousCharacters
}
}
}
}
}
@Composable
fun DictionaryUrlInputContent(
existingSources: List<DictionarySource>,
onAddSources: (List<String>) -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier
) {
val urls = remember { mutableStateListOf(TextFieldValue("")) }
val validationErrors = remember { mutableStateMapOf<Int, String>() }
val scrollState = rememberScrollState()
val focusManager = LocalFocusManager.current
val errorMessages = ErrorMessages(
empty = stringResource(R.string.dictionary_source_url_empty),
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
duplicate = stringResource(R.string.dictionary_source_duplicate),
duplicatePrefix = stringResource(R.string.dictionary_source_duplicate_prefix),
invalidProtocol = stringResource(R.string.dictionary_source_invalid_protocol),
invalidExtension = stringResource(R.string.dictionary_source_invalid_extension),
tooLong = stringResource(R.string.dictionary_source_too_long),
invalidUrl = stringResource(R.string.dictionary_source_invalid_url),
suspiciousCharacters = stringResource(R.string.dictionary_source_suspicious_characters)
)
// Cache extracted prefixes for existing sources to avoid repeated extraction
val existingPrefixes = remember(existingSources) {
existingSources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
}
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
val inputUrlPrefixes = remember {
derivedStateOf {
urls.associate { urlValue ->
val urlString = urlValue.text.trim()
urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString)
} else null
}
}
}
Column(
modifier = modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.verticalScroll(scrollState)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.dictionary_source_add_button),
style = MaterialTheme.typography.headlineSmall
)
urls.forEachIndexed { index, urlValue ->
val isLastItem = index == urls.lastIndex
val urlString = urlValue.text.trim()
val cachedPrefix = inputUrlPrefixes.value[urlString]
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
validateDictionaryUrl(
UrlValidationContext(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.text.trim() },
urlPrefix = cachedPrefix,
errorMessages = errorMessages
)
)
}
val displayError = validationErrors[index] ?: realtimeError
OutlinedTextField(
value = urlValue,
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}") },
isError = displayError != null,
supportingText = displayError?.let { { Text(it) } },
modifier = Modifier
.fillMaxWidth()
.then(
if (index == 0) {
Modifier
.testTag("url_input_field")
.semantics { testTagsAsResourceId = true }
} else {
Modifier
}
),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
),
trailingIcon = if (urls.size > 1) {
{
IconButton(onClick = {
urls.removeAt(index)
validationErrors.clear()
}) {
Icon(painterResource(R.drawable.ic_close), stringResource(R.string.action_remove))
}
}
} else null
)
}
TextButton(
onClick = {
urls.add(TextFieldValue(""))
},
modifier = Modifier
.align(Alignment.Start)
.testTag("add_another_url_button")
.semantics { testTagsAsResourceId = true }
) {
Icon(painterResource(R.drawable.ic_add), null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.action_add_another_url))
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = {
focusManager.clearFocus(force = true)
onCancel()
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.action_cancel))
}
TextButton(
onClick = {
validationErrors.clear()
var hasError = false
val validUrls = mutableListOf<String>()
val seenUrls = mutableSetOf<String>()
urls.forEachIndexed { index, urlValue ->
val urlString = urlValue.text.trim()
if (urlString.isBlank()) {
if (urls.size == 1) {
validationErrors[index] = errorMessages.empty
hasError = true
}
} else {
val urlPrefix = inputUrlPrefixes.value[urlString]
val error = validateDictionaryUrl(
UrlValidationContext(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = seenUrls.toList() + urlString,
urlPrefix = urlPrefix,
errorMessages = errorMessages
)
)
if (error != null) {
validationErrors[index] = error
hasError = true
} else {
validUrls.add(urlString)
seenUrls.add(urlString)
}
}
}
if (!hasError && validUrls.isNotEmpty()) {
focusManager.clearFocus(force = true)
onAddSources(validUrls)
}
},
modifier = Modifier
.weight(1f)
.testTag("add_confirm_button")
.semantics { testTagsAsResourceId = true },
enabled = urls.any { it.text.trim().isNotBlank() } &&
urls.none { urlValue ->
val urlString = urlValue.text.trim()
if (urlString.isEmpty()) {
false
} else {
val urlPrefix = inputUrlPrefixes.value[urlString]
validateDictionaryUrl(
UrlValidationContext(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.text.trim() },
urlPrefix = urlPrefix,
errorMessages = errorMessages
)
) != null
}
}
) {
Text(stringResource(R.string.action_add))
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
@@ -0,0 +1,42 @@
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
@Composable
fun LanguageSelectionSection(
currentLanguage: String,
onLanguageChanged: (String) -> Unit,
modifier: Modifier = Modifier
) {
val languages = listOf(
"system" to stringResource(R.string.language_system),
"en" to stringResource(R.string.language_english),
"ru" to stringResource(R.string.language_russian)
)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
languages.forEach { (code, label) ->
val isSelected = currentLanguage == code
OutlinedChoiceButton(
text = label,
onClick = { onLanguageChanged(code) },
modifier = Modifier
.weight(1f)
.testTag("language_$code"),
selected = isSelected
)
}
}
}
@@ -0,0 +1,41 @@
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.AppTheme
@Composable
fun ThemeSelectionSection(
currentTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
AppTheme.entries.forEach { theme ->
val isSelected = currentTheme == theme
OutlinedChoiceButton(
text = when (theme) {
AppTheme.LIGHT -> stringResource(R.string.theme_light)
AppTheme.DARK -> stringResource(R.string.theme_dark)
AppTheme.SYSTEM -> stringResource(R.string.theme_system)
},
onClick = { onThemeSelected(theme) },
modifier = Modifier
.weight(1f)
.testTag("theme_${theme.name.lowercase()}"),
selected = isSelected
)
}
}
}
@@ -0,0 +1,12 @@
package com.example.research.ui.theme
import androidx.compose.ui.unit.dp
object Spacing {
// Semantic spacing constants for specific UI elements
val topBarPadding = 8.dp
val topBarHeight = 56.dp
val iconStart = 32.dp
val searchTextStart = 68.dp
val listItemVertical = 8.dp
}
@@ -0,0 +1,36 @@
package com.example.research.ui.theme
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.runtime.Composable
object AppWindowInsets {
val horizontalSafeDrawing: WindowInsets
@Composable
get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
val topAndHorizontalSafeDrawing: WindowInsets
@Composable
get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
val contentWithNavigation: WindowInsets
@Composable
get() = WindowInsets.navigationBars.union(WindowInsets.ime)
val fullScreen: WindowInsets
@Composable
get() = WindowInsets.statusBars
.union(WindowInsets.navigationBars)
.union(WindowInsets.displayCutout)
val navigationOnly: WindowInsets
@Composable
get() = WindowInsets.navigationBars
}
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</vector>

Some files were not shown because too many files have changed in this diff Show More