Compare commits
27
Commits
1.2.1
..
380e4ca916
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380e4ca916 | ||
|
|
47b1197a92 | ||
|
|
c1563b8172 | ||
|
|
4a00d8adff | ||
|
|
944d2e6634 | ||
|
|
d7de09a152 | ||
|
|
e21a134d65 | ||
|
|
b9baf61264 | ||
|
|
7b63ee4377 | ||
|
|
d892427a3f | ||
|
|
628813c3f6 | ||
|
|
4f202f8c10 | ||
|
|
44ebe68f75 | ||
|
|
5de466792a | ||
|
|
b6e6bd328c | ||
|
|
5132ae66b9 | ||
|
|
5151665216 | ||
|
|
96461bd7cf | ||
|
|
98ef99ab53 | ||
|
|
43823e3bfa | ||
|
|
bab03b6c73 | ||
|
|
d4e6302274 | ||
|
|
bc3fa85b9d | ||
|
|
457bbe43b6 | ||
|
|
f751ac0d36 | ||
|
|
910bc8313c | ||
|
|
9d18d0c927 |
@@ -29,7 +29,7 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
|
|||||||
- Random-access article loading from DictZip dictionaries.
|
- Random-access article loading from DictZip dictionaries.
|
||||||
- Background processing with progress shown in Settings and notifications.
|
- Background processing with progress shown in Settings and notifications.
|
||||||
- Dictionary management with enable, disable, update, and delete actions.
|
- Dictionary management with enable, disable, update, and delete actions.
|
||||||
- Material 3 interface with light, dark, and system themes.
|
- Material 3 interface with dynamic color, light, dark, and system themes.
|
||||||
- English and Russian localization.
|
- English and Russian localization.
|
||||||
- About screen with open source license information.
|
- About screen with open source license information.
|
||||||
|
|
||||||
@@ -95,15 +95,15 @@ The main search screen remains usable as long as at least one indexed dictionary
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Kotlin 2.4.10
|
- Kotlin 2.4.10
|
||||||
- Jetpack Compose (BOM 2026.06.01)
|
- Jetpack Compose (BOM 2026.08.00)
|
||||||
- Material 3
|
- Material 3
|
||||||
- Coroutines and Flow 1.11.0
|
- Coroutines and Flow 1.11.0
|
||||||
- DataStore Preferences 1.2.1
|
- DataStore Preferences 1.2.1
|
||||||
- Paging 3.5.0
|
- Paging 3.5.1
|
||||||
- OkHttp 5.4.0
|
- OkHttp 5.4.0
|
||||||
- kotlinx.serialization 1.11.0
|
- kotlinx.serialization 1.11.0
|
||||||
- AboutLibraries 15.0.4 metadata generation
|
- AboutLibraries 15.0.4 metadata generation
|
||||||
- Android Gradle Plugin 9.3.0
|
- Android Gradle Plugin 9.3.1
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ android {
|
|||||||
applicationId = "com.example.research"
|
applicationId = "com.example.research"
|
||||||
minSdk = project.property("minSdk").toString().toInt()
|
minSdk = project.property("minSdk").toString().toInt()
|
||||||
targetSdk = project.property("targetSdk").toString().toInt()
|
targetSdk = project.property("targetSdk").toString().toInt()
|
||||||
versionCode = 5
|
versionCode = 8
|
||||||
versionName = "1.2.1"
|
versionName = "1.5.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.example.research
|
package com.example.research
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface DictionaryStatus {
|
sealed interface DictionaryStatus {
|
||||||
data object Unknown : DictionaryStatus
|
data object Unknown : DictionaryStatus
|
||||||
data object Checking : DictionaryStatus
|
data object Checking : DictionaryStatus
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val app = application as ReSearchApplication
|
val app = application as ReSearchApplication
|
||||||
val initialTheme = loadInitialTheme(app)
|
val initialTheme = loadInitialTheme(app)
|
||||||
val initialLanguage = loadInitialLanguage(app)
|
val initialLanguage = loadInitialLanguage(app)
|
||||||
|
val initialHadNoDictionaries = loadInitialHadNoDictionaries(app)
|
||||||
|
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
setContent {
|
setContent {
|
||||||
@@ -104,7 +105,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
) {
|
) {
|
||||||
AppNavigation(
|
AppNavigation(
|
||||||
searchViewModel = searchViewModel,
|
searchViewModel = searchViewModel,
|
||||||
settingsViewModel = settingsViewModel
|
settingsViewModel = settingsViewModel,
|
||||||
|
seedNoDictionaries = initialHadNoDictionaries
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadInitialHadNoDictionaries(app: ReSearchApplication): Boolean {
|
||||||
|
return try {
|
||||||
|
runBlocking {
|
||||||
|
app.preferencesManager.hadNoDictionaries.first()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.example.research
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.example.research.common.progress.DictionaryProgressStateHolder
|
import com.example.research.common.progress.DictionaryProgressStateHolder
|
||||||
|
import com.example.research.common.util.NotificationHelper
|
||||||
import com.example.research.data.local.preferences.PreferencesManager
|
import com.example.research.data.local.preferences.PreferencesManager
|
||||||
import com.example.research.data.repository.LocalDictionaryRepository
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.feature.download.DownloadManager
|
import com.example.research.feature.download.DownloadManager
|
||||||
@@ -36,11 +37,18 @@ class ReSearchApplication : Application() {
|
|||||||
private set
|
private set
|
||||||
lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder
|
lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder
|
||||||
private set
|
private set
|
||||||
|
lateinit var dictionaryProgressPresenter: com.example.research.common.progress.DictionaryProgressPresenter
|
||||||
|
private set
|
||||||
|
lateinit var dictionaryPipelineCoordinator: com.example.research.common.progress.DictionaryPipelineCoordinator
|
||||||
|
private set
|
||||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
val notificationHelper = NotificationHelper(this)
|
||||||
|
notificationHelper.cancelProgressNotification()
|
||||||
|
|
||||||
preferencesManager = PreferencesManager(this)
|
preferencesManager = PreferencesManager(this)
|
||||||
applicationScope.launch(Dispatchers.IO) {
|
applicationScope.launch(Dispatchers.IO) {
|
||||||
preferencesManager.sanitizeDictionarySources()
|
preferencesManager.sanitizeDictionarySources()
|
||||||
@@ -62,7 +70,8 @@ class ReSearchApplication : Application() {
|
|||||||
|
|
||||||
downloadManager = DownloadManager(
|
downloadManager = DownloadManager(
|
||||||
dictionaryRepository = downloadDictionaryRepository,
|
dictionaryRepository = downloadDictionaryRepository,
|
||||||
unknownErrorMessage = getString(R.string.unknown_error)
|
unknownErrorMessage = getString(R.string.unknown_error),
|
||||||
|
sourceUnavailableMessage = getString(R.string.download_file_not_found)
|
||||||
)
|
)
|
||||||
|
|
||||||
dictionaryImportManager = DictionaryImportManager(
|
dictionaryImportManager = DictionaryImportManager(
|
||||||
@@ -76,6 +85,40 @@ class ReSearchApplication : Application() {
|
|||||||
localDictionaryRepository = localDictionaryRepository,
|
localDictionaryRepository = localDictionaryRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dictionaryProgressPresenter = com.example.research.common.progress.DictionaryProgressPresenter(
|
||||||
|
scope = applicationScope,
|
||||||
|
progressSnapshot = dictionaryProgressStateHolder.progressSnapshot,
|
||||||
|
notifications = notificationHelper,
|
||||||
|
)
|
||||||
|
|
||||||
|
dictionaryPipelineCoordinator = com.example.research.common.progress.DictionaryPipelineCoordinator(
|
||||||
|
scope = applicationScope,
|
||||||
|
steps = com.example.research.common.progress.DefaultReindexingSteps(
|
||||||
|
localDictionaryRepository = localDictionaryRepository,
|
||||||
|
downloadDictionaryRepository = downloadDictionaryRepository,
|
||||||
|
),
|
||||||
|
importOperations = dictionaryImportManager,
|
||||||
|
ui = dictionaryProgressPresenter,
|
||||||
|
dictionaryPath = { preferencesManager.dictionaryPath },
|
||||||
|
finishService = {
|
||||||
|
val intent = android.content.Intent(
|
||||||
|
this,
|
||||||
|
com.example.research.feature.download.service.DictionaryForegroundService::class.java
|
||||||
|
).apply {
|
||||||
|
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_FINISH
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startService(intent)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
dictionaryImportManager.onFlowStarted = { dictionaryProgressPresenter.beginFlow() }
|
||||||
|
dictionaryImportManager.onTerminal = dictionaryPipelineCoordinator::onImportTerminal
|
||||||
|
downloadManager.onFlowStarted = { dictionaryProgressPresenter.beginFlow() }
|
||||||
|
downloadManager.onDownloadSuccess = dictionaryPipelineCoordinator::onDownloadSuccess
|
||||||
|
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTrimMemory(level: Int) {
|
override fun onTrimMemory(level: Int) {
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package com.example.research.common.progress
|
||||||
|
|
||||||
|
import com.example.research.core.util.OperationResult
|
||||||
|
import com.example.research.feature.download.model.DownloadState
|
||||||
|
import com.example.research.ui.settings.ImportState
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class DictionaryPipelineCoordinator(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val steps: ReindexingSteps,
|
||||||
|
private val importOperations: ImportFlowOperations,
|
||||||
|
private val ui: PipelineTerminalUi,
|
||||||
|
private val dictionaryPath: () -> String,
|
||||||
|
private val finishService: () -> Unit,
|
||||||
|
private val launchDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) {
|
||||||
|
private var pipelineJob: Job? = null
|
||||||
|
|
||||||
|
fun onImportTerminal(state: ImportState) {
|
||||||
|
when (state) {
|
||||||
|
is ImportState.Success -> runPipeline(isImport = true)
|
||||||
|
is ImportState.Error -> {
|
||||||
|
ui.showError()
|
||||||
|
finishService()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDownloadSuccess() = runPipeline(isImport = false)
|
||||||
|
|
||||||
|
fun onDownloadTerminal(state: DownloadState) {
|
||||||
|
when (state) {
|
||||||
|
is DownloadState.Error -> {
|
||||||
|
ui.showError()
|
||||||
|
finishService()
|
||||||
|
}
|
||||||
|
is DownloadState.Cancelled -> {
|
||||||
|
ui.cancelProgress()
|
||||||
|
finishService()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelPipeline() {
|
||||||
|
pipelineJob?.cancel()
|
||||||
|
pipelineJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runPipeline(isImport: Boolean) {
|
||||||
|
pipelineJob?.cancel()
|
||||||
|
pipelineJob = scope.launch(launchDispatcher) {
|
||||||
|
val dir = if (isImport) File(dictionaryPath()) else null
|
||||||
|
val filesBeforeReindex = dir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
|
||||||
|
try {
|
||||||
|
triggerReindexing(isImport)
|
||||||
|
if (isImport) {
|
||||||
|
importOperations.getAndClearImportedFiles()
|
||||||
|
importOperations.clearImportState()
|
||||||
|
ui.showImportSuccess()
|
||||||
|
} else {
|
||||||
|
ui.showDownloadSuccess()
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
if (isImport) {
|
||||||
|
val filesToCleanup = importOperations.getAndClearImportedFiles()
|
||||||
|
filesToCleanup.forEach { file ->
|
||||||
|
try {
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupNewFiles(dir, filesBeforeReindex)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ui.showError()
|
||||||
|
} finally {
|
||||||
|
finishService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun triggerReindexing(isImport: Boolean) = withContext(ioDispatcher) {
|
||||||
|
steps.waitForIndexingCompletion()
|
||||||
|
delay(1.seconds)
|
||||||
|
|
||||||
|
val path = dictionaryPath()
|
||||||
|
if (isImport) {
|
||||||
|
importOperations.updateExtractionProgress(0f)
|
||||||
|
}
|
||||||
|
steps.extractArchives(onProgress = { progress ->
|
||||||
|
if (isImport) {
|
||||||
|
importOperations.updateExtractionProgress(progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
val result = steps.scanDirectory(path)
|
||||||
|
if (result is OperationResult.Success && result.data > 0) {
|
||||||
|
steps.warmupIndexes()
|
||||||
|
steps.performAllCleanup()
|
||||||
|
}
|
||||||
|
if (isImport) {
|
||||||
|
importOperations.markImportPipelineSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupNewFiles(dir: File?, filesBeforeSnapshot: Set<String>) {
|
||||||
|
try {
|
||||||
|
dir?.listFiles()?.forEach { file ->
|
||||||
|
if (file.name !in filesBeforeSnapshot) {
|
||||||
|
try {
|
||||||
|
file.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.example.research.common.progress
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class DictionaryProgressPresenter(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
|
||||||
|
private val notifications: NotificationPort,
|
||||||
|
mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||||
|
) : PipelineTerminalUi {
|
||||||
|
@Volatile
|
||||||
|
private var terminalActive = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch(mainDispatcher) {
|
||||||
|
progressSnapshot.collect { snapshot ->
|
||||||
|
when {
|
||||||
|
snapshot == null -> {
|
||||||
|
if (!terminalActive && notifications.hasActiveProgressNotification()) {
|
||||||
|
notifications.cancelProgressNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!terminalActive -> notifications.showProgress(snapshot.percent, snapshot.titleRes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beginFlow() {
|
||||||
|
terminalActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showImportSuccess() {
|
||||||
|
notifications.showImportSuccess()
|
||||||
|
terminalActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showDownloadSuccess() {
|
||||||
|
notifications.showDownloadSuccess()
|
||||||
|
terminalActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showError() {
|
||||||
|
notifications.showError()
|
||||||
|
terminalActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelProgress() {
|
||||||
|
terminalActive = false
|
||||||
|
notifications.cancelProgressNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.research.common.progress
|
||||||
|
|
||||||
|
interface NotificationPort {
|
||||||
|
fun showProgress(percent: Int, titleRes: Int)
|
||||||
|
fun showImportSuccess()
|
||||||
|
fun showDownloadSuccess()
|
||||||
|
fun showError()
|
||||||
|
fun cancelProgressNotification()
|
||||||
|
fun hasActiveProgressNotification(): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.example.research.common.progress
|
||||||
|
|
||||||
|
import com.example.research.core.util.OperationResult
|
||||||
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
|
import com.example.research.feature.download.repository.DictionaryRepository
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
interface PipelineTerminalUi {
|
||||||
|
fun showImportSuccess()
|
||||||
|
fun showDownloadSuccess()
|
||||||
|
fun showError()
|
||||||
|
fun cancelProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportFlowOperations {
|
||||||
|
fun updateExtractionProgress(progress: Float)
|
||||||
|
fun markImportPipelineSuccess()
|
||||||
|
fun clearImportState()
|
||||||
|
fun getAndClearImportedFiles(): List<File>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReindexingSteps {
|
||||||
|
suspend fun waitForIndexingCompletion(): Boolean
|
||||||
|
suspend fun extractArchives(onProgress: (Float) -> Unit)
|
||||||
|
suspend fun scanDirectory(path: String): OperationResult<Int>
|
||||||
|
suspend fun warmupIndexes()
|
||||||
|
suspend fun performAllCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultReindexingSteps(
|
||||||
|
private val localDictionaryRepository: LocalDictionaryRepository,
|
||||||
|
private val downloadDictionaryRepository: DictionaryRepository,
|
||||||
|
) : ReindexingSteps {
|
||||||
|
|
||||||
|
override suspend fun waitForIndexingCompletion(): Boolean {
|
||||||
|
if (!localDictionaryRepository.indexingProgress.first().isIndexing) return true
|
||||||
|
var waitCount = 0
|
||||||
|
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < MAX_POLL) {
|
||||||
|
delay(POLL_INTERVAL)
|
||||||
|
waitCount++
|
||||||
|
}
|
||||||
|
return waitCount < MAX_POLL
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun extractArchives(onProgress: (Float) -> Unit) {
|
||||||
|
downloadDictionaryRepository.extractArchives(onProgress = onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun scanDirectory(path: String): OperationResult<Int> =
|
||||||
|
localDictionaryRepository.scanDirectory(path)
|
||||||
|
|
||||||
|
override suspend fun warmupIndexes() = localDictionaryRepository.warmupIndexes()
|
||||||
|
|
||||||
|
override suspend fun performAllCleanup() {
|
||||||
|
downloadDictionaryRepository.performAllCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_POLL = 100
|
||||||
|
val POLL_INTERVAL = 200.milliseconds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import androidx.core.app.*
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.example.research.MainActivity
|
import com.example.research.MainActivity
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.progress.NotificationPort
|
||||||
import com.example.research.feature.download.receiver.DownloadCancelReceiver
|
import com.example.research.feature.download.receiver.DownloadCancelReceiver
|
||||||
class NotificationHelper(private val context: Context) {
|
class NotificationHelper(private val context: Context) : NotificationPort {
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "download_progress_channel"
|
const val CHANNEL_ID = "download_progress_channel"
|
||||||
const val NOTIFICATION_ID = 1
|
const val NOTIFICATION_ID = 1
|
||||||
@@ -64,6 +65,22 @@ class NotificationHelper(private val context: Context) {
|
|||||||
android.Manifest.permission.POST_NOTIFICATIONS
|
android.Manifest.permission.POST_NOTIFICATIONS
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
override fun showProgress(percent: Int, titleRes: Int) {
|
||||||
|
showUnifiedProgressNotification(
|
||||||
|
title = context.getString(titleRes),
|
||||||
|
contentText = "$percent%",
|
||||||
|
progressPercent = percent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showDownloadSuccess() = showSuccessNotification()
|
||||||
|
|
||||||
|
override fun showImportSuccess() = showImportSuccessNotification()
|
||||||
|
|
||||||
|
override fun showError() = showErrorNotification()
|
||||||
|
|
||||||
|
override fun cancelProgressNotification() = cancelNotification()
|
||||||
|
|
||||||
fun showSuccessNotification() {
|
fun showSuccessNotification() {
|
||||||
if (!canShowNotification()) return
|
if (!canShowNotification()) return
|
||||||
lastNotificationKey = 0
|
lastNotificationKey = 0
|
||||||
@@ -93,6 +110,8 @@ class NotificationHelper(private val context: Context) {
|
|||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
|
||||||
|
|
||||||
fun showUnifiedProgressNotification(
|
fun showUnifiedProgressNotification(
|
||||||
title: String,
|
title: String,
|
||||||
contentText: String,
|
contentText: String,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.example.research.core.domain.model
|
package com.example.research.core.domain.model
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot of an ongoing indexing operation.
|
* Snapshot of an ongoing indexing operation.
|
||||||
*
|
*
|
||||||
@@ -11,6 +13,7 @@ import kotlin.math.roundToInt
|
|||||||
* [perFileProgress] is kept for diagnostics; callers should prefer
|
* [perFileProgress] is kept for diagnostics; callers should prefer
|
||||||
* [progress] / [progressPercent].
|
* [progress] / [progressPercent].
|
||||||
*/
|
*/
|
||||||
|
@Immutable
|
||||||
data class IndexingProgress(
|
data class IndexingProgress(
|
||||||
val currentFile: String = "",
|
val currentFile: String = "",
|
||||||
val currentIndex: Int = 0,
|
val currentIndex: Int = 0,
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.research.core.domain.usecase
|
||||||
|
|
||||||
|
import com.example.research.core.domain.model.Dictionary
|
||||||
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object DictionarySourceFileMatcher {
|
||||||
|
fun matches(source: DictionarySource, fileName: String): Boolean =
|
||||||
|
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
|
||||||
|
|
||||||
|
fun matches(source: DictionarySource, dictionary: Dictionary): Boolean =
|
||||||
|
matches(source, File(dictionary.path).name)
|
||||||
|
|
||||||
|
fun installedSources(
|
||||||
|
sources: List<DictionarySource>,
|
||||||
|
dictionaries: List<Dictionary>,
|
||||||
|
): List<DictionarySource> = sources.filter { source ->
|
||||||
|
dictionaries.any { dictionary -> matches(source, dictionary) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installedSourcesForFileNames(
|
||||||
|
sources: List<DictionarySource>,
|
||||||
|
fileNames: Collection<String>,
|
||||||
|
): List<DictionarySource> = sources.filter { source ->
|
||||||
|
fileNames.any { fileName -> matches(source, fileName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
-8
@@ -43,12 +43,4 @@ class DictionarySourceValidator {
|
|||||||
return ValidationResult.Valid
|
return ValidationResult.Valid
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findMatchingSourceWithPrecomputedPrefixes(
|
|
||||||
dictionaryPrefix: String,
|
|
||||||
sourcesWithPrefixes: List<Pair<DictionarySource, String?>>
|
|
||||||
): DictionarySource? {
|
|
||||||
return sourcesWithPrefixes.find { (_, sourcePrefix) ->
|
|
||||||
sourcePrefix != null && dictionaryPrefix.equals(sourcePrefix, ignoreCase = true)
|
|
||||||
}?.first
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-9
@@ -30,18 +30,24 @@ class ManageDictionarySourcesUseCase(
|
|||||||
preferencesManager.removeDictionarySource(sourceId)
|
preferencesManager.removeDictionarySource(sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeSourceForDictionary(dictionary: Dictionary) {
|
suspend fun removeSourceForDictionary(
|
||||||
val dictionaryPrefix = dictionary.name
|
dictionary: Dictionary,
|
||||||
val sources = preferencesManager.dictionarySources.first()
|
remainingDictionaryFileNames: Collection<String>?,
|
||||||
val sourcesWithPrefixes = sources.map { source ->
|
) {
|
||||||
source to DictionarySource.extractPrefix(source.urlTemplate)
|
if (remainingDictionaryFileNames == null) return
|
||||||
}
|
|
||||||
|
|
||||||
val matchingSource = validator.findMatchingSourceWithPrecomputedPrefixes(dictionaryPrefix, sourcesWithPrefixes)
|
val sources = preferencesManager.dictionarySources.first()
|
||||||
if (matchingSource != null) {
|
val sourceIdsToRemove = sources
|
||||||
preferencesManager.removeDictionarySource(matchingSource.id)
|
.filter { source ->
|
||||||
|
DictionarySourceFileMatcher.matches(source, dictionary) &&
|
||||||
|
remainingDictionaryFileNames.none { fileName ->
|
||||||
|
DictionarySourceFileMatcher.matches(source, fileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.map(DictionarySource::id)
|
||||||
|
|
||||||
|
preferencesManager.removeDictionarySources(sourceIdsToRemove)
|
||||||
|
}
|
||||||
|
|
||||||
sealed class AddSourceResult {
|
sealed class AddSourceResult {
|
||||||
object Success : AddSourceResult()
|
object Success : AddSourceResult()
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ fun Throwable.isNetworkError(): Boolean {
|
|||||||
if (isSslHandshakeError()) return true
|
if (isSslHandshakeError()) return true
|
||||||
|
|
||||||
return when (this) {
|
return when (this) {
|
||||||
|
is java.net.UnknownHostException,
|
||||||
|
is java.net.ConnectException,
|
||||||
|
is java.net.NoRouteToHostException,
|
||||||
|
is java.net.BindException -> true
|
||||||
is java.io.IOException -> {
|
is java.io.IOException -> {
|
||||||
val message = message ?: ""
|
val message = message ?: ""
|
||||||
message.contains("connection", ignoreCase = true) ||
|
message.contains("connection", ignoreCase = true) ||
|
||||||
@@ -49,9 +53,14 @@ fun Throwable.isNetworkError(): Boolean {
|
|||||||
message.contains("unreachable", ignoreCase = true) ||
|
message.contains("unreachable", ignoreCase = true) ||
|
||||||
message.contains("no route", ignoreCase = true) ||
|
message.contains("no route", ignoreCase = true) ||
|
||||||
message.contains("broken pipe", ignoreCase = true) ||
|
message.contains("broken pipe", ignoreCase = true) ||
|
||||||
|
message.contains("resolve host", ignoreCase = true) ||
|
||||||
|
message.contains("unexpected end of stream", ignoreCase = true) ||
|
||||||
|
message.contains("socket closed", ignoreCase = true) ||
|
||||||
message.contains("ECONNRESET", ignoreCase = true) ||
|
message.contains("ECONNRESET", ignoreCase = true) ||
|
||||||
message.contains("ECONNREFUSED", ignoreCase = true) ||
|
message.contains("ECONNREFUSED", ignoreCase = true) ||
|
||||||
message.contains("ENETUNREACH", ignoreCase = true)
|
message.contains("ECONNABORTED", ignoreCase = true) ||
|
||||||
|
message.contains("ENETUNREACH", ignoreCase = true) ||
|
||||||
|
message.contains("EHOSTUNREACH", ignoreCase = true)
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class PreferencesManager(private val context: Context) {
|
|||||||
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
|
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
|
||||||
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
|
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
|
||||||
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
||||||
|
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
||||||
}
|
}
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
@@ -67,6 +68,17 @@ class PreferencesManager(private val context: Context) {
|
|||||||
preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] ?: emptySet()
|
preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] ?: emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val hadNoDictionaries: Flow<Boolean> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[PreferencesKeys.HAD_NO_DICTIONARIES] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setHadNoDictionaries(value: Boolean) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[PreferencesKeys.HAD_NO_DICTIONARIES] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setDictionaryActive(path: String, isActive: Boolean) {
|
suspend fun setDictionaryActive(path: String, isActive: Boolean) {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
|
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ class IndexEntryPagingSource(
|
|||||||
|
|
||||||
private data class EmittedKey(
|
private data class EmittedKey(
|
||||||
val dictPosition: Int,
|
val dictPosition: Int,
|
||||||
val originalWord: String,
|
val word: String,
|
||||||
val offset: Long,
|
val offset: Long
|
||||||
val length: Int
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val emittedArticles = HashSet<EmittedKey>()
|
private val emittedArticles = HashSet<EmittedKey>()
|
||||||
@@ -103,6 +102,7 @@ class IndexEntryPagingSource(
|
|||||||
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
||||||
val ranked = rawResults.map { it.second }
|
val ranked = rawResults.map { it.second }
|
||||||
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
|
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
|
||||||
|
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
|
||||||
|
|
||||||
rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) }
|
rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) }
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ class IndexEntryPagingSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun IndexEntry.emittedKey(dictPosition: Int) =
|
private fun IndexEntry.emittedKey(dictPosition: Int) =
|
||||||
EmittedKey(dictPosition, originalWord, offset.value, length.value)
|
EmittedKey(dictPosition, word, offset.value)
|
||||||
|
|
||||||
override fun getRefreshKey(state: PagingState<TailKey, IndexEntry>): TailKey? {
|
override fun getRefreshKey(state: PagingState<TailKey, IndexEntry>): TailKey? {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ object DslHeadwordParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
|
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
|
||||||
|
val unescaped = unescape(trimmed)
|
||||||
return ParsedHeadword(
|
return ParsedHeadword(
|
||||||
simplified = trimmed,
|
simplified = unescaped,
|
||||||
displayText = trimmed,
|
displayText = unescaped,
|
||||||
searchableText = trimmed.lowercase()
|
searchableText = unescaped.lowercase()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +61,9 @@ object DslHeadwordParser {
|
|||||||
val searchableText = createSearchableText(trimmed)
|
val searchableText = createSearchableText(trimmed)
|
||||||
|
|
||||||
return ParsedHeadword(
|
return ParsedHeadword(
|
||||||
simplified = simplified,
|
simplified = unescape(simplified),
|
||||||
displayText = displayText,
|
displayText = unescape(displayText),
|
||||||
searchableText = searchableText
|
searchableText = unescape(searchableText)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +130,17 @@ object DslHeadwordParser {
|
|||||||
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
|
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
|
||||||
|
|
||||||
return buildString(value.length) {
|
return buildString(value.length) {
|
||||||
value.forEach { char ->
|
var index = 0
|
||||||
|
while (index < value.length) {
|
||||||
|
val char = value[index]
|
||||||
|
if (char == '\\' && index + 1 < value.length) {
|
||||||
|
append(char)
|
||||||
|
append(value[index + 1])
|
||||||
|
index += 2
|
||||||
|
} else {
|
||||||
if (char != '{' && char != '}') append(char)
|
if (char != '{' && char != '}') append(char)
|
||||||
|
index++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +148,10 @@ object DslHeadwordParser {
|
|||||||
private fun firstFormattingTagIndex(value: String): Int {
|
private fun firstFormattingTagIndex(value: String): Int {
|
||||||
var index = 0
|
var index = 0
|
||||||
while (index < value.length) {
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
|
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
@@ -149,8 +163,12 @@ object DslHeadwordParser {
|
|||||||
private fun firstCurlyContent(value: String): String? {
|
private fun firstCurlyContent(value: String): String? {
|
||||||
var index = 0
|
var index = 0
|
||||||
while (index < value.length) {
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (value[index] == '{') {
|
if (value[index] == '{') {
|
||||||
val end = value.indexOf('}', startIndex = index + 1)
|
val end = matchingCurlyEnd(value, index + 1)
|
||||||
if (end > index + 1) {
|
if (end > index + 1) {
|
||||||
return value.substring(index + 1, end)
|
return value.substring(index + 1, end)
|
||||||
}
|
}
|
||||||
@@ -161,7 +179,7 @@ object DslHeadwordParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun substringBeforeFirstBracket(value: String): String {
|
private fun substringBeforeFirstBracket(value: String): String {
|
||||||
val bracketIndex = value.indexOf('[')
|
val bracketIndex = indexOfUnescaped(value, '[')
|
||||||
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
|
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +189,12 @@ object DslHeadwordParser {
|
|||||||
var index = 0
|
var index = 0
|
||||||
|
|
||||||
while (index < value.length) {
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (value[index] == '{') {
|
if (value[index] == '{') {
|
||||||
val end = value.indexOf('}', startIndex = index + 1)
|
val end = matchingCurlyEnd(value, index + 1)
|
||||||
if (end >= 0 && (removeEmpty || end > index + 1)) {
|
if (end >= 0 && (removeEmpty || end > index + 1)) {
|
||||||
if (builder == null) {
|
if (builder == null) {
|
||||||
builder = StringBuilder(value.length)
|
builder = StringBuilder(value.length)
|
||||||
@@ -197,6 +219,10 @@ object DslHeadwordParser {
|
|||||||
var index = 0
|
var index = 0
|
||||||
|
|
||||||
while (index < value.length) {
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (value[index] == '[') {
|
if (value[index] == '[') {
|
||||||
val tagEnd = formattingTagEnd(value, index)
|
val tagEnd = formattingTagEnd(value, index)
|
||||||
if (tagEnd > index) {
|
if (tagEnd > index) {
|
||||||
@@ -217,6 +243,56 @@ object DslHeadwordParser {
|
|||||||
}?.toString() ?: value
|
}?.toString() ?: value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun matchingCurlyEnd(value: String, startIndex: Int): Int {
|
||||||
|
var index = startIndex
|
||||||
|
while (index < value.length) {
|
||||||
|
when {
|
||||||
|
value[index] == '\\' && index + 1 < value.length -> index += 2
|
||||||
|
value[index] == '{' -> return -1
|
||||||
|
value[index] == '}' -> return index
|
||||||
|
else -> index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun indexOfUnescaped(
|
||||||
|
value: String,
|
||||||
|
target: Char,
|
||||||
|
startIndex: Int = 0,
|
||||||
|
): Int {
|
||||||
|
var index = startIndex.coerceAtLeast(0)
|
||||||
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
index += 2
|
||||||
|
} else if (value[index] == target) {
|
||||||
|
return index
|
||||||
|
} else {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unescape(value: String): String {
|
||||||
|
val firstEscape = value.indexOf('\\')
|
||||||
|
if (firstEscape < 0) return value
|
||||||
|
|
||||||
|
return buildString(value.length) {
|
||||||
|
append(value, 0, firstEscape)
|
||||||
|
var index = firstEscape
|
||||||
|
while (index < value.length) {
|
||||||
|
if (value[index] == '\\' && index + 1 < value.length) {
|
||||||
|
append(value[index + 1])
|
||||||
|
index += 2
|
||||||
|
} else {
|
||||||
|
append(value[index])
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
|
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
|
||||||
val tokenStart = openBracketIndex + 1
|
val tokenStart = openBracketIndex + 1
|
||||||
if (tokenStart >= value.length) return -1
|
if (tokenStart >= value.length) return -1
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ class IndexSearcher(private val context: Context) {
|
|||||||
val dataStartOffset: Long
|
val dataStartOffset: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IndexComparisonSummary(
|
|
||||||
val expectedCount: Int,
|
|
||||||
val actualCount: Int,
|
|
||||||
val comparedCount: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RangeCursor(
|
data class RangeCursor(
|
||||||
val rangeQuery: String,
|
val rangeQuery: String,
|
||||||
val absoluteIndex: Int,
|
val absoluteIndex: Int,
|
||||||
@@ -72,8 +66,6 @@ class IndexSearcher(private val context: Context) {
|
|||||||
val cursorAfter: RangeCursor?
|
val cursorAfter: RangeCursor?
|
||||||
)
|
)
|
||||||
|
|
||||||
class IndexComparisonException(message: String) : IllegalStateException(message)
|
|
||||||
|
|
||||||
suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
|
suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
|
||||||
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
||||||
try {
|
try {
|
||||||
@@ -244,99 +236,6 @@ class IndexSearcher(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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, expectedMetadata.totalCount > 0)
|
|
||||||
var actualNext = readEntryOrNull(actualSource, actualMetadata, 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,
|
|
||||||
expectedMetadata,
|
|
||||||
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,
|
|
||||||
actualMetadata,
|
|
||||||
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,
|
|
||||||
metadata: IndexMetadata,
|
|
||||||
shouldRead: Boolean
|
|
||||||
): IndexEntry? = if (shouldRead) readEntry(source, metadata) else null
|
|
||||||
|
|
||||||
fun trimMemory(level: Int) {
|
fun trimMemory(level: Int) {
|
||||||
metadataCache.trim(level)
|
metadataCache.trim(level)
|
||||||
resultsCache.trim(level)
|
resultsCache.trim(level)
|
||||||
|
|||||||
+57
-33
@@ -27,9 +27,9 @@ import kotlinx.coroutines.awaitAll
|
|||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
@@ -95,12 +95,10 @@ class LocalDictionaryRepository(
|
|||||||
override val coroutineContext = Job() + ioDispatcher
|
override val coroutineContext = Job() + ioDispatcher
|
||||||
|
|
||||||
private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList())
|
private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList())
|
||||||
val dictionaries: StateFlow<List<Dictionary>>
|
val dictionaries: StateFlow<List<Dictionary>> = mutableDictionaries.asStateFlow()
|
||||||
get() = mutableDictionaries
|
|
||||||
|
|
||||||
private val mutableIndexingProgress = MutableStateFlow(IndexingProgress())
|
private val mutableIndexingProgress = MutableStateFlow(IndexingProgress())
|
||||||
val indexingProgress: StateFlow<IndexingProgress>
|
val indexingProgress: StateFlow<IndexingProgress> = mutableIndexingProgress.asStateFlow()
|
||||||
get() = mutableIndexingProgress
|
|
||||||
|
|
||||||
private val indexingMutex = Mutex()
|
private val indexingMutex = Mutex()
|
||||||
private val indexingSemaphore = Semaphore(1)
|
private val indexingSemaphore = Semaphore(1)
|
||||||
@@ -123,6 +121,24 @@ class LocalDictionaryRepository(
|
|||||||
|
|
||||||
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
|
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
|
||||||
|
|
||||||
|
suspend fun listDictionaryPayloadFileNames(path: String): List<String>? =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val directory = File(path)
|
||||||
|
when {
|
||||||
|
!directory.exists() -> {
|
||||||
|
if (directory.parentFile?.isDirectory == true) emptyList() else null
|
||||||
|
}
|
||||||
|
!directory.isDirectory -> null
|
||||||
|
else -> directory.listFiles { file -> isDictionaryPayloadFile(file) }
|
||||||
|
?.map(File::getName)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Failed to list dictionary files: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
|
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
|
||||||
if (isIndexingInProgress()) {
|
if (isIndexingInProgress()) {
|
||||||
return@withContext OperationResult.Error(
|
return@withContext OperationResult.Error(
|
||||||
@@ -371,9 +387,7 @@ class LocalDictionaryRepository(
|
|||||||
val dir = File(path)
|
val dir = File(path)
|
||||||
if (!dir.exists() || !dir.isDirectory) return emptyList()
|
if (!dir.exists() || !dir.isDirectory) return emptyList()
|
||||||
|
|
||||||
return dir.listFiles { f ->
|
return dir.listFiles { file -> isDictionaryPayloadFile(file) }?.map { file ->
|
||||||
f.isFile && (f.name.endsWith(".dsl") || f.name.endsWith(".dsl.dz") || f.name.endsWith(".dsl.gz"))
|
|
||||||
}?.map { file ->
|
|
||||||
DiscoveredFile(
|
DiscoveredFile(
|
||||||
name = file.name,
|
name = file.name,
|
||||||
localPath = file.absolutePath,
|
localPath = file.absolutePath,
|
||||||
@@ -382,6 +396,13 @@ class LocalDictionaryRepository(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isDictionaryPayloadFile(file: File): Boolean =
|
||||||
|
file.isFile && (
|
||||||
|
file.name.endsWith(".dsl") ||
|
||||||
|
file.name.endsWith(".dsl.dz") ||
|
||||||
|
file.name.endsWith(".dsl.gz")
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
|
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
|
||||||
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
|
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
|
||||||
|
|
||||||
@@ -460,20 +481,17 @@ class LocalDictionaryRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
|
suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
|
||||||
try {
|
return try {
|
||||||
dictionaryStateMutex.withLock { removeDictionaryFromState(dictionary.path) }
|
val (failure, cleanupErrors) = withContext(ioDispatcher) {
|
||||||
|
|
||||||
launch(ioDispatcher) {
|
|
||||||
try {
|
|
||||||
val deleteErrors = mutableListOf<String>()
|
|
||||||
|
|
||||||
val dictFile = File(dictionary.path)
|
val dictFile = File(dictionary.path)
|
||||||
if (dictFile.exists()) {
|
if (dictFile.exists() && !dictFile.delete()) {
|
||||||
if (!dictFile.delete()) {
|
return@withContext Pair(
|
||||||
deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}")
|
"Failed to delete dictionary file: ${dictFile.absolutePath}",
|
||||||
}
|
emptyList(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val deleteErrors = mutableListOf<String>()
|
||||||
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
|
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
|
||||||
File(dictionary.indexPath)
|
File(dictionary.indexPath)
|
||||||
} else {
|
} else {
|
||||||
@@ -481,12 +499,20 @@ class LocalDictionaryRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteIndexFiles(indexFile, deleteErrors)
|
deleteIndexFiles(indexFile, deleteErrors)
|
||||||
|
Pair<String?, List<String>>(null, deleteErrors)
|
||||||
if (!dictFile.exists()) {
|
|
||||||
dictionaryStateMutex.withLock {
|
|
||||||
preferencesManager.removeDictionaryActiveState(dictionary.path)
|
|
||||||
removeDictionaryFromState(dictionary.path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (failure != null) {
|
||||||
|
return OperationResult.Error(failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
dictionaryStateMutex.withLock {
|
||||||
|
try {
|
||||||
|
preferencesManager.removeDictionaryActiveState(dictionary.path)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to clear dictionary active state: ${e.message}")
|
||||||
|
}
|
||||||
|
removeDictionaryFromState(dictionary.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -495,18 +521,16 @@ class LocalDictionaryRepository(
|
|||||||
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
|
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deleteErrors.isNotEmpty()) {
|
if (cleanupErrors.isNotEmpty()) {
|
||||||
val message = deleteErrors.joinToString("; ")
|
Log.w(
|
||||||
Log.w(TAG, "Dictionary deletion completed with errors: $message")
|
TAG,
|
||||||
}
|
"Dictionary deletion completed with errors: ${cleanupErrors.joinToString("; ")}"
|
||||||
} catch (e: Exception) {
|
)
|
||||||
Log.e(TAG, "Background deletion failed: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return OperationResult.Success(Unit)
|
OperationResult.Success(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return OperationResult.Error("Failed to delete dictionary", e)
|
OperationResult.Error("Failed to delete dictionary", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
class DownloadManager(
|
class DownloadManager(
|
||||||
private val dictionaryRepository: DictionaryRepository,
|
private val dictionaryRepository: DictionaryRepository,
|
||||||
private val unknownErrorMessage: String,
|
private val unknownErrorMessage: String,
|
||||||
|
private val sourceUnavailableMessage: String,
|
||||||
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
externalScope: CoroutineScope? = null
|
externalScope: CoroutineScope? = null
|
||||||
) {
|
) {
|
||||||
@@ -40,31 +41,51 @@ class DownloadManager(
|
|||||||
val clampedProgress = progress.coerceIn(0f, 1f)
|
val clampedProgress = progress.coerceIn(0f, 1f)
|
||||||
mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress)
|
mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress)
|
||||||
mutableDownloadState.value = state
|
mutableDownloadState.value = state
|
||||||
|
if (state is DownloadState.Success) {
|
||||||
|
onDownloadSuccess?.invoke()
|
||||||
|
} else if (state is DownloadState.Error || state is DownloadState.Cancelled) {
|
||||||
|
onTerminal?.invoke(state)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var onFlowStarted: (() -> Unit)? = null
|
||||||
|
var onDownloadSuccess: (() -> Unit)? = null
|
||||||
|
var onTerminal: ((DownloadState) -> Unit)? = null
|
||||||
|
|
||||||
fun startDownload() {
|
fun startDownload() {
|
||||||
if (downloadState.value is DownloadState.Loading) return
|
if (downloadState.value is DownloadState.Loading) return
|
||||||
if (!downloadScope.isActive) return
|
if (!downloadScope.isActive) return
|
||||||
|
onFlowStarted?.invoke()
|
||||||
cancelCleanupJob = null
|
cancelCleanupJob = null
|
||||||
downloadJob = downloadScope.launch {
|
downloadJob = downloadScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
try {
|
try {
|
||||||
updateDownloadProgressState(DownloadState.Loading, 0f)
|
val enabledSources = try {
|
||||||
|
dictionaryRepository.getEnabledSources()
|
||||||
val hasEnabledSources = try {
|
|
||||||
dictionaryRepository.hasEnabledSources()
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasEnabledSources) {
|
if (enabledSources.isEmpty()) {
|
||||||
dictionaryRepository.performAllCleanup()
|
dictionaryRepository.performAllCleanup()
|
||||||
updateDownloadProgressState(DownloadState.Success, 1f)
|
updateDownloadProgressState(DownloadState.Idle, 0f)
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val downloadableSources =
|
||||||
|
dictionaryRepository.filterServerAvailableSources(enabledSources)
|
||||||
|
if (downloadableSources.isEmpty()) {
|
||||||
|
updateDownloadProgressState(
|
||||||
|
DownloadState.Error(sourceUnavailableMessage), 0f
|
||||||
|
)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDownloadProgressState(DownloadState.Loading, 0f)
|
||||||
|
|
||||||
var lastReportedProgressBucket = -1
|
var lastReportedProgressBucket = -1
|
||||||
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
|
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
|
||||||
dictionaryRepository.downloadDictionaries { progress ->
|
dictionaryRepository.downloadSources(downloadableSources) { progress ->
|
||||||
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
|
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
|
||||||
if (progressBucket != lastReportedProgressBucket) {
|
if (progressBucket != lastReportedProgressBucket) {
|
||||||
lastReportedProgressBucket = progressBucket
|
lastReportedProgressBucket = progressBucket
|
||||||
@@ -126,20 +147,41 @@ class DownloadManager(
|
|||||||
fun startDownloadForSources(sourceUrls: List<String>) {
|
fun startDownloadForSources(sourceUrls: List<String>) {
|
||||||
if (downloadState.value is DownloadState.Loading) return
|
if (downloadState.value is DownloadState.Loading) return
|
||||||
if (!downloadScope.isActive) return
|
if (!downloadScope.isActive) return
|
||||||
|
onFlowStarted?.invoke()
|
||||||
cancelCleanupJob = null
|
cancelCleanupJob = null
|
||||||
downloadJob = downloadScope.launch {
|
downloadJob = downloadScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
try {
|
try {
|
||||||
updateDownloadProgressState(DownloadState.Loading, 0f)
|
|
||||||
|
|
||||||
if (sourceUrls.isEmpty()) {
|
if (sourceUrls.isEmpty()) {
|
||||||
updateDownloadProgressState(DownloadState.Success, 1f)
|
updateDownloadProgressState(DownloadState.Idle, 0f)
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sources = try {
|
||||||
|
dictionaryRepository.getEnabledSources(sourceUrls)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
updateDownloadProgressState(DownloadState.Idle, 0f)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadableSources =
|
||||||
|
dictionaryRepository.filterServerAvailableSources(sources)
|
||||||
|
if (downloadableSources.isEmpty()) {
|
||||||
|
updateDownloadProgressState(
|
||||||
|
DownloadState.Error(sourceUnavailableMessage), 0f
|
||||||
|
)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDownloadProgressState(DownloadState.Loading, 0f)
|
||||||
|
|
||||||
var lastReportedProgressBucket = -1
|
var lastReportedProgressBucket = -1
|
||||||
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
|
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
|
||||||
dictionaryRepository.downloadSpecificSources(sourceUrls) { progress ->
|
dictionaryRepository.downloadSources(downloadableSources) { progress ->
|
||||||
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
|
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
|
||||||
if (progressBucket != lastReportedProgressBucket) {
|
if (progressBucket != lastReportedProgressBucket) {
|
||||||
lastReportedProgressBucket = progressBucket
|
lastReportedProgressBucket = progressBucket
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
package com.example.research.feature.download.model
|
package com.example.research.feature.download.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed class DownloadState {
|
sealed class DownloadState {
|
||||||
data object Idle : DownloadState()
|
data object Idle : DownloadState()
|
||||||
data object Loading : DownloadState()
|
data object Loading : DownloadState()
|
||||||
@@ -7,3 +11,6 @@ sealed class DownloadState {
|
|||||||
data class Error(val message: String) : DownloadState()
|
data class Error(val message: String) : DownloadState()
|
||||||
data object Cancelled : DownloadState()
|
data object Cancelled : DownloadState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val DownloadState.isActive: Boolean
|
||||||
|
get() = this is DownloadState.Loading || this is DownloadState.Extracting || this is DownloadState.Success
|
||||||
|
|||||||
+33
-3
@@ -12,6 +12,12 @@ import kotlinx.coroutines.withContext
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
|
||||||
|
enum class SourceAvailability {
|
||||||
|
AVAILABLE,
|
||||||
|
NOT_FOUND,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
class DictionaryChecker(
|
class DictionaryChecker(
|
||||||
private val fileStorageManager: FileStorageManager,
|
private val fileStorageManager: FileStorageManager,
|
||||||
private val client: OkHttpClient
|
private val client: OkHttpClient
|
||||||
@@ -85,7 +91,26 @@ class DictionaryChecker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean {
|
suspend fun checkSourceAvailability(source: DictionarySource): SourceAvailability =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val currentDate = DateUtils.getCurrentDateString()
|
||||||
|
when (probeSource(source, currentDate)) {
|
||||||
|
SourceAvailability.AVAILABLE -> SourceAvailability.AVAILABLE
|
||||||
|
SourceAvailability.UNKNOWN -> SourceAvailability.UNKNOWN
|
||||||
|
SourceAvailability.NOT_FOUND -> {
|
||||||
|
if (DictionarySource.hasDatePlaceholder(source.urlTemplate)) {
|
||||||
|
probeSource(source, DateUtils.getPreviousDateString())
|
||||||
|
} else {
|
||||||
|
SourceAvailability.NOT_FOUND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean =
|
||||||
|
probeSource(source, date) == SourceAvailability.AVAILABLE
|
||||||
|
|
||||||
|
private fun probeSource(source: DictionarySource, date: String): SourceAvailability {
|
||||||
return try {
|
return try {
|
||||||
val url = DictionarySource.buildUrl(source.urlTemplate, date)
|
val url = DictionarySource.buildUrl(source.urlTemplate, date)
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -94,12 +119,17 @@ class DictionaryChecker(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
response.isSuccessful &&
|
if (response.isSuccessful &&
|
||||||
response.header("Content-Type")?.startsWith("text/html") != true
|
response.header("Content-Type")?.startsWith("text/html") != true
|
||||||
|
) {
|
||||||
|
SourceAvailability.AVAILABLE
|
||||||
|
} else {
|
||||||
|
SourceAvailability.NOT_FOUND
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
|
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
|
||||||
false
|
SourceAvailability.UNKNOWN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -343,6 +343,8 @@ class DictionaryDownloader(
|
|||||||
context.getString(R.string.download_connection_reset)
|
context.getString(R.string.download_connection_reset)
|
||||||
e is SecurityException ->
|
e is SecurityException ->
|
||||||
context.getString(R.string.download_no_write_permission)
|
context.getString(R.string.download_no_write_permission)
|
||||||
|
e.isNetworkError() ->
|
||||||
|
context.getString(R.string.download_network_failed)
|
||||||
else ->
|
else ->
|
||||||
context.getString(R.string.download_error)
|
context.getString(R.string.download_error)
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-51
@@ -8,6 +8,8 @@ import com.example.research.core.domain.model.DictionarySource
|
|||||||
import com.example.research.data.local.preferences.PreferencesManager
|
import com.example.research.data.local.preferences.PreferencesManager
|
||||||
import com.example.research.common.util.FileStorageManager
|
import com.example.research.common.util.FileStorageManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -40,74 +42,58 @@ class DictionaryRepository(
|
|||||||
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
|
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun hasEnabledSources(): Boolean = withContext(Dispatchers.IO) {
|
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
|
||||||
preferencesManager.dictionarySources.first().any { it.isEnabled }
|
withContext(Dispatchers.IO) {
|
||||||
|
val enabledSources = preferencesManager.dictionarySources.first()
|
||||||
|
.filter { it.isEnabled }
|
||||||
|
if (sourceUrls == null) {
|
||||||
|
return@withContext enabledSources
|
||||||
}
|
}
|
||||||
|
|
||||||
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 sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
|
val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
|
||||||
val sourcesToDownload = allSources.mapNotNull { source ->
|
enabledSources.mapNotNull { source ->
|
||||||
if (!source.isEnabled || DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
|
if (DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
|
source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sourcesToDownload.isEmpty()) {
|
suspend fun filterServerAvailableSources(
|
||||||
return@withContext Result.success(Unit)
|
sources: List<DictionarySource>
|
||||||
|
): List<DictionarySource> = withContext(Dispatchers.IO) {
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
return@withContext emptyList()
|
||||||
|
}
|
||||||
|
sources
|
||||||
|
.map { source -> async { source to checker.checkSourceAvailability(source) } }
|
||||||
|
.awaitAll()
|
||||||
|
.filter { (_, availability) -> availability != SourceAvailability.NOT_FOUND }
|
||||||
|
.map { (source, _) -> source }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadSources(
|
||||||
|
sources: List<DictionarySource>,
|
||||||
|
onProgress: (Float) -> Unit
|
||||||
|
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
val folderUri = preferencesManager.dictionaryPath
|
||||||
|
val uri = Uri.fromFile(File(folderUri))
|
||||||
|
val files = fileStorageManager.listFilesInFolder(uri)
|
||||||
|
cleaner.deleteCorruptedArchives(uri, files)
|
||||||
|
|
||||||
|
synchronized(filesBeforeDownload) {
|
||||||
|
filesBeforeDownload = files.map { it.name }.toMutableSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized(downloadedPrefixes) {
|
synchronized(downloadedPrefixes) {
|
||||||
downloadedPrefixes = sourcesToDownload.mapNotNull {
|
downloadedPrefixes = sources.mapNotNull {
|
||||||
DictionarySource.extractPrefix(it.urlTemplate)
|
DictionarySource.extractPrefix(it.urlTemplate)
|
||||||
}.toMutableSet()
|
}.toMutableSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
downloader.downloadDictionaries(
|
downloader.downloadDictionaries(
|
||||||
folderUri = uri,
|
folderUri = uri,
|
||||||
sources = sourcesToDownload,
|
sources = sources,
|
||||||
onProgress = onProgress
|
onProgress = onProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-181
@@ -3,19 +3,19 @@ package com.example.research.feature.download.service
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import com.example.research.ReSearchApplication
|
import com.example.research.ReSearchApplication
|
||||||
import com.example.research.common.progress.renderTitle
|
import com.example.research.common.progress.DictionaryPipelineCoordinator
|
||||||
|
import com.example.research.common.progress.DictionaryProgressPresenter
|
||||||
import com.example.research.common.util.NotificationHelper
|
import com.example.research.common.util.NotificationHelper
|
||||||
import com.example.research.core.util.OperationResult
|
|
||||||
import com.example.research.data.repository.LocalDictionaryRepository
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.feature.download.DownloadManager
|
import com.example.research.feature.download.DownloadManager
|
||||||
import com.example.research.feature.download.model.DownloadState
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlinx.coroutines.cancel
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlinx.coroutines.cancelChildren
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
class DictionaryForegroundService : Service() {
|
class DictionaryForegroundService : Service() {
|
||||||
|
|
||||||
@@ -23,21 +23,21 @@ class DictionaryForegroundService : Service() {
|
|||||||
const val ACTION_START = "com.example.research.START_DOWNLOAD"
|
const val ACTION_START = "com.example.research.START_DOWNLOAD"
|
||||||
const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD"
|
const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD"
|
||||||
const val ACTION_IMPORT = "com.example.research.START_IMPORT"
|
const val ACTION_IMPORT = "com.example.research.START_IMPORT"
|
||||||
|
const val ACTION_FINISH = "com.example.research.FINISH_PIPELINE"
|
||||||
private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID
|
private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var downloadManager: DownloadManager
|
private lateinit var downloadManager: DownloadManager
|
||||||
private lateinit var localDictionaryRepository: LocalDictionaryRepository
|
private lateinit var localDictionaryRepository: LocalDictionaryRepository
|
||||||
private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager
|
private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager
|
||||||
private lateinit var progressStateHolder: com.example.research.common.progress.DictionaryProgressStateHolder
|
private lateinit var pipelineCoordinator: DictionaryPipelineCoordinator
|
||||||
|
private lateinit var presenter: DictionaryProgressPresenter
|
||||||
private val notificationHelper by lazy {
|
private val notificationHelper by lazy {
|
||||||
NotificationHelper(this)
|
NotificationHelper(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
@Volatile
|
@Volatile
|
||||||
private var isHandlingSuccess = false
|
|
||||||
@Volatile
|
|
||||||
private var latestStartId = 0
|
private var latestStartId = 0
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -47,164 +47,8 @@ class DictionaryForegroundService : Service() {
|
|||||||
downloadManager = app.downloadManager
|
downloadManager = app.downloadManager
|
||||||
localDictionaryRepository = app.localDictionaryRepository
|
localDictionaryRepository = app.localDictionaryRepository
|
||||||
dictionaryImportManager = app.dictionaryImportManager
|
dictionaryImportManager = app.dictionaryImportManager
|
||||||
progressStateHolder = app.dictionaryProgressStateHolder
|
pipelineCoordinator = app.dictionaryPipelineCoordinator
|
||||||
observeDownloadState()
|
presenter = app.dictionaryProgressPresenter
|
||||||
observeImportState()
|
|
||||||
observeUnifiedProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeDownloadState() {
|
|
||||||
serviceScope.launch {
|
|
||||||
downloadManager.downloadState.collect { state ->
|
|
||||||
when (state) {
|
|
||||||
is DownloadState.Success -> handleSuccess()
|
|
||||||
is DownloadState.Error -> {
|
|
||||||
notificationHelper.showErrorNotification()
|
|
||||||
stopForegroundService(removeNotification = false)
|
|
||||||
}
|
|
||||||
is DownloadState.Cancelled -> {
|
|
||||||
notificationHelper.cancelNotification()
|
|
||||||
stopForegroundService()
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeImportState() {
|
|
||||||
serviceScope.launch {
|
|
||||||
dictionaryImportManager.importState.collect { state ->
|
|
||||||
when (state) {
|
|
||||||
is com.example.research.ui.settings.ImportState.Success -> handleSuccess()
|
|
||||||
is com.example.research.ui.settings.ImportState.Error -> {
|
|
||||||
notificationHelper.showErrorNotification()
|
|
||||||
stopForegroundService(removeNotification = false)
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeUnifiedProgress() {
|
|
||||||
serviceScope.launch {
|
|
||||||
progressStateHolder.progressSnapshot
|
|
||||||
.collect { snapshot ->
|
|
||||||
if (snapshot != null) {
|
|
||||||
notificationHelper.showUnifiedProgressNotification(
|
|
||||||
title = snapshot.renderTitle(this@DictionaryForegroundService),
|
|
||||||
contentText = "${snapshot.percent}%",
|
|
||||||
progressPercent = snapshot.percent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun triggerReindexing(isImportFlow: Boolean) = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
waitForIndexingCompletion()
|
|
||||||
delay(1.seconds)
|
|
||||||
|
|
||||||
val app = application as? ReSearchApplication
|
|
||||||
?: return@withContext
|
|
||||||
val path = app.preferencesManager.dictionaryPath
|
|
||||||
if (isImportFlow) {
|
|
||||||
dictionaryImportManager.updateExtractionProgress(0f)
|
|
||||||
}
|
|
||||||
app.downloadDictionaryRepository.extractArchives(onProgress = { progress ->
|
|
||||||
if (isImportFlow) {
|
|
||||||
dictionaryImportManager.updateExtractionProgress(progress)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
val result = localDictionaryRepository.scanDirectory(path)
|
|
||||||
if (result is OperationResult.Success && result.data > 0) {
|
|
||||||
localDictionaryRepository.warmupIndexes()
|
|
||||||
app.downloadDictionaryRepository.performAllCleanup()
|
|
||||||
}
|
|
||||||
if (isImportFlow) {
|
|
||||||
dictionaryImportManager.markImportPipelineSuccess()
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("DictionaryForegroundService", "triggerReindexing() failed: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun waitForIndexingCompletion(): Boolean {
|
|
||||||
val isIndexing = localDictionaryRepository.indexingProgress.first().isIndexing
|
|
||||||
|
|
||||||
if (!isIndexing) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
var waitCount = 0
|
|
||||||
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < 100) {
|
|
||||||
delay(200.milliseconds)
|
|
||||||
waitCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
return waitCount < 100
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSuccess() {
|
|
||||||
if (isHandlingSuccess) return
|
|
||||||
isHandlingSuccess = true
|
|
||||||
serviceScope.launch {
|
|
||||||
try {
|
|
||||||
val isImportFlow = dictionaryImportManager.importState.value !is com.example.research.ui.settings.ImportState.Idle
|
|
||||||
|
|
||||||
if (isImportFlow) {
|
|
||||||
val app = application as? ReSearchApplication
|
|
||||||
val path = app?.preferencesManager?.dictionaryPath
|
|
||||||
val dictionariesDir = path?.let { java.io.File(it) }
|
|
||||||
val filesBeforeReindex = dictionariesDir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
|
|
||||||
|
|
||||||
try {
|
|
||||||
triggerReindexing(true)
|
|
||||||
dictionaryImportManager.getAndClearImportedFiles()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
val filesToCleanup = dictionaryImportManager.getAndClearImportedFiles()
|
|
||||||
filesToCleanup.forEach { file ->
|
|
||||||
try { if (file.exists()) file.delete() } catch (_: Exception) { /* Ignore cleanup failure */ }
|
|
||||||
}
|
|
||||||
if (dictionariesDir != null) {
|
|
||||||
cleanupNewFiles(dictionariesDir, filesBeforeReindex)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
triggerReindexing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isImportFlow) {
|
|
||||||
notificationHelper.showImportSuccessNotification()
|
|
||||||
dictionaryImportManager.clearImportState()
|
|
||||||
} else {
|
|
||||||
notificationHelper.showSuccessNotification()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e !is CancellationException) {
|
|
||||||
Log.e("DownloadForegroundService", "handleSuccess() failed: ${e.message}", e)
|
|
||||||
notificationHelper.showErrorNotification()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isHandlingSuccess = false
|
|
||||||
stopForegroundService(removeNotification = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cleanupNewFiles(dir: java.io.File, filesBeforeSnapshot: Set<String>) {
|
|
||||||
try {
|
|
||||||
dir.listFiles()?.forEach { file ->
|
|
||||||
if (file.name !in filesBeforeSnapshot) {
|
|
||||||
try { file.delete() } catch (_: Exception) { /* Ignore delete failure */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { /* Ignore directory listing failure */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
@@ -249,15 +93,20 @@ class DictionaryForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun stopForTimeout(startId: Int) {
|
private fun stopForTimeout(startId: Int) {
|
||||||
|
cancelAllWork()
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelfResult(startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelAllWork() {
|
||||||
serviceScope.coroutineContext[Job]?.cancelChildren()
|
serviceScope.coroutineContext[Job]?.cancelChildren()
|
||||||
|
pipelineCoordinator.cancelPipeline()
|
||||||
if (localDictionaryRepository.isIndexingInProgress()) {
|
if (localDictionaryRepository.isIndexingInProgress()) {
|
||||||
localDictionaryRepository.cancelIndexing()
|
localDictionaryRepository.cancelIndexing()
|
||||||
}
|
}
|
||||||
downloadManager.cancelDownload()
|
downloadManager.cancelDownload()
|
||||||
dictionaryImportManager.cancelImport()
|
dictionaryImportManager.cancelImport()
|
||||||
notificationHelper.cancelNotification()
|
presenter.cancelProgress()
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
|
||||||
stopSelfResult(startId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -275,21 +124,16 @@ class DictionaryForegroundService : Service() {
|
|||||||
ACTION_IMPORT -> {
|
ACTION_IMPORT -> {
|
||||||
startForegroundService()
|
startForegroundService()
|
||||||
}
|
}
|
||||||
|
ACTION_FINISH -> {
|
||||||
|
stopForegroundService(removeNotification = false)
|
||||||
|
}
|
||||||
ACTION_STOP -> {
|
ACTION_STOP -> {
|
||||||
try {
|
try {
|
||||||
startForegroundService()
|
startForegroundService()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Service may already be stopped
|
// Service may already be stopped
|
||||||
}
|
}
|
||||||
serviceScope.coroutineContext[Job]?.cancelChildren()
|
cancelAllWork()
|
||||||
|
|
||||||
if (localDictionaryRepository.isIndexingInProgress()) {
|
|
||||||
localDictionaryRepository.cancelIndexing()
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadManager.cancelDownload()
|
|
||||||
dictionaryImportManager.cancelImport()
|
|
||||||
notificationHelper.cancelNotification()
|
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.example.research.feature.import
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.progress.ImportFlowOperations
|
||||||
import com.example.research.common.util.SafeFileName
|
import com.example.research.common.util.SafeFileName
|
||||||
import com.example.research.core.performance.ReSearchTrace
|
import com.example.research.core.performance.ReSearchTrace
|
||||||
import com.example.research.ui.settings.ImportState
|
import com.example.research.ui.settings.ImportState
|
||||||
@@ -14,16 +15,19 @@ import java.util.Collections
|
|||||||
|
|
||||||
class DictionaryImportManager(
|
class DictionaryImportManager(
|
||||||
private val application: Application
|
private val application: Application
|
||||||
) {
|
) : ImportFlowOperations {
|
||||||
private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle)
|
private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle)
|
||||||
val importState: StateFlow<ImportState> = mutableImportState.asStateFlow()
|
val importState: StateFlow<ImportState> = mutableImportState.asStateFlow()
|
||||||
|
|
||||||
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var importJob: Job? = null
|
private var importJob: Job? = null
|
||||||
private val importedFiles = Collections.synchronizedList(mutableListOf<File>())
|
private val importedFiles = Collections.synchronizedList(mutableListOf<File>())
|
||||||
|
var onFlowStarted: (() -> Unit)? = null
|
||||||
|
var onTerminal: ((ImportState) -> Unit)? = null
|
||||||
|
|
||||||
fun importDictionaries(uris: List<Uri>) {
|
fun importDictionaries(uris: List<Uri>) {
|
||||||
importJob?.cancel()
|
importJob?.cancel()
|
||||||
|
onFlowStarted?.invoke()
|
||||||
importJob = managerScope.launch {
|
importJob = managerScope.launch {
|
||||||
mutableImportState.value = ImportState.Idle
|
mutableImportState.value = ImportState.Idle
|
||||||
performImport(uris)
|
performImport(uris)
|
||||||
@@ -36,6 +40,8 @@ class DictionaryImportManager(
|
|||||||
val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries")
|
val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries")
|
||||||
val totalFiles = uris.size.coerceAtLeast(1)
|
val totalFiles = uris.size.coerceAtLeast(1)
|
||||||
importedFiles.clear()
|
importedFiles.clear()
|
||||||
|
val skippedNames = mutableListOf<String>()
|
||||||
|
val deferredErrors = mutableListOf<String>()
|
||||||
|
|
||||||
if (!dictionariesDir.exists()) {
|
if (!dictionariesDir.exists()) {
|
||||||
dictionariesDir.mkdirs()
|
dictionariesDir.mkdirs()
|
||||||
@@ -46,9 +52,7 @@ class DictionaryImportManager(
|
|||||||
currentCoroutineContext().ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
val fileName = getFileName(uri) ?: continue
|
val fileName = getFileName(uri) ?: continue
|
||||||
if (SafeFileName.validate(fileName) == null) {
|
if (SafeFileName.validate(fileName) == null) {
|
||||||
mutableImportState.value = ImportState.Error(
|
deferredErrors += context.getString(R.string.import_invalid_file_name, fileName)
|
||||||
context.getString(R.string.import_invalid_file_name, fileName)
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val lowerFileName = fileName.lowercase()
|
val lowerFileName = fileName.lowercase()
|
||||||
@@ -65,9 +69,7 @@ class DictionaryImportManager(
|
|||||||
val destFile = File(dictionariesDir, fileName)
|
val destFile = File(dictionariesDir, fileName)
|
||||||
|
|
||||||
if (destFile.exists()) {
|
if (destFile.exists()) {
|
||||||
mutableImportState.value = ImportState.Error(
|
skippedNames += fileName
|
||||||
context.getString(R.string.import_file_exists, fileName)
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,16 +117,30 @@ class DictionaryImportManager(
|
|||||||
|
|
||||||
currentCoroutineContext().ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
|
|
||||||
mutableImportState.value = ImportState.Success
|
val terminalState = resolveImportOutcome(
|
||||||
|
importedCount = importedFiles.size,
|
||||||
|
skippedNames = skippedNames,
|
||||||
|
deferredErrors = deferredErrors,
|
||||||
|
).toImportState(
|
||||||
|
existsMessage = { names ->
|
||||||
|
context.getString(R.string.import_file_exists, names.joinToString(separator = ", "))
|
||||||
|
},
|
||||||
|
invalidMessage = { message -> message },
|
||||||
|
nothingImportedMessage = { context.getString(R.string.import_nothing_imported) },
|
||||||
|
)
|
||||||
|
mutableImportState.value = terminalState
|
||||||
|
onTerminal?.invoke(terminalState)
|
||||||
|
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
cleanupImportedFiles()
|
cleanupImportedFiles()
|
||||||
mutableImportState.value = ImportState.Idle
|
mutableImportState.value = ImportState.Idle
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
mutableImportState.value = ImportState.Error(
|
val errorState = ImportState.Error(
|
||||||
context.getString(R.string.import_error, e.message ?: "Unknown error")
|
context.getString(R.string.import_error, e.message ?: "Unknown error")
|
||||||
)
|
)
|
||||||
|
mutableImportState.value = errorState
|
||||||
|
onTerminal?.invoke(errorState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,18 +170,18 @@ class DictionaryImportManager(
|
|||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearImportState() {
|
override fun clearImportState() {
|
||||||
mutableImportState.value = ImportState.Idle
|
mutableImportState.value = ImportState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateExtractionProgress(progress: Float) {
|
override fun updateExtractionProgress(progress: Float) {
|
||||||
mutableImportState.update { current ->
|
mutableImportState.update { current ->
|
||||||
if (current is ImportState.Idle || current is ImportState.Error) current
|
if (current is ImportState.Idle || current is ImportState.Error) current
|
||||||
else ImportState.Extracting(progress.coerceIn(0f, 1f))
|
else ImportState.Extracting(progress.coerceIn(0f, 1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markImportPipelineSuccess() {
|
override fun markImportPipelineSuccess() {
|
||||||
mutableImportState.update { current ->
|
mutableImportState.update { current ->
|
||||||
if (current is ImportState.Idle || current is ImportState.Error) current
|
if (current is ImportState.Idle || current is ImportState.Error) current
|
||||||
else ImportState.Success
|
else ImportState.Success
|
||||||
@@ -179,7 +195,7 @@ class DictionaryImportManager(
|
|||||||
mutableImportState.value = ImportState.Idle
|
mutableImportState.value = ImportState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAndClearImportedFiles(): List<File> {
|
override fun getAndClearImportedFiles(): List<File> {
|
||||||
val files = importedFiles.toList()
|
val files = importedFiles.toList()
|
||||||
importedFiles.clear()
|
importedFiles.clear()
|
||||||
return files
|
return files
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.research.feature.import
|
||||||
|
|
||||||
|
import com.example.research.ui.settings.ImportState
|
||||||
|
|
||||||
|
internal sealed interface ImportOutcome {
|
||||||
|
data object Imported : ImportOutcome
|
||||||
|
data class AlreadyExists(val names: List<String>) : ImportOutcome
|
||||||
|
data class InvalidFile(val message: String) : ImportOutcome
|
||||||
|
data object NothingImported : ImportOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resolveImportOutcome(
|
||||||
|
importedCount: Int,
|
||||||
|
skippedNames: List<String>,
|
||||||
|
deferredErrors: List<String>,
|
||||||
|
): ImportOutcome = when {
|
||||||
|
importedCount > 0 -> ImportOutcome.Imported
|
||||||
|
skippedNames.isNotEmpty() -> ImportOutcome.AlreadyExists(skippedNames)
|
||||||
|
deferredErrors.isNotEmpty() -> ImportOutcome.InvalidFile(deferredErrors.first())
|
||||||
|
else -> ImportOutcome.NothingImported
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ImportOutcome.toImportState(
|
||||||
|
existsMessage: (List<String>) -> String,
|
||||||
|
invalidMessage: (String) -> String,
|
||||||
|
nothingImportedMessage: () -> String,
|
||||||
|
): ImportState = when (this) {
|
||||||
|
ImportOutcome.Imported -> ImportState.Success
|
||||||
|
is ImportOutcome.AlreadyExists -> ImportState.Error(existsMessage(names))
|
||||||
|
is ImportOutcome.InvalidFile -> ImportState.Error(invalidMessage(message))
|
||||||
|
ImportOutcome.NothingImported -> ImportState.Error(nothingImportedMessage())
|
||||||
|
}
|
||||||
@@ -85,7 +85,12 @@ class SearchViewModel(
|
|||||||
|
|
||||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
val searchResults: Flow<PagingData<IndexEntry>> = combine(
|
val searchResults: Flow<PagingData<IndexEntry>> = combine(
|
||||||
searchQuery.debounce(300.milliseconds).distinctUntilChanged(),
|
searchQuery
|
||||||
|
.map { it to it.isBlank() }
|
||||||
|
.distinctUntilChangedBy { it.first }
|
||||||
|
.flatMapLatest { (query, isBlank) ->
|
||||||
|
if (isBlank) flowOf(query) else flowOf(query).debounce(300.milliseconds)
|
||||||
|
},
|
||||||
activeDictionaries
|
activeDictionaries
|
||||||
) { query, dictionaries ->
|
) { query, dictionaries ->
|
||||||
query to dictionaries
|
query to dictionaries
|
||||||
|
|||||||
@@ -78,6 +78,4 @@ internal object AboutLibrariesParser {
|
|||||||
context.resources.openRawResource(resourceId)
|
context.resources.openRawResource(resourceId)
|
||||||
.bufferedReader()
|
.bufferedReader()
|
||||||
.use { reader -> json.decodeFromString(reader.readText()) }
|
.use { reader -> json.decodeFromString(reader.readText()) }
|
||||||
|
|
||||||
fun decode(source: String): AboutLibrariesData = json.decodeFromString(source)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.example.research.ui.about
|
package com.example.research.ui.about
|
||||||
|
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
@@ -27,6 +26,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -43,6 +43,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
import com.example.research.common.ui.components.OutlinedChoiceButton
|
import com.example.research.common.ui.components.OutlinedChoiceButton
|
||||||
import com.example.research.ui.theme.AppWindowInsets
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -57,9 +59,11 @@ fun AboutScreen(
|
|||||||
}
|
}
|
||||||
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
|
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val librariesData = remember(context) {
|
val librariesData by produceState<AboutLibrariesData?>(initialValue = null, context) {
|
||||||
|
value = withContext(Dispatchers.IO) {
|
||||||
AboutLibrariesParser.read(context, R.raw.aboutlibraries)
|
AboutLibrariesParser.read(context, R.raw.aboutlibraries)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
|
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
|
||||||
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
|
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
|
||||||
|
|
||||||
@@ -93,12 +97,12 @@ fun AboutScreen(
|
|||||||
AboutHeader(versionName = versionName)
|
AboutHeader(versionName = versionName)
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = librariesData.libraries,
|
items = librariesData?.libraries.orEmpty(),
|
||||||
key = AboutLibrary::uniqueId,
|
key = AboutLibrary::uniqueId,
|
||||||
) { library ->
|
) { library ->
|
||||||
AboutLibraryRow(
|
AboutLibraryRow(
|
||||||
library = library,
|
library = library,
|
||||||
licenses = librariesData.licenses,
|
licenses = librariesData?.licenses.orEmpty(),
|
||||||
expanded = expandedLibraryId == library.uniqueId,
|
expanded = expandedLibraryId == library.uniqueId,
|
||||||
onToggle = {
|
onToggle = {
|
||||||
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
|
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
|
||||||
@@ -191,8 +195,7 @@ private fun AboutLibraryRow(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
.animateContentSize(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ object DslAnnotatedParser {
|
|||||||
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
|
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
|
||||||
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
|
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
|
||||||
)
|
)
|
||||||
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
|
|
||||||
|
|
||||||
data class ColorScheme(
|
data class ColorScheme(
|
||||||
val secondaryText: Color,
|
val secondaryText: Color,
|
||||||
@@ -101,7 +100,6 @@ object DslAnnotatedParser {
|
|||||||
'\\' -> {
|
'\\' -> {
|
||||||
if (i + 1 < length) {
|
if (i + 1 < length) {
|
||||||
val next = dsl[i + 1]
|
val next = dsl[i + 1]
|
||||||
if (next in ESCAPED_CHARS) {
|
|
||||||
builder.append(next)
|
builder.append(next)
|
||||||
if (refStack.isNotEmpty()) {
|
if (refStack.isNotEmpty()) {
|
||||||
refStack.last().second.append(next)
|
refStack.last().second.append(next)
|
||||||
@@ -110,7 +108,6 @@ object DslAnnotatedParser {
|
|||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
builder.append(char)
|
builder.append(char)
|
||||||
if (refStack.isNotEmpty()) {
|
if (refStack.isNotEmpty()) {
|
||||||
refStack.last().second.append(char)
|
refStack.last().second.append(char)
|
||||||
@@ -119,7 +116,7 @@ object DslAnnotatedParser {
|
|||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
'[' -> {
|
'[' -> {
|
||||||
val end = dsl.indexOf(']', i + 1)
|
val end = tagCloseIndex(dsl, i + 1)
|
||||||
if (end != -1) {
|
if (end != -1) {
|
||||||
val tagStart = i + 1
|
val tagStart = i + 1
|
||||||
var tagEnd = end
|
var tagEnd = end
|
||||||
@@ -228,6 +225,19 @@ object DslAnnotatedParser {
|
|||||||
splitOversizedBlocks(result)
|
splitOversizedBlocks(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun tagCloseIndex(value: String, startIndex: Int): Int {
|
||||||
|
var index = startIndex
|
||||||
|
while (index < value.length) {
|
||||||
|
when {
|
||||||
|
value[index] == '\\' && index + 1 < value.length -> index += 2
|
||||||
|
value[index] == '[' -> return -1
|
||||||
|
value[index] == ']' -> return index
|
||||||
|
else -> index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
|
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
|
||||||
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
|
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
|
||||||
return blocks.flatMap { block ->
|
return blocks.flatMap { block ->
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
@@ -135,7 +136,9 @@ private fun SearchField(
|
|||||||
Icon(
|
Icon(
|
||||||
painter = searchIcon,
|
painter = searchIcon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier
|
||||||
|
.offset(x = 4.dp)
|
||||||
|
.size(24.dp)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = if (searchState.text.isNotEmpty()) {
|
trailingIcon = if (searchState.text.isNotEmpty()) {
|
||||||
@@ -146,6 +149,7 @@ private fun SearchField(
|
|||||||
onClearQuery()
|
onClearQuery()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.padding(end = 12.dp)
|
||||||
.testTag("clear_search")
|
.testTag("clear_search")
|
||||||
.semantics { testTagsAsResourceId = true }
|
.semantics { testTagsAsResourceId = true }
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ fun SearchResultsList(
|
|||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
count = results.itemCount,
|
count = results.itemCount,
|
||||||
|
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
|
||||||
contentType = results.itemContentType { "search_result" }
|
contentType = results.itemContentType { "search_result" }
|
||||||
) { index ->
|
) { index ->
|
||||||
results[index]?.let { entry ->
|
results[index]?.let { entry ->
|
||||||
|
|||||||
@@ -2,22 +2,18 @@ package com.example.research.ui.navigation
|
|||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.*
|
import androidx.compose.runtime.saveable.*
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
|
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
|
||||||
import com.example.research.feature.search.SearchAction
|
import com.example.research.feature.search.SearchAction
|
||||||
import com.example.research.feature.search.SearchViewModel
|
import com.example.research.feature.search.SearchViewModel
|
||||||
import com.example.research.feature.download.model.DownloadState
|
|
||||||
import com.example.research.ui.about.AboutScreen
|
import com.example.research.ui.about.AboutScreen
|
||||||
import com.example.research.ui.article.ArticleRoute
|
import com.example.research.ui.article.ArticleRoute
|
||||||
import com.example.research.ui.main.MainRoute
|
import com.example.research.ui.main.MainRoute
|
||||||
import com.example.research.ui.settings.ImportState
|
|
||||||
import com.example.research.ui.settings.SettingsRoute
|
import com.example.research.ui.settings.SettingsRoute
|
||||||
import com.example.research.ui.settings.SettingsViewModel
|
import com.example.research.ui.settings.SettingsViewModel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
private data class NavigationState(
|
private data class NavigationState(
|
||||||
val shouldShowSettings: Boolean,
|
val shouldShowSettings: Boolean,
|
||||||
@@ -33,7 +29,8 @@ enum class Screen {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
searchViewModel: SearchViewModel,
|
searchViewModel: SearchViewModel,
|
||||||
settingsViewModel: SettingsViewModel
|
settingsViewModel: SettingsViewModel,
|
||||||
|
seedNoDictionaries: Boolean = false
|
||||||
) {
|
) {
|
||||||
val screenStack = rememberSaveable(
|
val screenStack = rememberSaveable(
|
||||||
saver = listSaver(
|
saver = listSaver(
|
||||||
@@ -53,39 +50,15 @@ fun AppNavigation(
|
|||||||
val currentScreen = screenStack.lastOrNull() ?: Screen.Home
|
val currentScreen = screenStack.lastOrNull() ?: Screen.Home
|
||||||
|
|
||||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val progressSnapshot by settingsViewModel.progressSnapshot.collectAsStateWithLifecycle()
|
|
||||||
val isPipelineActiveByState = remember(
|
|
||||||
progressSnapshot,
|
|
||||||
settingsState.indexingProgress.isIndexing,
|
|
||||||
settingsState.downloadState,
|
|
||||||
settingsState.importState,
|
|
||||||
) {
|
|
||||||
progressSnapshot != null ||
|
|
||||||
settingsState.indexingProgress.isIndexing ||
|
|
||||||
settingsState.downloadState is DownloadState.Loading ||
|
|
||||||
settingsState.downloadState is DownloadState.Extracting ||
|
|
||||||
settingsState.downloadState is DownloadState.Success ||
|
|
||||||
settingsState.importState is ImportState.Importing ||
|
|
||||||
settingsState.importState is ImportState.Extracting ||
|
|
||||||
settingsState.importState is ImportState.Success
|
|
||||||
}
|
|
||||||
var stickyPipelineActive by rememberSaveable { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(isPipelineActiveByState) {
|
|
||||||
if (isPipelineActiveByState) {
|
|
||||||
stickyPipelineActive = true
|
|
||||||
} else {
|
|
||||||
delay(1500.milliseconds)
|
|
||||||
stickyPipelineActive = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val navState by remember {
|
val navState by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val isOperationActive = stickyPipelineActive
|
val isOperationActive = !settingsState.pipelineIdle
|
||||||
val hasConfirmedNoDictionaries =
|
val hasConfirmedNoDictionaries =
|
||||||
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
|
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
|
||||||
|
val hasSeededNoDictionaries = seedNoDictionaries && !settingsState.hasCompletedStartupScan
|
||||||
NavigationState(
|
NavigationState(
|
||||||
shouldShowSettings = hasConfirmedNoDictionaries || isOperationActive,
|
shouldShowSettings = hasConfirmedNoDictionaries || hasSeededNoDictionaries || isOperationActive,
|
||||||
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
|
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
|
||||||
isOperationActive = isOperationActive,
|
isOperationActive = isOperationActive,
|
||||||
)
|
)
|
||||||
@@ -95,7 +68,7 @@ fun AppNavigation(
|
|||||||
val shouldShowSettings = navState.shouldShowSettings
|
val shouldShowSettings = navState.shouldShowSettings
|
||||||
val showBackButtonInSettings = navState.showBackButtonInSettings
|
val showBackButtonInSettings = navState.showBackButtonInSettings
|
||||||
val isOperationActive = navState.isOperationActive
|
val isOperationActive = navState.isOperationActive
|
||||||
val isWideScreen = currentWindowAdaptiveInfo()
|
val isWideScreen = currentWindowAdaptiveInfoV2()
|
||||||
.windowSizeClass
|
.windowSizeClass
|
||||||
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
|
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.example.research.ui.settings
|
package com.example.research.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface ImportState {
|
sealed interface ImportState {
|
||||||
data object Idle : ImportState
|
data object Idle : ImportState
|
||||||
data class Importing(val progress: Float) : ImportState
|
data class Importing(val progress: Float) : ImportState
|
||||||
@@ -7,3 +10,6 @@ sealed interface ImportState {
|
|||||||
data object Success : ImportState
|
data object Success : ImportState
|
||||||
data class Error(val message: String) : ImportState
|
data class Error(val message: String) : ImportState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val ImportState.isActive: Boolean
|
||||||
|
get() = this is ImportState.Importing || this is ImportState.Extracting || this is ImportState.Success
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.research.ui.settings
|
package com.example.research.ui.settings
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import com.example.research.core.domain.model.AppTheme
|
import com.example.research.core.domain.model.AppTheme
|
||||||
import com.example.research.core.domain.model.Dictionary
|
import com.example.research.core.domain.model.Dictionary
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
@@ -8,6 +9,7 @@ import com.example.research.core.domain.model.IndexingProgress
|
|||||||
import com.example.research.DictionaryStatus
|
import com.example.research.DictionaryStatus
|
||||||
import com.example.research.feature.download.model.DownloadState
|
import com.example.research.feature.download.model.DownloadState
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val theme: AppTheme = AppTheme.SYSTEM,
|
val theme: AppTheme = AppTheme.SYSTEM,
|
||||||
val language: String = "system",
|
val language: String = "system",
|
||||||
@@ -21,6 +23,7 @@ data class SettingsUiState(
|
|||||||
val importState: ImportState = ImportState.Idle,
|
val importState: ImportState = ImportState.Idle,
|
||||||
val dictionarySources: List<DictionarySource> = emptyList(),
|
val dictionarySources: List<DictionarySource> = emptyList(),
|
||||||
val hasCompletedStartupScan: Boolean = false,
|
val hasCompletedStartupScan: Boolean = false,
|
||||||
|
val pipelineIdle: Boolean = true,
|
||||||
val appVersion: String = "1.0",
|
val appVersion: String = "1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.example.research.core.domain.model.AppTheme
|
|||||||
import com.example.research.core.domain.model.Dictionary
|
import com.example.research.core.domain.model.Dictionary
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
import com.example.research.core.domain.model.IndexingProgress
|
import com.example.research.core.domain.model.IndexingProgress
|
||||||
|
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
|
||||||
import com.example.research.core.domain.usecase.DictionarySourceValidator
|
import com.example.research.core.domain.usecase.DictionarySourceValidator
|
||||||
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
|
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
|
||||||
import com.example.research.core.util.OperationResult
|
import com.example.research.core.util.OperationResult
|
||||||
@@ -18,6 +19,7 @@ import com.example.research.data.local.preferences.PreferencesManager
|
|||||||
import com.example.research.data.repository.LocalDictionaryRepository
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.feature.download.DownloadManager
|
import com.example.research.feature.download.DownloadManager
|
||||||
import com.example.research.feature.download.model.DownloadState
|
import com.example.research.feature.download.model.DownloadState
|
||||||
|
import com.example.research.feature.download.model.isActive
|
||||||
import com.example.research.feature.download.repository.DictionaryRepository
|
import com.example.research.feature.download.repository.DictionaryRepository
|
||||||
import com.example.research.feature.import.DictionaryImportManager
|
import com.example.research.feature.import.DictionaryImportManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -30,7 +32,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
private data class DictionaryStateInputs(
|
private data class DictionaryStateInputs(
|
||||||
val theme: AppTheme,
|
val theme: AppTheme,
|
||||||
@@ -79,16 +81,29 @@ class SettingsViewModel(
|
|||||||
private val language = MutableStateFlow("system")
|
private val language = MutableStateFlow("system")
|
||||||
private val hasCompletedStartupScan = MutableStateFlow(false)
|
private val hasCompletedStartupScan = MutableStateFlow(false)
|
||||||
|
|
||||||
private val pendingSourceUrls = mutableSetOf<String>()
|
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
|
||||||
private var isDownloadInProgress = false
|
private var isDownloadInProgress = false
|
||||||
|
|
||||||
private var cancelRefreshPending = false
|
private var cancelRefreshPending = false
|
||||||
|
private var errorStateHandled = false
|
||||||
|
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupStateObservation()
|
setupStateObservation()
|
||||||
|
observeDictionariesForSeedFlag()
|
||||||
initialize()
|
initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeDictionariesForSeedFlag() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
localDictionaryRepository.dictionaries.collect { dictionaries ->
|
||||||
|
if (hasCompletedStartupScan.value) {
|
||||||
|
preferencesManager.setHadNoDictionaries(dictionaries.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupStateObservation() {
|
private fun setupStateObservation() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
var wasIndexing = false
|
var wasIndexing = false
|
||||||
@@ -146,31 +161,30 @@ class SettingsViewModel(
|
|||||||
is DownloadState.Loading, is DownloadState.Extracting -> {
|
is DownloadState.Loading, is DownloadState.Extracting -> {
|
||||||
isDownloadInProgress = true
|
isDownloadInProgress = true
|
||||||
cancelRefreshPending = false
|
cancelRefreshPending = false
|
||||||
|
errorStateHandled = false
|
||||||
}
|
}
|
||||||
is DownloadState.Success -> {
|
is DownloadState.Success -> {
|
||||||
pendingSourceUrls.clear()
|
pendingSourceUrls.clear()
|
||||||
}
|
}
|
||||||
is DownloadState.Error -> {
|
is DownloadState.Error -> {
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
|
if (!errorStateHandled) {
|
||||||
|
errorStateHandled = true
|
||||||
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
||||||
DictionaryStatus.Empty
|
DictionaryStatus.Empty
|
||||||
} else {
|
} else when (statusBeforeDownload) {
|
||||||
DictionaryStatus.UpToDate
|
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
|
||||||
|
else -> statusBeforeDownload
|
||||||
}
|
}
|
||||||
if (pendingSourceUrls.isNotEmpty()) {
|
if (pendingSourceUrls.isNotEmpty()) {
|
||||||
|
|
||||||
pendingSourceUrls.forEach { urlTemplate ->
|
pendingSourceUrls.forEach { urlTemplate ->
|
||||||
val source = dictionaryState.dictionarySources.find {
|
val source = dictionaryState.dictionarySources.find {
|
||||||
it.urlTemplate == urlTemplate
|
it.urlTemplate == urlTemplate
|
||||||
}
|
}
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
val hasDictionary = dictionaryState.dictionaries.any { dict ->
|
val hasDictionary = dictionaryState.dictionaries.any { dictionary ->
|
||||||
DictionarySource.matchesDictionaryFile(
|
DictionarySourceFileMatcher.matches(source, dictionary)
|
||||||
urlTemplate,
|
|
||||||
File(dict.path).name
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasDictionary) {
|
if (!hasDictionary) {
|
||||||
manageDictionarySourcesUseCase.removeSource(source.id)
|
manageDictionarySourcesUseCase.removeSource(source.id)
|
||||||
}
|
}
|
||||||
@@ -179,6 +193,7 @@ class SettingsViewModel(
|
|||||||
pendingSourceUrls.clear()
|
pendingSourceUrls.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
is DownloadState.Cancelled -> {
|
is DownloadState.Cancelled -> {
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
cancelRefreshPending = true
|
cancelRefreshPending = true
|
||||||
@@ -211,6 +226,9 @@ class SettingsViewModel(
|
|||||||
importState = operationState.importState,
|
importState = operationState.importState,
|
||||||
dictionaryStatus = operationState.dictionaryStatus,
|
dictionaryStatus = operationState.dictionaryStatus,
|
||||||
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
|
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
|
||||||
|
pipelineIdle = !isIndexing &&
|
||||||
|
!operationState.downloadState.isActive &&
|
||||||
|
!operationState.importState.isActive,
|
||||||
isThemeExpanded = operationState.isThemeExpanded,
|
isThemeExpanded = operationState.isThemeExpanded,
|
||||||
isLanguageExpanded = operationState.isLanguageExpanded,
|
isLanguageExpanded = operationState.isLanguageExpanded,
|
||||||
isDictionariesExpanded = operationState.isDictionariesExpanded,
|
isDictionariesExpanded = operationState.isDictionariesExpanded,
|
||||||
@@ -242,6 +260,9 @@ class SettingsViewModel(
|
|||||||
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
|
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
|
||||||
}
|
}
|
||||||
hasCompletedStartupScan.value = true
|
hasCompletedStartupScan.value = true
|
||||||
|
preferencesManager.setHadNoDictionaries(
|
||||||
|
localDictionaryRepository.dictionaries.value.isEmpty()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,18 +285,21 @@ class SettingsViewModel(
|
|||||||
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
|
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
|
||||||
return try {
|
return try {
|
||||||
val actualDictionaries = localDictionaryRepository.dictionaries.first()
|
val actualDictionaries = localDictionaryRepository.dictionaries.first()
|
||||||
val hasDictionaries = actualDictionaries.isNotEmpty()
|
|
||||||
|
|
||||||
if (!hasDictionaries) {
|
|
||||||
return DictionaryStatus.Empty
|
|
||||||
}
|
|
||||||
|
|
||||||
var sources = preferencesManager.dictionarySources.first()
|
var sources = preferencesManager.dictionarySources.first()
|
||||||
if (downloadManager.downloadState.value !is DownloadState.Loading &&
|
if (pendingSourceUrls.isEmpty() &&
|
||||||
|
downloadManager.downloadState.value !is DownloadState.Loading &&
|
||||||
downloadManager.downloadState.value !is DownloadState.Extracting
|
downloadManager.downloadState.value !is DownloadState.Extracting
|
||||||
) {
|
) {
|
||||||
val installedSources = installedSources(sources, actualDictionaries)
|
val dictionaryFileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
|
||||||
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id)
|
preferencesManager.dictionaryPath
|
||||||
|
)
|
||||||
|
if (dictionaryFileNames != null) {
|
||||||
|
val installedSources = DictionarySourceFileMatcher.installedSourcesForFileNames(
|
||||||
|
sources,
|
||||||
|
dictionaryFileNames,
|
||||||
|
)
|
||||||
|
val installedSourceIds = installedSources
|
||||||
|
.mapTo(mutableSetOf(), DictionarySource::id)
|
||||||
val staleSourceIds = sources
|
val staleSourceIds = sources
|
||||||
.filterNot { it.id in installedSourceIds }
|
.filterNot { it.id in installedSourceIds }
|
||||||
.map(DictionarySource::id)
|
.map(DictionarySource::id)
|
||||||
@@ -284,6 +308,12 @@ class SettingsViewModel(
|
|||||||
sources = installedSources
|
sources = installedSources
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualDictionaries.isEmpty()) {
|
||||||
|
return DictionaryStatus.Empty
|
||||||
|
}
|
||||||
|
|
||||||
val enabledSources = installedEnabledSources(sources, actualDictionaries)
|
val enabledSources = installedEnabledSources(sources, actualDictionaries)
|
||||||
|
|
||||||
if (enabledSources.isEmpty()) {
|
if (enabledSources.isEmpty()) {
|
||||||
@@ -338,21 +368,31 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
private fun deleteDictionary(dictionary: Dictionary) {
|
private fun deleteDictionary(dictionary: Dictionary) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localDictionaryRepository.deleteDictionary(dictionary)
|
when (localDictionaryRepository.deleteDictionary(dictionary)) {
|
||||||
|
is OperationResult.Success -> {
|
||||||
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary)
|
val remainingDictionaryFileNames =
|
||||||
|
localDictionaryRepository.listDictionaryPayloadFileNames(
|
||||||
val remainingDictionaries = localDictionaryRepository.dictionaries.first()
|
preferencesManager.dictionaryPath
|
||||||
|
)
|
||||||
if (remainingDictionaries.isEmpty()) {
|
manageDictionarySourcesUseCase.removeSourceForDictionary(
|
||||||
dictionaryStatus.value = DictionaryStatus.Empty
|
dictionary = dictionary,
|
||||||
|
remainingDictionaryFileNames = remainingDictionaryFileNames,
|
||||||
|
)
|
||||||
|
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||||
|
evaluateDictionaryStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is OperationResult.Error -> {
|
||||||
|
effectChannel.send(
|
||||||
|
getApplication<Application>().getString(R.string.error_delete_dictionary)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addDictionarySources(urlTemplates: List<String>) {
|
private fun addDictionarySources(urlTemplates: List<String>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
|
||||||
val validUrls = mutableListOf<String>()
|
val validUrls = mutableListOf<String>()
|
||||||
|
|
||||||
urlTemplates.forEach { urlTemplate ->
|
urlTemplates.forEach { urlTemplate ->
|
||||||
@@ -361,23 +401,30 @@ class SettingsViewModel(
|
|||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val normalizedUrl = DictionarySource.normalizeTemplate(trimmed)
|
||||||
|
if (!pendingSourceUrls.add(normalizedUrl)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
|
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
|
||||||
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
|
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
|
||||||
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
|
|
||||||
validUrls.add(trimmed)
|
validUrls.add(trimmed)
|
||||||
}
|
}
|
||||||
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
|
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
|
||||||
|
pendingSourceUrls.remove(normalizedUrl)
|
||||||
// Validation failed, skip this source
|
// Validation failed, skip this source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingSourceUrls.remove(normalizedUrl)
|
||||||
|
android.util.Log.e("SettingsViewModel", "Error adding dictionary source", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validUrls.isNotEmpty()) {
|
if (validUrls.isNotEmpty()) {
|
||||||
startDownloadForSources(validUrls)
|
startDownloadForSources(validUrls)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +437,7 @@ class SettingsViewModel(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusBeforeDownload = dictionaryStatus.value
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
isDownloadInProgress = true
|
isDownloadInProgress = true
|
||||||
|
|
||||||
@@ -413,6 +461,7 @@ class SettingsViewModel(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusBeforeDownload = dictionaryStatus.value
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
|
|
||||||
val installedSources = installedEnabledSources(
|
val installedSources = installedEnabledSources(
|
||||||
@@ -448,14 +497,8 @@ class SettingsViewModel(
|
|||||||
private fun installedSources(
|
private fun installedSources(
|
||||||
sources: List<DictionarySource>,
|
sources: List<DictionarySource>,
|
||||||
dictionaries: List<Dictionary>
|
dictionaries: List<Dictionary>
|
||||||
): List<DictionarySource> {
|
): List<DictionarySource> =
|
||||||
val installedFileNames = dictionaries.map { File(it.path).name }
|
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
|
||||||
return sources.filter { source ->
|
|
||||||
installedFileNames.any { fileName ->
|
|
||||||
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelDownload() {
|
private fun cancelDownload() {
|
||||||
// Cancel the ViewModel-side scan job so no further status recomputes
|
// Cancel the ViewModel-side scan job so no further status recomputes
|
||||||
@@ -501,10 +544,11 @@ class SettingsViewModel(
|
|||||||
is OperationResult.Success -> {
|
is OperationResult.Success -> {
|
||||||
val dictionaries = localDictionaryRepository.dictionaries.first()
|
val dictionaries = localDictionaryRepository.dictionaries.first()
|
||||||
if (dictionaries.isEmpty()) {
|
if (dictionaries.isEmpty()) {
|
||||||
dictionaryStatus.value = DictionaryStatus.Empty
|
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||||
|
evaluateDictionaryStatus()
|
||||||
|
}
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
} else {
|
} else if (isDownloadInProgress) {
|
||||||
if (isDownloadInProgress) {
|
|
||||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
} else {
|
} else {
|
||||||
@@ -512,7 +556,6 @@ class SettingsViewModel(
|
|||||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
||||||
dictionaryStatus.value = status
|
dictionaryStatus.value = status
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (result.data > 0) {
|
if (result.data > 0) {
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
|||||||
+55
-18
@@ -28,8 +28,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
@@ -81,7 +83,8 @@ fun DictionaryListItem(
|
|||||||
dictionary: Dictionary,
|
dictionary: Dictionary,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
isDeleteBlocked: () -> Boolean = { false }
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -93,7 +96,10 @@ fun DictionaryListItem(
|
|||||||
val offsetAnim = remember { Animatable(0f) }
|
val offsetAnim = remember { Animatable(0f) }
|
||||||
|
|
||||||
var rawOffset by remember { mutableFloatStateOf(0f) }
|
var rawOffset by remember { mutableFloatStateOf(0f) }
|
||||||
val isDeleteRevealed by remember { derivedStateOf { rawOffset <= -swipeThresholdPx } }
|
val isDeleteBlockedCurrent by rememberUpdatedState(isDeleteBlocked)
|
||||||
|
val isDeleteRevealed by remember {
|
||||||
|
derivedStateOf { !isDeleteBlockedCurrent() && rawOffset <= -swipeThresholdPx }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(isDeleteRevealed) {
|
LaunchedEffect(isDeleteRevealed) {
|
||||||
if (isDeleteRevealed) {
|
if (isDeleteRevealed) {
|
||||||
@@ -101,6 +107,16 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { isDeleteBlockedCurrent() }
|
||||||
|
.collect { blocked ->
|
||||||
|
if (blocked) {
|
||||||
|
offsetAnim.snapTo(0f)
|
||||||
|
rawOffset = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val currentOnDelete by rememberUpdatedState(onDelete)
|
val currentOnDelete by rememberUpdatedState(onDelete)
|
||||||
val currentOnToggle by rememberUpdatedState(onToggle)
|
val currentOnToggle by rememberUpdatedState(onToggle)
|
||||||
|
|
||||||
@@ -121,6 +137,8 @@ fun DictionaryListItem(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.height(DictionaryItemHeight)
|
||||||
|
.clipToBounds()
|
||||||
.testTag("dictionary_item")
|
.testTag("dictionary_item")
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -132,12 +150,15 @@ fun DictionaryListItem(
|
|||||||
.pointerInput(maxSwipePx, swipeThresholdPx) {
|
.pointerInput(maxSwipePx, swipeThresholdPx) {
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
|
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
|
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
|
||||||
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
|
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
|
||||||
rawOffset = target
|
rawOffset = target
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (isDeleteBlockedCurrent()) return@launch
|
||||||
offsetAnim.animateTo(
|
offsetAnim.animateTo(
|
||||||
target,
|
target,
|
||||||
animationSpec = spring(stiffness = Spring.StiffnessMedium)
|
animationSpec = spring(stiffness = Spring.StiffnessMedium)
|
||||||
@@ -145,9 +166,11 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
|
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
|
||||||
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
|
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
|
||||||
rawOffset = target
|
rawOffset = target
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (isDeleteBlockedCurrent()) return@launch
|
||||||
offsetAnim.animateTo(
|
offsetAnim.animateTo(
|
||||||
target,
|
target,
|
||||||
animationSpec = spring(stiffness = Spring.StiffnessMedium)
|
animationSpec = spring(stiffness = Spring.StiffnessMedium)
|
||||||
@@ -155,9 +178,13 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHorizontalDrag = { _, dragAmount ->
|
onHorizontalDrag = { _, dragAmount ->
|
||||||
|
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
|
||||||
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
|
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
|
||||||
rawOffset = newOffset
|
rawOffset = newOffset
|
||||||
scope.launch { offsetAnim.snapTo(newOffset) }
|
scope.launch {
|
||||||
|
if (isDeleteBlockedCurrent()) return@launch
|
||||||
|
offsetAnim.snapTo(newOffset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -222,25 +249,11 @@ fun DictionaryListItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDeleteRevealed) {
|
if (isDeleteRevealed) {
|
||||||
IconButton(
|
Box(
|
||||||
onClick = {
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
currentOnDelete()
|
|
||||||
scope.launch { offsetAnim.snapTo(0f) }
|
|
||||||
rawOffset = 0f
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(end = ItemHorizontalPadding)
|
.padding(end = ItemHorizontalPadding)
|
||||||
.graphicsLayer { translationX = -offsetAnim.value }
|
|
||||||
.size(IconButtonSize)
|
.size(IconButtonSize)
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_delete),
|
|
||||||
contentDescription = stringResource(R.string.dictionary_delete),
|
|
||||||
tint = DeleteIconColor,
|
|
||||||
modifier = Modifier.size(IconSize)
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Switch(
|
Switch(
|
||||||
checked = dictionary.isActive,
|
checked = dictionary.isActive,
|
||||||
@@ -257,5 +270,29 @@ fun DictionaryListItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDeleteRevealed) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
currentOnDelete()
|
||||||
|
scope.launch {
|
||||||
|
offsetAnim.snapTo(0f)
|
||||||
|
rawOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.padding(end = ItemHorizontalPadding)
|
||||||
|
.size(IconButtonSize)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_delete),
|
||||||
|
contentDescription = stringResource(R.string.dictionary_delete),
|
||||||
|
tint = DeleteIconColor,
|
||||||
|
modifier = Modifier.size(IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-8
@@ -11,8 +11,10 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
@@ -21,6 +23,7 @@ import androidx.compose.ui.platform.LocalFocusManager
|
|||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.invisibleToUser
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -75,6 +78,13 @@ fun DictionaryManagement(
|
|||||||
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
|
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
|
||||||
}
|
}
|
||||||
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
|
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
|
||||||
|
val isBlockingMutations = isInProgress
|
||||||
|
|
||||||
|
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
|
||||||
|
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
|
||||||
|
// (e.g. Modifier.alpha(if (isDeleteBlocked()) ...)) would invalidate every row on each flip.
|
||||||
|
val isMutationsBlockedState = rememberUpdatedState(isBlockingMutations)
|
||||||
|
val isDeleteBlocked = remember { { isMutationsBlockedState.value } }
|
||||||
|
|
||||||
SectionCard(
|
SectionCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -125,13 +135,13 @@ fun DictionaryManagement(
|
|||||||
|
|
||||||
// Disable accessibility on background content during import to improve performance.
|
// Disable accessibility on background content during import to improve performance.
|
||||||
// The progress dialog remains accessible for cancellation.
|
// The progress dialog remains accessible for cancellation.
|
||||||
val backgroundModifier = if (isInProgress) {
|
val backgroundModifier = if (isBlockingMutations) {
|
||||||
Modifier.semantics(mergeDescendants = true) { }
|
Modifier.semantics { invisibleToUser() }
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dictionaries.isEmpty() && !isInProgress) {
|
if (dictionaries.isEmpty() && !isBlockingMutations) {
|
||||||
Column(
|
Column(
|
||||||
modifier = backgroundModifier
|
modifier = backgroundModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -153,7 +163,7 @@ fun DictionaryManagement(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) {
|
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isBlockingMutations && hasEnabledSources) {
|
||||||
DictionaryUpdateCard(
|
DictionaryUpdateCard(
|
||||||
onStartDownload = onStartDownload,
|
onStartDownload = onStartDownload,
|
||||||
modifier = backgroundModifier
|
modifier = backgroundModifier
|
||||||
@@ -166,10 +176,11 @@ fun DictionaryManagement(
|
|||||||
onDeleteDictionary = onDeleteDictionary,
|
onDeleteDictionary = onDeleteDictionary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(backgroundModifier)
|
.then(backgroundModifier),
|
||||||
|
isDeleteBlocked = isDeleteBlocked
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isInProgress) {
|
if (!isBlockingMutations) {
|
||||||
DictionaryActionButtons(
|
DictionaryActionButtons(
|
||||||
onAddSource = {
|
onAddSource = {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
@@ -246,18 +257,22 @@ private fun DictionaryListSection(
|
|||||||
dictionaries: List<Dictionary>,
|
dictionaries: List<Dictionary>,
|
||||||
onToggleDictionary: (String) -> Unit,
|
onToggleDictionary: (String) -> Unit,
|
||||||
onDeleteDictionary: (Dictionary) -> Unit,
|
onDeleteDictionary: (Dictionary) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
isDeleteBlocked: () -> Boolean = { false }
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
dictionaries.forEach { dictionary ->
|
dictionaries.forEach { dictionary ->
|
||||||
|
key(dictionary.path) {
|
||||||
DictionaryListItem(
|
DictionaryListItem(
|
||||||
dictionary = dictionary,
|
dictionary = dictionary,
|
||||||
onToggle = { onToggleDictionary(dictionary.path) },
|
onToggle = { onToggleDictionary(dictionary.path) },
|
||||||
onDelete = { onDeleteDictionary(dictionary) }
|
onDelete = { onDeleteDictionary(dictionary) },
|
||||||
|
isDeleteBlocked = isDeleteBlocked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -41,6 +41,11 @@ fun DictionaryProgressSection(
|
|||||||
var displayedPercent by remember { mutableIntStateOf(percent) }
|
var displayedPercent by remember { mutableIntStateOf(percent) }
|
||||||
var displayedTestTag by remember { mutableStateOf(testTag) }
|
var displayedTestTag by remember { mutableStateOf(testTag) }
|
||||||
|
|
||||||
|
LaunchedEffect(visible) {
|
||||||
|
if (visible) {
|
||||||
|
monotonicTargetProgress = targetProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(visible, targetProgress) {
|
LaunchedEffect(visible, targetProgress) {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
|
monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<string name="button_cancel_download">Отмена</string>
|
<string name="button_cancel_download">Отмена</string>
|
||||||
<string name="action_ok">ОК</string>
|
<string name="action_ok">ОК</string>
|
||||||
<string name="download_error">Ошибка загрузки словарей</string>
|
<string name="download_error">Ошибка загрузки словарей</string>
|
||||||
|
<string name="download_network_failed">Нет связи с сервером словарей. Проверьте сеть и попробуйте снова</string>
|
||||||
<string name="language">Язык</string>
|
<string name="language">Язык</string>
|
||||||
<string name="language_english">Английский</string>
|
<string name="language_english">Английский</string>
|
||||||
<string name="language_russian">Русский</string>
|
<string name="language_russian">Русский</string>
|
||||||
@@ -67,8 +68,9 @@
|
|||||||
<string name="dictionary_management_title">Словари</string>
|
<string name="dictionary_management_title">Словари</string>
|
||||||
<string name="import_dictionary_button">Выбрать файлы</string>
|
<string name="import_dictionary_button">Выбрать файлы</string>
|
||||||
<string name="import_error">Не удалось импортировать словарь: %1$s</string>
|
<string name="import_error">Не удалось импортировать словарь: %1$s</string>
|
||||||
<string name="import_file_exists">Файл уже существует: %1$s</string>
|
<string name="import_file_exists">Словарь уже добавлен: %1$s</string>
|
||||||
<string name="import_invalid_file_name">Недопустимое имя файла: %1$s</string>
|
<string name="import_invalid_file_name">Недопустимое имя файла: %1$s</string>
|
||||||
|
<string name="import_nothing_imported">В выбранном нет поддерживаемых файлов словарей</string>
|
||||||
<string name="dictionary_source_url_hint">URL</string>
|
<string name="dictionary_source_url_hint">URL</string>
|
||||||
<string name="dictionary_source_add_button">Добавить источник</string>
|
<string name="dictionary_source_add_button">Добавить источник</string>
|
||||||
<string name="dictionary_source_duplicate">Этот URL уже существует</string>
|
<string name="dictionary_source_duplicate">Этот URL уже существует</string>
|
||||||
@@ -95,6 +97,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="dictionary_not_indexed">• Не индексирован</string>
|
<string name="dictionary_not_indexed">• Не индексирован</string>
|
||||||
<string name="dictionary_delete">Удалить словарь</string>
|
<string name="dictionary_delete">Удалить словарь</string>
|
||||||
|
<string name="error_delete_dictionary">Не удалось удалить словарь</string>
|
||||||
<string name="dictionaries_tap_to_update">Нажмите для обновления</string>
|
<string name="dictionaries_tap_to_update">Нажмите для обновления</string>
|
||||||
<string name="notification_import_title">Импорт словарей</string>
|
<string name="notification_import_title">Импорт словарей</string>
|
||||||
<string name="notification_import_success_title">Импорт завершён</string>
|
<string name="notification_import_success_title">Импорт завершён</string>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<string name="button_cancel_download">Cancel</string>
|
<string name="button_cancel_download">Cancel</string>
|
||||||
<string name="action_ok">OK</string>
|
<string name="action_ok">OK</string>
|
||||||
<string name="download_error">Dictionary download failed</string>
|
<string name="download_error">Dictionary download failed</string>
|
||||||
|
<string name="download_network_failed">No connection to the dictionary server. Check your network and try again</string>
|
||||||
<string name="language">Language</string>
|
<string name="language">Language</string>
|
||||||
<string name="language_english">English</string>
|
<string name="language_english">English</string>
|
||||||
<string name="language_russian">Russian</string>
|
<string name="language_russian">Russian</string>
|
||||||
@@ -65,8 +66,9 @@
|
|||||||
<string name="dictionary_management_title">Dictionaries</string>
|
<string name="dictionary_management_title">Dictionaries</string>
|
||||||
<string name="import_dictionary_button">Select files</string>
|
<string name="import_dictionary_button">Select files</string>
|
||||||
<string name="import_error">Failed to import dictionary: %1$s</string>
|
<string name="import_error">Failed to import dictionary: %1$s</string>
|
||||||
<string name="import_file_exists">File already exists: %1$s</string>
|
<string name="import_file_exists">Dictionary already added: %1$s</string>
|
||||||
<string name="import_invalid_file_name">Invalid file name: %1$s</string>
|
<string name="import_invalid_file_name">Invalid file name: %1$s</string>
|
||||||
|
<string name="import_nothing_imported">No supported dictionary files were found in the selection</string>
|
||||||
<string name="dictionary_source_url_hint">URL</string>
|
<string name="dictionary_source_url_hint">URL</string>
|
||||||
<string name="dictionary_source_add_button">Add source</string>
|
<string name="dictionary_source_add_button">Add source</string>
|
||||||
<string name="dictionary_source_duplicate">This URL already exists</string>
|
<string name="dictionary_source_duplicate">This URL already exists</string>
|
||||||
@@ -91,6 +93,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="dictionary_not_indexed">• Not indexed</string>
|
<string name="dictionary_not_indexed">• Not indexed</string>
|
||||||
<string name="dictionary_delete">Delete dictionary</string>
|
<string name="dictionary_delete">Delete dictionary</string>
|
||||||
|
<string name="error_delete_dictionary">Failed to delete dictionary</string>
|
||||||
<string name="dictionaries_tap_to_update">Tap to update all dictionaries</string>
|
<string name="dictionaries_tap_to_update">Tap to update all dictionaries</string>
|
||||||
<string name="notification_import_title">Importing dictionaries</string>
|
<string name="notification_import_title">Importing dictionaries</string>
|
||||||
<string name="notification_import_success_title">Import completed</string>
|
<string name="notification_import_success_title">Import completed</string>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Dictionaries can no longer be deleted while an update is running, which used to lose their update source
|
||||||
|
- Fixed the dictionary icon sliding outside the row while swiping to delete
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- The dictionary update card now returns after a failed download
|
||||||
|
- Fixed headwords and articles with escaped characters being displayed incorrectly
|
||||||
|
- Fixed slow rendering of articles containing many bracket characters
|
||||||
@@ -9,6 +9,6 @@ Features:
|
|||||||
- Automatic indexing with binary index files for instant search
|
- Automatic indexing with binary index files for instant search
|
||||||
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
|
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
|
||||||
- Adaptive three-pane layout for tablets and large screens
|
- Adaptive three-pane layout for tablets and large screens
|
||||||
- Light, dark, and system-follow themes
|
- Material 3 UI with dynamic color, plus light, dark, and system-follow themes
|
||||||
- English and Russian localization
|
- English and Russian localization
|
||||||
- No tracking, no ads, fully open source
|
- No tracking, no ads, fully open source
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Словари больше нельзя удалить во время обновления — раньше при этом терялся источник обновления
|
||||||
|
- Исправлен выход иконки словаря за границы строки при свайпе
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Исправлено сопоставление URL-источников с установленными файлами — удалённый словарь теперь можно повторно скачать по тому же URL
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Карточка обновления словарей теперь появляется снова после неудачной загрузки
|
||||||
|
- Исправлено отображение заголовков и статей с экранированными символами
|
||||||
|
- Исправлено медленное отображение статей с большим количеством скобок
|
||||||
@@ -9,6 +9,6 @@ ReSearch — быстрое приложение для чтения слова
|
|||||||
- Автоматическая индексация с бинарными индексными файлами для мгновенного поиска
|
- Автоматическая индексация с бинарными индексными файлами для мгновенного поиска
|
||||||
- Поддержка нескольких кодировок — автоопределение UTF-8, UTF-16 LE/BE
|
- Поддержка нескольких кодировок — автоопределение UTF-8, UTF-16 LE/BE
|
||||||
- Адаптивный трёхпанельный интерфейс для планшетов и больших экранов
|
- Адаптивный трёхпанельный интерфейс для планшетов и больших экранов
|
||||||
- Светлая, тёмная и системная темы
|
- Material 3 с динамическими цветами, светлая, тёмная и системная темы
|
||||||
- Локализация на английский и русский языки
|
- Локализация на английский и русский языки
|
||||||
- Без трекеров, без рекламы, полностью открытый исходный код
|
- Без трекеров, без рекламы, полностью открытый исходный код
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
[versions]
|
[versions]
|
||||||
aboutlibraries = "15.0.4"
|
aboutlibraries = "15.2.0"
|
||||||
activity_compose = "1.13.0"
|
activity_compose = "1.13.0"
|
||||||
agp = "9.3.1"
|
agp = "9.3.1"
|
||||||
compose_bom = "2026.06.01"
|
compose_bom = "2026.08.00"
|
||||||
core = "1.19.0"
|
core = "1.19.0"
|
||||||
datastore_preferences = "1.2.1"
|
datastore_preferences = "1.2.1"
|
||||||
documentfile = "1.1.0"
|
documentfile = "1.1.0"
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
lifecycle_runtime_ktx = "2.11.0"
|
lifecycle_runtime_ktx = "2.11.0"
|
||||||
okhttp = "5.4.0"
|
okhttp = "5.5.0"
|
||||||
paging = "3.5.0"
|
paging = "3.5.1"
|
||||||
profileinstaller = "1.4.1"
|
profileinstaller = "1.4.1"
|
||||||
kotlinx_coroutines = "1.11.0"
|
kotlinx_coroutines = "1.11.0"
|
||||||
kotlinx_serialization = "1.11.0"
|
kotlinx_serialization = "1.11.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user