Compare commits
10
Commits
edf6a6074e
...
cf64986371
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf64986371 | ||
|
|
4d577a6b13 | ||
|
|
d15781a176 | ||
|
|
ee40748b00 | ||
|
|
380e4ca916 | ||
|
|
47b1197a92 | ||
|
|
c1563b8172 | ||
|
|
4a00d8adff | ||
|
|
944d2e6634 | ||
|
|
d7de09a152 |
No files matched your search
@@ -83,6 +83,7 @@ class MainActivity : ComponentActivity() {
|
||||
val app = application as ReSearchApplication
|
||||
val initialTheme = loadInitialTheme(app)
|
||||
val initialLanguage = loadInitialLanguage(app)
|
||||
val initialHadNoDictionaries = loadInitialHadNoDictionaries(app)
|
||||
|
||||
handleIntent(intent)
|
||||
setContent {
|
||||
@@ -104,7 +105,8 @@ class MainActivity : ComponentActivity() {
|
||||
) {
|
||||
AppNavigation(
|
||||
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) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
|
||||
@@ -37,12 +37,17 @@ class ReSearchApplication : Application() {
|
||||
private set
|
||||
lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder
|
||||
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)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
NotificationHelper(this).cancelNotification()
|
||||
val notificationHelper = NotificationHelper(this)
|
||||
notificationHelper.cancelProgressNotification()
|
||||
|
||||
preferencesManager = PreferencesManager(this)
|
||||
applicationScope.launch(Dispatchers.IO) {
|
||||
@@ -80,6 +85,40 @@ class ReSearchApplication : Application() {
|
||||
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) {
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
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 = File(dictionaryPath())
|
||||
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)
|
||||
}
|
||||
cleanupOrphanedFiles(dir)
|
||||
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 cleanupOrphanedFiles(dir: File?) {
|
||||
try {
|
||||
dir?.listFiles()?.forEach { file ->
|
||||
val n = file.name.lowercase()
|
||||
val isRawDsl = n.endsWith(".dsl") && !n.endsWith(".dsl.dz") && !n.endsWith(".dsl.gz")
|
||||
val isBareDz = n.endsWith(".dz") && !n.endsWith(".dsl.dz")
|
||||
val orphaned = when {
|
||||
isBareDz -> true
|
||||
isRawDsl -> !File(file.parent, file.name + ".dz").exists()
|
||||
else -> false
|
||||
}
|
||||
if (orphaned) {
|
||||
try { file.delete() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
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 com.example.research.MainActivity
|
||||
import com.example.research.R
|
||||
import com.example.research.common.progress.NotificationPort
|
||||
import com.example.research.feature.download.receiver.DownloadCancelReceiver
|
||||
class NotificationHelper(private val context: Context) {
|
||||
class NotificationHelper(private val context: Context) : NotificationPort {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "download_progress_channel"
|
||||
const val NOTIFICATION_ID = 1
|
||||
@@ -64,6 +65,22 @@ class NotificationHelper(private val context: Context) {
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) == 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() {
|
||||
if (!canShowNotification()) return
|
||||
lastNotificationKey = 0
|
||||
@@ -93,6 +110,8 @@ class NotificationHelper(private val context: Context) {
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
|
||||
|
||||
fun showUnifiedProgressNotification(
|
||||
title: String,
|
||||
contentText: String,
|
||||
|
||||
@@ -1,45 +1,16 @@
|
||||
package com.example.research.core.domain.model
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Snapshot of an ongoing indexing operation.
|
||||
*
|
||||
* [progress] is the authoritative [0f, 1f] completion value and is O(1) to
|
||||
* compute: the repository's internal `ProgressTracker` maintains a running
|
||||
* sum across files incrementally and stores it in [aggregateSum], avoiding
|
||||
* an O(n) walk over [perFileProgress] on every read.
|
||||
* [perFileProgress] is kept for diagnostics; callers should prefer
|
||||
* [progress] / [progressPercent].
|
||||
* [progress] is the authoritative [0f, 1f] completion value: the repository
|
||||
* aggregates a file-size-weighted sum across files incrementally while
|
||||
* indexing, so reading it is O(1).
|
||||
*/
|
||||
@Immutable
|
||||
data class IndexingProgress(
|
||||
val currentFile: String = "",
|
||||
val currentIndex: Int = 0,
|
||||
val totalFiles: Int = 0,
|
||||
val isIndexing: Boolean = false,
|
||||
val currentFileProgress: Float = 0f,
|
||||
val label: String = "",
|
||||
val perFileProgress: Map<String, Float> = emptyMap(),
|
||||
/** Pre-aggregated sum in [0f, totalFiles] supplied by the producer; -1f = unknown. */
|
||||
val aggregateSum: Float = -1f,
|
||||
) {
|
||||
val progress: Float
|
||||
get() = if (totalFiles > 0) {
|
||||
when {
|
||||
aggregateSum >= 0f -> (aggregateSum / totalFiles).coerceIn(0f, 1f)
|
||||
perFileProgress.isNotEmpty() -> {
|
||||
val totalProgress = perFileProgress.values.sum()
|
||||
(totalProgress / totalFiles).coerceIn(0f, 1f)
|
||||
}
|
||||
else -> {
|
||||
val completedFiles = (currentIndex - 1).coerceAtLeast(0)
|
||||
((completedFiles + currentFileProgress) / totalFiles).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
} else 0f
|
||||
|
||||
val progressPercent: Int
|
||||
get() = (progress * 100).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
val progress: Float = 0f,
|
||||
)
|
||||
@@ -25,6 +25,7 @@ class PreferencesManager(private val context: Context) {
|
||||
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
|
||||
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
|
||||
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
||||
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -67,6 +68,17 @@ class PreferencesManager(private val context: Context) {
|
||||
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) {
|
||||
context.dataStore.edit { preferences ->
|
||||
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
|
||||
|
||||
@@ -46,9 +46,8 @@ class IndexEntryPagingSource(
|
||||
|
||||
private data class EmittedKey(
|
||||
val dictPosition: Int,
|
||||
val originalWord: String,
|
||||
val offset: Long,
|
||||
val length: Int
|
||||
val word: String,
|
||||
val offset: Long
|
||||
)
|
||||
|
||||
private val emittedArticles = HashSet<EmittedKey>()
|
||||
@@ -103,6 +102,7 @@ class IndexEntryPagingSource(
|
||||
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
||||
val ranked = rawResults.map { it.second }
|
||||
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
|
||||
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
|
||||
|
||||
rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) }
|
||||
|
||||
@@ -184,7 +184,7 @@ class IndexEntryPagingSource(
|
||||
}
|
||||
|
||||
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? {
|
||||
return null
|
||||
|
||||
+33
-36
@@ -70,13 +70,9 @@ private class ProgressTracker(files: List<LocalDictionaryRepository.DiscoveredFi
|
||||
aggregateWeightedMicros.addAndGet(newMicros - prevMicros)
|
||||
}
|
||||
|
||||
/**
|
||||
* Current weighted sum mapped back to [0f, totalFiles] so the existing
|
||||
* IndexingProgress model can keep deriving progress as aggregate/total.
|
||||
*/
|
||||
fun aggregateSum(): Float {
|
||||
fun progress(): Float {
|
||||
val weightedProgress = aggregateWeightedMicros.get().toDouble() / (totalWeight.toDouble() * 1_000_000.0)
|
||||
return (weightedProgress.coerceIn(0.0, 1.0) * totalFiles).toFloat()
|
||||
return weightedProgress.coerceIn(0.0, 1.0).toFloat()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
@@ -192,61 +188,59 @@ class LocalDictionaryRepository(
|
||||
mutableIndexingProgress.emit(
|
||||
IndexingProgress(
|
||||
isIndexing = true,
|
||||
currentFile = context.getString(R.string.indexing_label),
|
||||
totalFiles = filesToScan.size,
|
||||
currentIndex = filesToScan.size - filesToIndex.size,
|
||||
aggregateSum = progressTracker?.aggregateSum() ?: -1f,
|
||||
progress = progressTracker?.progress() ?: 0f,
|
||||
)
|
||||
)
|
||||
|
||||
val lastEmittedPercent = AtomicInteger(-1)
|
||||
val lastProgressEmitMs = AtomicLong(0L)
|
||||
|
||||
val publishCurrentProgress = {
|
||||
val tracker = progressTracker
|
||||
if (tracker != null) {
|
||||
val progress = tracker.progress()
|
||||
lastEmittedPercent.set((progress * 100f).toInt().coerceIn(0, 100))
|
||||
lastProgressEmitMs.set(SystemClock.elapsedRealtime())
|
||||
mutableIndexingProgress.update { p ->
|
||||
p.copy(progress = progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supervisorScope {
|
||||
filesToIndex.mapIndexed { index, file ->
|
||||
filesToIndex.map { file ->
|
||||
async {
|
||||
indexingSemaphore.withPermit {
|
||||
coroutineContext.ensureActive()
|
||||
yield()
|
||||
|
||||
val res = indexDictionary(file) { _, op, prog ->
|
||||
val res = indexDictionary(file) { _, _, prog ->
|
||||
if (prog >= 0f) {
|
||||
progressTracker?.updateFileProgress(file.name, prog)
|
||||
}
|
||||
|
||||
val tracker = progressTracker
|
||||
val aggregate = tracker?.aggregateSum() ?: -1f
|
||||
val newPercent = if (filesToScan.isNotEmpty() && aggregate >= 0f) {
|
||||
((aggregate / filesToScan.size) * 100f).toInt().coerceIn(0, 100)
|
||||
} else -1
|
||||
val progress = progressTracker?.progress() ?: 0f
|
||||
val newPercent = (progress * 100f).toInt().coerceIn(0, 100)
|
||||
|
||||
val currentIdx = filesToScan.size - filesToIndex.size + index + 1
|
||||
val previous = mutableIndexingProgress.value
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val percentChanged = newPercent >= 0 && newPercent != lastEmittedPercent.get()
|
||||
val fileChanged = previous.currentFile != file.name
|
||||
val percentChanged = newPercent != lastEmittedPercent.get()
|
||||
val completed = newPercent >= 100 || prog >= 1f
|
||||
val intervalElapsed =
|
||||
now - lastProgressEmitMs.get() >= MIN_PROGRESS_EMIT_INTERVAL_MS
|
||||
val shouldEmit = fileChanged || completed || (percentChanged && intervalElapsed)
|
||||
val shouldEmit = completed || (percentChanged && intervalElapsed)
|
||||
|
||||
if (shouldEmit) {
|
||||
if (newPercent >= 0) lastEmittedPercent.set(newPercent)
|
||||
lastEmittedPercent.set(newPercent)
|
||||
lastProgressEmitMs.set(now)
|
||||
mutableIndexingProgress.update { p ->
|
||||
p.copy(
|
||||
currentFile = file.name,
|
||||
currentIndex = currentIdx,
|
||||
currentFileProgress = prog,
|
||||
label = op.ifEmpty { p.label },
|
||||
aggregateSum = aggregate,
|
||||
)
|
||||
p.copy(progress = progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (res is OperationResult.Success) {
|
||||
progressTracker?.updateFileProgress(file.name, 1.0f)
|
||||
publishCurrentProgress()
|
||||
totalArticlesIndexed.addAndGet(res.data.articleCount)
|
||||
val dictionary = res.data
|
||||
addDictionary(dictionary)
|
||||
@@ -396,12 +390,15 @@ class LocalDictionaryRepository(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun isDictionaryPayloadFile(file: File): Boolean =
|
||||
file.isFile && (
|
||||
file.name.endsWith(".dsl") ||
|
||||
file.name.endsWith(".dsl.dz") ||
|
||||
file.name.endsWith(".dsl.gz")
|
||||
)
|
||||
private fun isDictionaryPayloadFile(file: File): Boolean {
|
||||
if (!file.isFile) return false
|
||||
val n = file.name.lowercase()
|
||||
if (n.endsWith(".dsl.dz") || n.endsWith(".dsl.gz")) return true
|
||||
if (n.endsWith(".dsl")) {
|
||||
return File(file.parent, file.name + ".dz").exists()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
|
||||
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
|
||||
|
||||
@@ -41,11 +41,21 @@ class DownloadManager(
|
||||
val clampedProgress = progress.coerceIn(0f, 1f)
|
||||
mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress)
|
||||
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() {
|
||||
if (downloadState.value is DownloadState.Loading) return
|
||||
if (!downloadScope.isActive) return
|
||||
onFlowStarted?.invoke()
|
||||
cancelCleanupJob = null
|
||||
downloadJob = downloadScope.launch {
|
||||
mutex.withLock {
|
||||
@@ -137,6 +147,7 @@ class DownloadManager(
|
||||
fun startDownloadForSources(sourceUrls: List<String>) {
|
||||
if (downloadState.value is DownloadState.Loading) return
|
||||
if (!downloadScope.isActive) return
|
||||
onFlowStarted?.invoke()
|
||||
cancelCleanupJob = null
|
||||
downloadJob = downloadScope.launch {
|
||||
mutex.withLock {
|
||||
|
||||
@@ -11,3 +11,6 @@ sealed class DownloadState {
|
||||
data class Error(val message: String) : DownloadState()
|
||||
data object Cancelled : DownloadState()
|
||||
}
|
||||
|
||||
val DownloadState.isActive: Boolean
|
||||
get() = this is DownloadState.Loading || this is DownloadState.Extracting || this is DownloadState.Success
|
||||
+7
-2
@@ -15,8 +15,11 @@ import com.example.research.feature.download.config.DictionaryConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
@@ -58,6 +61,7 @@ class DictionaryDownloader(
|
||||
) {
|
||||
if (fileUri == null) return
|
||||
|
||||
withContext(NonCancellable) {
|
||||
runCatching {
|
||||
val fileToDelete = fileStorageManager.getDocumentFile(fileUri)
|
||||
if (fileToDelete != null && fileToDelete.exists()) {
|
||||
@@ -75,6 +79,7 @@ class DictionaryDownloader(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend fun downloadDictionaries(
|
||||
folderUri: Uri,
|
||||
sources: List<DictionarySource>,
|
||||
@@ -169,7 +174,7 @@ class DictionaryDownloader(
|
||||
}
|
||||
|
||||
private suspend fun deletePartialFileIfExists(folderUri: Uri, fileName: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO + NonCancellable) {
|
||||
runCatching {
|
||||
val existingFile = fileStorageManager.findFile(folderUri, fileName)
|
||||
if (existingFile != null && existingFile.exists()) {
|
||||
@@ -313,7 +318,7 @@ class DictionaryDownloader(
|
||||
call?.cancel()
|
||||
response?.close()
|
||||
outputStream?.close()
|
||||
if (e is CancellationException) {
|
||||
if (e is CancellationException || !currentCoroutineContext().isActive) {
|
||||
deletePartialFileIfExists(folderUri, fileName)
|
||||
deletePartialFileSafely(fileUri, folderUri, fileName)
|
||||
throw e
|
||||
|
||||
+30
@@ -9,7 +9,9 @@ import com.example.research.common.util.FileStorageManager
|
||||
import com.example.research.data.dictzip.DictZipRandomAccessFile
|
||||
import com.example.research.data.dictzip.DictZipWriter
|
||||
import com.example.research.feature.download.config.DictionaryConfig
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DictionaryExtractor(
|
||||
@@ -138,11 +140,39 @@ class DictionaryExtractor(
|
||||
|
||||
onProgress?.invoke(1f)
|
||||
Result.success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
deleteOrphanedFiles(folderUri)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteOrphanedFiles(folderUri: Uri) {
|
||||
try {
|
||||
withContext(Dispatchers.IO + NonCancellable) {
|
||||
val files = fileStorageManager.listFilesInFolder(folderUri)
|
||||
for (file in files) {
|
||||
val n = file.name.lowercase()
|
||||
val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) &&
|
||||
!n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
|
||||
!n.endsWith(".dsl.gz")
|
||||
val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION)
|
||||
when {
|
||||
isBareDz -> ArchiveUtils.deleteFile(context, file.uri)
|
||||
isRawDsl -> {
|
||||
val pair = fileStorageManager.findFile(folderUri, file.name + ".dz")
|
||||
if (pair == null || !pair.exists()) {
|
||||
ArchiveUtils.deleteFile(context, file.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureDslDictZip(
|
||||
dslUri: Uri,
|
||||
dslFileName: String,
|
||||
|
||||
+25
-181
@@ -3,19 +3,19 @@ package com.example.research.feature.download.service
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.ServiceCompat
|
||||
import com.example.research.ReSearchApplication
|
||||
import com.example.research.common.progress.renderTitle
|
||||
import com.example.research.common.progress.DictionaryPipelineCoordinator
|
||||
import com.example.research.common.progress.DictionaryProgressPresenter
|
||||
import com.example.research.common.util.NotificationHelper
|
||||
import com.example.research.core.util.OperationResult
|
||||
import com.example.research.data.repository.LocalDictionaryRepository
|
||||
import com.example.research.feature.download.DownloadManager
|
||||
import com.example.research.feature.download.model.DownloadState
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
class DictionaryForegroundService : Service() {
|
||||
|
||||
@@ -23,21 +23,21 @@ class DictionaryForegroundService : Service() {
|
||||
const val ACTION_START = "com.example.research.START_DOWNLOAD"
|
||||
const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD"
|
||||
const val ACTION_IMPORT = "com.example.research.START_IMPORT"
|
||||
const val ACTION_FINISH = "com.example.research.FINISH_PIPELINE"
|
||||
private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID
|
||||
}
|
||||
|
||||
private lateinit var downloadManager: DownloadManager
|
||||
private lateinit var localDictionaryRepository: LocalDictionaryRepository
|
||||
private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager
|
||||
private lateinit var progressStateHolder: com.example.research.common.progress.DictionaryProgressStateHolder
|
||||
private lateinit var pipelineCoordinator: DictionaryPipelineCoordinator
|
||||
private lateinit var presenter: DictionaryProgressPresenter
|
||||
private val notificationHelper by lazy {
|
||||
NotificationHelper(this)
|
||||
}
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
@Volatile
|
||||
private var isHandlingSuccess = false
|
||||
@Volatile
|
||||
private var latestStartId = 0
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -47,164 +47,8 @@ class DictionaryForegroundService : Service() {
|
||||
downloadManager = app.downloadManager
|
||||
localDictionaryRepository = app.localDictionaryRepository
|
||||
dictionaryImportManager = app.dictionaryImportManager
|
||||
progressStateHolder = app.dictionaryProgressStateHolder
|
||||
observeDownloadState()
|
||||
observeImportState()
|
||||
observeUnifiedProgress()
|
||||
}
|
||||
|
||||
private fun observeDownloadState() {
|
||||
serviceScope.launch {
|
||||
downloadManager.downloadState.collect { state ->
|
||||
when (state) {
|
||||
is DownloadState.Success -> handleSuccess()
|
||||
is DownloadState.Error -> {
|
||||
notificationHelper.showErrorNotification()
|
||||
stopForegroundService(removeNotification = false)
|
||||
}
|
||||
is DownloadState.Cancelled -> {
|
||||
notificationHelper.cancelNotification()
|
||||
stopForegroundService()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeImportState() {
|
||||
serviceScope.launch {
|
||||
dictionaryImportManager.importState.collect { state ->
|
||||
when (state) {
|
||||
is com.example.research.ui.settings.ImportState.Success -> handleSuccess()
|
||||
is com.example.research.ui.settings.ImportState.Error -> {
|
||||
notificationHelper.showErrorNotification()
|
||||
stopForegroundService(removeNotification = false)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeUnifiedProgress() {
|
||||
serviceScope.launch {
|
||||
progressStateHolder.progressSnapshot
|
||||
.collect { snapshot ->
|
||||
if (snapshot != null) {
|
||||
notificationHelper.showUnifiedProgressNotification(
|
||||
title = snapshot.renderTitle(this@DictionaryForegroundService),
|
||||
contentText = "${snapshot.percent}%",
|
||||
progressPercent = snapshot.percent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun triggerReindexing(isImportFlow: Boolean) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
waitForIndexingCompletion()
|
||||
delay(1.seconds)
|
||||
|
||||
val app = application as? ReSearchApplication
|
||||
?: return@withContext
|
||||
val path = app.preferencesManager.dictionaryPath
|
||||
if (isImportFlow) {
|
||||
dictionaryImportManager.updateExtractionProgress(0f)
|
||||
}
|
||||
app.downloadDictionaryRepository.extractArchives(onProgress = { progress ->
|
||||
if (isImportFlow) {
|
||||
dictionaryImportManager.updateExtractionProgress(progress)
|
||||
}
|
||||
})
|
||||
val result = localDictionaryRepository.scanDirectory(path)
|
||||
if (result is OperationResult.Success && result.data > 0) {
|
||||
localDictionaryRepository.warmupIndexes()
|
||||
app.downloadDictionaryRepository.performAllCleanup()
|
||||
}
|
||||
if (isImportFlow) {
|
||||
dictionaryImportManager.markImportPipelineSuccess()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e("DictionaryForegroundService", "triggerReindexing() failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForIndexingCompletion(): Boolean {
|
||||
val isIndexing = localDictionaryRepository.indexingProgress.first().isIndexing
|
||||
|
||||
if (!isIndexing) {
|
||||
return true
|
||||
}
|
||||
|
||||
var waitCount = 0
|
||||
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < 100) {
|
||||
delay(200.milliseconds)
|
||||
waitCount++
|
||||
}
|
||||
|
||||
return waitCount < 100
|
||||
}
|
||||
|
||||
private fun handleSuccess() {
|
||||
if (isHandlingSuccess) return
|
||||
isHandlingSuccess = true
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val isImportFlow = dictionaryImportManager.importState.value !is com.example.research.ui.settings.ImportState.Idle
|
||||
|
||||
if (isImportFlow) {
|
||||
val app = application as? ReSearchApplication
|
||||
val path = app?.preferencesManager?.dictionaryPath
|
||||
val dictionariesDir = path?.let { java.io.File(it) }
|
||||
val filesBeforeReindex = dictionariesDir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
|
||||
|
||||
try {
|
||||
triggerReindexing(true)
|
||||
dictionaryImportManager.getAndClearImportedFiles()
|
||||
} catch (e: CancellationException) {
|
||||
val filesToCleanup = dictionaryImportManager.getAndClearImportedFiles()
|
||||
filesToCleanup.forEach { file ->
|
||||
try { if (file.exists()) file.delete() } catch (_: Exception) { /* Ignore cleanup failure */ }
|
||||
}
|
||||
if (dictionariesDir != null) {
|
||||
cleanupNewFiles(dictionariesDir, filesBeforeReindex)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
triggerReindexing(false)
|
||||
}
|
||||
|
||||
if (isImportFlow) {
|
||||
notificationHelper.showImportSuccessNotification()
|
||||
dictionaryImportManager.clearImportState()
|
||||
} else {
|
||||
notificationHelper.showSuccessNotification()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException) {
|
||||
Log.e("DownloadForegroundService", "handleSuccess() failed: ${e.message}", e)
|
||||
notificationHelper.showErrorNotification()
|
||||
}
|
||||
} finally {
|
||||
isHandlingSuccess = false
|
||||
stopForegroundService(removeNotification = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupNewFiles(dir: java.io.File, filesBeforeSnapshot: Set<String>) {
|
||||
try {
|
||||
dir.listFiles()?.forEach { file ->
|
||||
if (file.name !in filesBeforeSnapshot) {
|
||||
try { file.delete() } catch (_: Exception) { /* Ignore delete failure */ }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { /* Ignore directory listing failure */ }
|
||||
pipelineCoordinator = app.dictionaryPipelineCoordinator
|
||||
presenter = app.dictionaryProgressPresenter
|
||||
}
|
||||
|
||||
private fun startForegroundService() {
|
||||
@@ -249,15 +93,20 @@ class DictionaryForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun stopForTimeout(startId: Int) {
|
||||
cancelAllWork()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelfResult(startId)
|
||||
}
|
||||
|
||||
private fun cancelAllWork() {
|
||||
serviceScope.coroutineContext[Job]?.cancelChildren()
|
||||
pipelineCoordinator.cancelPipeline()
|
||||
if (localDictionaryRepository.isIndexingInProgress()) {
|
||||
localDictionaryRepository.cancelIndexing()
|
||||
}
|
||||
downloadManager.cancelDownload()
|
||||
dictionaryImportManager.cancelImport()
|
||||
notificationHelper.cancelNotification()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelfResult(startId)
|
||||
presenter.cancelProgress()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -275,21 +124,16 @@ class DictionaryForegroundService : Service() {
|
||||
ACTION_IMPORT -> {
|
||||
startForegroundService()
|
||||
}
|
||||
ACTION_FINISH -> {
|
||||
stopForegroundService(removeNotification = false)
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
try {
|
||||
startForegroundService()
|
||||
} catch (_: Exception) {
|
||||
// Service may already be stopped
|
||||
}
|
||||
serviceScope.coroutineContext[Job]?.cancelChildren()
|
||||
|
||||
if (localDictionaryRepository.isIndexingInProgress()) {
|
||||
localDictionaryRepository.cancelIndexing()
|
||||
}
|
||||
|
||||
downloadManager.cancelDownload()
|
||||
dictionaryImportManager.cancelImport()
|
||||
notificationHelper.cancelNotification()
|
||||
cancelAllWork()
|
||||
stopForegroundService()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.example.research.feature.import
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import com.example.research.R
|
||||
import com.example.research.common.progress.ImportFlowOperations
|
||||
import com.example.research.common.util.SafeFileName
|
||||
import com.example.research.core.performance.ReSearchTrace
|
||||
import com.example.research.ui.settings.ImportState
|
||||
@@ -14,16 +15,19 @@ import java.util.Collections
|
||||
|
||||
class DictionaryImportManager(
|
||||
private val application: Application
|
||||
) {
|
||||
) : ImportFlowOperations {
|
||||
private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle)
|
||||
val importState: StateFlow<ImportState> = mutableImportState.asStateFlow()
|
||||
|
||||
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var importJob: Job? = null
|
||||
private val importedFiles = Collections.synchronizedList(mutableListOf<File>())
|
||||
var onFlowStarted: (() -> Unit)? = null
|
||||
var onTerminal: ((ImportState) -> Unit)? = null
|
||||
|
||||
fun importDictionaries(uris: List<Uri>) {
|
||||
importJob?.cancel()
|
||||
onFlowStarted?.invoke()
|
||||
importJob = managerScope.launch {
|
||||
mutableImportState.value = ImportState.Idle
|
||||
performImport(uris)
|
||||
@@ -36,6 +40,8 @@ class DictionaryImportManager(
|
||||
val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries")
|
||||
val totalFiles = uris.size.coerceAtLeast(1)
|
||||
importedFiles.clear()
|
||||
val skippedNames = mutableListOf<String>()
|
||||
val deferredErrors = mutableListOf<String>()
|
||||
|
||||
if (!dictionariesDir.exists()) {
|
||||
dictionariesDir.mkdirs()
|
||||
@@ -46,9 +52,7 @@ class DictionaryImportManager(
|
||||
currentCoroutineContext().ensureActive()
|
||||
val fileName = getFileName(uri) ?: continue
|
||||
if (SafeFileName.validate(fileName) == null) {
|
||||
mutableImportState.value = ImportState.Error(
|
||||
context.getString(R.string.import_invalid_file_name, fileName)
|
||||
)
|
||||
deferredErrors += context.getString(R.string.import_invalid_file_name, fileName)
|
||||
continue
|
||||
}
|
||||
val lowerFileName = fileName.lowercase()
|
||||
@@ -65,9 +69,7 @@ class DictionaryImportManager(
|
||||
val destFile = File(dictionariesDir, fileName)
|
||||
|
||||
if (destFile.exists()) {
|
||||
mutableImportState.value = ImportState.Error(
|
||||
context.getString(R.string.import_file_exists, fileName)
|
||||
)
|
||||
skippedNames += fileName
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -115,16 +117,30 @@ class DictionaryImportManager(
|
||||
|
||||
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) {
|
||||
cleanupImportedFiles()
|
||||
mutableImportState.value = ImportState.Idle
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
mutableImportState.value = ImportState.Error(
|
||||
val errorState = ImportState.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
|
||||
}
|
||||
|
||||
fun clearImportState() {
|
||||
override fun clearImportState() {
|
||||
mutableImportState.value = ImportState.Idle
|
||||
}
|
||||
|
||||
fun updateExtractionProgress(progress: Float) {
|
||||
override fun updateExtractionProgress(progress: Float) {
|
||||
mutableImportState.update { current ->
|
||||
if (current is ImportState.Idle || current is ImportState.Error) current
|
||||
else ImportState.Extracting(progress.coerceIn(0f, 1f))
|
||||
}
|
||||
}
|
||||
|
||||
fun markImportPipelineSuccess() {
|
||||
override fun markImportPipelineSuccess() {
|
||||
mutableImportState.update { current ->
|
||||
if (current is ImportState.Idle || current is ImportState.Error) current
|
||||
else ImportState.Success
|
||||
@@ -179,7 +195,7 @@ class DictionaryImportManager(
|
||||
mutableImportState.value = ImportState.Idle
|
||||
}
|
||||
|
||||
fun getAndClearImportedFiles(): List<File> {
|
||||
override fun getAndClearImportedFiles(): List<File> {
|
||||
val files = importedFiles.toList()
|
||||
importedFiles.clear()
|
||||
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())
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.example.research.ui.about
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
@@ -27,6 +26,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -43,6 +43,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.example.research.R
|
||||
import com.example.research.common.ui.components.OutlinedChoiceButton
|
||||
import com.example.research.ui.theme.AppWindowInsets
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -57,9 +59,11 @@ fun AboutScreen(
|
||||
}
|
||||
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
|
||||
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)
|
||||
}
|
||||
}
|
||||
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
|
||||
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
|
||||
|
||||
@@ -93,12 +97,12 @@ fun AboutScreen(
|
||||
AboutHeader(versionName = versionName)
|
||||
}
|
||||
items(
|
||||
items = librariesData.libraries,
|
||||
items = librariesData?.libraries.orEmpty(),
|
||||
key = AboutLibrary::uniqueId,
|
||||
) { library ->
|
||||
AboutLibraryRow(
|
||||
library = library,
|
||||
licenses = librariesData.licenses,
|
||||
licenses = librariesData?.licenses.orEmpty(),
|
||||
expanded = expandedLibraryId == library.uniqueId,
|
||||
onToggle = {
|
||||
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
|
||||
@@ -191,8 +195,7 @@ private fun AboutLibraryRow(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.animateContentSize(),
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -41,6 +41,7 @@ fun SearchResultsList(
|
||||
) {
|
||||
items(
|
||||
count = results.itemCount,
|
||||
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
|
||||
contentType = results.itemContentType { "search_result" }
|
||||
) { index ->
|
||||
results[index]?.let { entry ->
|
||||
|
||||
@@ -9,15 +9,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
|
||||
import com.example.research.feature.search.SearchAction
|
||||
import com.example.research.feature.search.SearchViewModel
|
||||
import com.example.research.feature.download.model.DownloadState
|
||||
import com.example.research.ui.about.AboutScreen
|
||||
import com.example.research.ui.article.ArticleRoute
|
||||
import com.example.research.ui.main.MainRoute
|
||||
import com.example.research.ui.settings.ImportState
|
||||
import com.example.research.ui.settings.SettingsRoute
|
||||
import com.example.research.ui.settings.SettingsViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private data class NavigationState(
|
||||
val shouldShowSettings: Boolean,
|
||||
@@ -33,7 +29,8 @@ enum class Screen {
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
searchViewModel: SearchViewModel,
|
||||
settingsViewModel: SettingsViewModel
|
||||
settingsViewModel: SettingsViewModel,
|
||||
seedNoDictionaries: Boolean = false
|
||||
) {
|
||||
val screenStack = rememberSaveable(
|
||||
saver = listSaver(
|
||||
@@ -53,39 +50,15 @@ fun AppNavigation(
|
||||
val currentScreen = screenStack.lastOrNull() ?: Screen.Home
|
||||
|
||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val progressSnapshot by settingsViewModel.progressSnapshot.collectAsStateWithLifecycle()
|
||||
val isPipelineActiveByState = remember(
|
||||
progressSnapshot,
|
||||
settingsState.indexingProgress.isIndexing,
|
||||
settingsState.downloadState,
|
||||
settingsState.importState,
|
||||
) {
|
||||
progressSnapshot != null ||
|
||||
settingsState.indexingProgress.isIndexing ||
|
||||
settingsState.downloadState is DownloadState.Loading ||
|
||||
settingsState.downloadState is DownloadState.Extracting ||
|
||||
settingsState.downloadState is DownloadState.Success ||
|
||||
settingsState.importState is ImportState.Importing ||
|
||||
settingsState.importState is ImportState.Extracting ||
|
||||
settingsState.importState is ImportState.Success
|
||||
}
|
||||
var stickyPipelineActive by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(isPipelineActiveByState) {
|
||||
if (isPipelineActiveByState) {
|
||||
stickyPipelineActive = true
|
||||
} else {
|
||||
delay(1500.milliseconds)
|
||||
stickyPipelineActive = false
|
||||
}
|
||||
}
|
||||
|
||||
val navState by remember {
|
||||
derivedStateOf {
|
||||
val isOperationActive = stickyPipelineActive
|
||||
val isOperationActive = !settingsState.pipelineIdle
|
||||
val hasConfirmedNoDictionaries =
|
||||
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
|
||||
val hasSeededNoDictionaries = seedNoDictionaries && !settingsState.hasCompletedStartupScan
|
||||
NavigationState(
|
||||
shouldShowSettings = hasConfirmedNoDictionaries || isOperationActive,
|
||||
shouldShowSettings = hasConfirmedNoDictionaries || hasSeededNoDictionaries || isOperationActive,
|
||||
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
|
||||
isOperationActive = isOperationActive,
|
||||
)
|
||||
|
||||
@@ -10,3 +10,6 @@ sealed interface ImportState {
|
||||
data object Success : 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
|
||||
@@ -23,6 +23,7 @@ data class SettingsUiState(
|
||||
val importState: ImportState = ImportState.Idle,
|
||||
val dictionarySources: List<DictionarySource> = emptyList(),
|
||||
val hasCompletedStartupScan: Boolean = false,
|
||||
val pipelineIdle: Boolean = true,
|
||||
val appVersion: String = "1.0",
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.example.research.data.local.preferences.PreferencesManager
|
||||
import com.example.research.data.repository.LocalDictionaryRepository
|
||||
import com.example.research.feature.download.DownloadManager
|
||||
import com.example.research.feature.download.model.DownloadState
|
||||
import com.example.research.feature.download.model.isActive
|
||||
import com.example.research.feature.download.repository.DictionaryRepository
|
||||
import com.example.research.feature.import.DictionaryImportManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -89,9 +90,20 @@ class SettingsViewModel(
|
||||
|
||||
init {
|
||||
setupStateObservation()
|
||||
observeDictionariesForSeedFlag()
|
||||
initialize()
|
||||
}
|
||||
|
||||
private fun observeDictionariesForSeedFlag() {
|
||||
viewModelScope.launch {
|
||||
localDictionaryRepository.dictionaries.collect { dictionaries ->
|
||||
if (hasCompletedStartupScan.value) {
|
||||
preferencesManager.setHadNoDictionaries(dictionaries.isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupStateObservation() {
|
||||
viewModelScope.launch {
|
||||
var wasIndexing = false
|
||||
@@ -214,6 +226,9 @@ class SettingsViewModel(
|
||||
importState = operationState.importState,
|
||||
dictionaryStatus = operationState.dictionaryStatus,
|
||||
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
|
||||
pipelineIdle = !isIndexing &&
|
||||
!operationState.downloadState.isActive &&
|
||||
!operationState.importState.isActive,
|
||||
isThemeExpanded = operationState.isThemeExpanded,
|
||||
isLanguageExpanded = operationState.isLanguageExpanded,
|
||||
isDictionariesExpanded = operationState.isDictionariesExpanded,
|
||||
@@ -245,6 +260,9 @@ class SettingsViewModel(
|
||||
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
|
||||
}
|
||||
hasCompletedStartupScan.value = true
|
||||
preferencesManager.setHadNoDictionaries(
|
||||
localDictionaryRepository.dictionaries.value.isEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-5
@@ -1,8 +1,8 @@
|
||||
package com.example.research.ui.settings.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -19,6 +19,10 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.research.R
|
||||
|
||||
private const val MILLIS_PER_PROGRESS_UNIT = 10_000
|
||||
private const val MIN_PROGRESS_MOTION_MS = 50
|
||||
private const val MAX_PROGRESS_MOTION_MS = 650
|
||||
|
||||
@Composable
|
||||
fun DictionaryProgressSection(
|
||||
visible: Boolean,
|
||||
@@ -58,11 +62,21 @@ fun DictionaryProgressSection(
|
||||
displayedTestTag = testTag
|
||||
}
|
||||
}
|
||||
val animatedProgress by animateFloatAsState(
|
||||
val animatedState = remember { Animatable(0f) }
|
||||
LaunchedEffect(monotonicTargetProgress) {
|
||||
val delta = monotonicTargetProgress - animatedState.value
|
||||
if (delta > 0f) {
|
||||
animatedState.animateTo(
|
||||
targetValue = monotonicTargetProgress,
|
||||
animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing),
|
||||
label = "dictionary-progress"
|
||||
animationSpec = tween(
|
||||
durationMillis = (delta * MILLIS_PER_PROGRESS_UNIT).toInt()
|
||||
.coerceIn(MIN_PROGRESS_MOTION_MS, MAX_PROGRESS_MOTION_MS),
|
||||
easing = LinearEasing,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val animatedProgress = animatedState.value
|
||||
|
||||
Column(modifier = Modifier.testTag(displayedTestTag)) {
|
||||
Row(
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<string name="language_english">Английский</string>
|
||||
<string name="language_russian">Русский</string>
|
||||
<string name="language_system">Системный</string>
|
||||
<string name="indexing_label">Индексация словарей</string>
|
||||
<string name="clear_search">Очистить поиск</string>
|
||||
<string name="article_no_selected">Статья не выбрана</string>
|
||||
<string name="article_return_to_search">Вернуться к поиску</string>
|
||||
@@ -68,8 +67,9 @@
|
||||
<string name="dictionary_management_title">Словари</string>
|
||||
<string name="import_dictionary_button">Выбрать файлы</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_nothing_imported">В выбранном нет поддерживаемых файлов словарей</string>
|
||||
<string name="dictionary_source_url_hint">URL</string>
|
||||
<string name="dictionary_source_add_button">Добавить источник</string>
|
||||
<string name="dictionary_source_duplicate">Этот URL уже существует</string>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<string name="language_english">English</string>
|
||||
<string name="language_russian">Russian</string>
|
||||
<string name="language_system">System</string>
|
||||
<string name="indexing_label">Indexing dictionaries</string>
|
||||
<string name="clear_search">Clear search</string>
|
||||
<string name="article_no_selected">No article selected</string>
|
||||
<string name="article_return_to_search">Return to search</string>
|
||||
@@ -66,8 +65,9 @@
|
||||
<string name="dictionary_management_title">Dictionaries</string>
|
||||
<string name="import_dictionary_button">Select files</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_nothing_imported">No supported dictionary files were found in the selection</string>
|
||||
<string name="dictionary_source_url_hint">URL</string>
|
||||
<string name="dictionary_source_add_button">Add source</string>
|
||||
<string name="dictionary_source_duplicate">This URL already exists</string>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
aboutlibraries = "15.0.4"
|
||||
aboutlibraries = "15.2.0"
|
||||
activity_compose = "1.13.0"
|
||||
agp = "9.3.1"
|
||||
compose_bom = "2026.08.00"
|
||||
|
||||
Reference in new issue
Block a user