Initial ReSearch export
This commit is contained in:
@@ -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> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#architecture">Architecture</a> •
|
||||
<a href="#dictionary-pipeline">Pipeline</a> •
|
||||
<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
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+11
@@ -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
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
+34
@@ -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)
|
||||
}
|
||||
+54
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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"
|
||||
}
|
||||
}
|
||||
+179
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+330
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -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
|
||||
}
|
||||
}
|
||||
+305
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+263
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+291
@@ -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))
|
||||
}
|
||||
}
|
||||
+42
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user