Initial ReSearch export
This commit is contained in:
@@ -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>
|
||||
@@ -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="M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z" />
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user