From 962605e2b464c47d3cd2e9f9f438db4a0347dff8 Mon Sep 17 00:00:00 2001 From: OneWay Date: Sun, 6 Sep 2026 15:32:35 +0800 Subject: [PATCH] fix: latch terminal notification state and post pipeline progress on one thread A late ImportState Success snapshot collected after the success notification overwrote it with an ongoing progress record. Post progress on Main behind a volatile terminal latch; notifications go through NotificationPort, the pipeline lives in the coordinator, and the service finish is injected. --- .../example/research/ReSearchApplication.kt | 41 +++- .../progress/DictionaryPipelineCoordinator.kt | 131 +++++++++++ .../progress/DictionaryProgressPresenter.kt | 56 +++++ .../common/progress/NotificationPort.kt | 10 + .../common/progress/PipelineContracts.kt | 65 ++++++ .../common/util/NotificationHelper.kt | 21 +- .../feature/download/DownloadManager.kt | 11 + .../service/DictionaryForegroundService.kt | 210 +++--------------- .../feature/import/DictionaryImportManager.kt | 22 +- 9 files changed, 372 insertions(+), 195 deletions(-) create mode 100644 app/src/main/java/com/example/research/common/progress/DictionaryPipelineCoordinator.kt create mode 100644 app/src/main/java/com/example/research/common/progress/DictionaryProgressPresenter.kt create mode 100644 app/src/main/java/com/example/research/common/progress/NotificationPort.kt create mode 100644 app/src/main/java/com/example/research/common/progress/PipelineContracts.kt diff --git a/app/src/main/java/com/example/research/ReSearchApplication.kt b/app/src/main/java/com/example/research/ReSearchApplication.kt index 4a5a291..e66771e 100644 --- a/app/src/main/java/com/example/research/ReSearchApplication.kt +++ b/app/src/main/java/com/example/research/ReSearchApplication.kt @@ -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) { diff --git a/app/src/main/java/com/example/research/common/progress/DictionaryPipelineCoordinator.kt b/app/src/main/java/com/example/research/common/progress/DictionaryPipelineCoordinator.kt new file mode 100644 index 0000000..74e3591 --- /dev/null +++ b/app/src/main/java/com/example/research/common/progress/DictionaryPipelineCoordinator.kt @@ -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) { + try { + dir?.listFiles()?.forEach { file -> + if (file.name !in filesBeforeSnapshot) { + try { + file.delete() + } catch (_: Exception) { + } + } + } + } catch (_: Exception) { + } + } +} diff --git a/app/src/main/java/com/example/research/common/progress/DictionaryProgressPresenter.kt b/app/src/main/java/com/example/research/common/progress/DictionaryProgressPresenter.kt new file mode 100644 index 0000000..c67be10 --- /dev/null +++ b/app/src/main/java/com/example/research/common/progress/DictionaryProgressPresenter.kt @@ -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, + 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() + } +} diff --git a/app/src/main/java/com/example/research/common/progress/NotificationPort.kt b/app/src/main/java/com/example/research/common/progress/NotificationPort.kt new file mode 100644 index 0000000..e2c5a3e --- /dev/null +++ b/app/src/main/java/com/example/research/common/progress/NotificationPort.kt @@ -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 +} diff --git a/app/src/main/java/com/example/research/common/progress/PipelineContracts.kt b/app/src/main/java/com/example/research/common/progress/PipelineContracts.kt new file mode 100644 index 0000000..3af205f --- /dev/null +++ b/app/src/main/java/com/example/research/common/progress/PipelineContracts.kt @@ -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 +} + +interface ReindexingSteps { + suspend fun waitForIndexingCompletion(): Boolean + suspend fun extractArchives(onProgress: (Float) -> Unit) + suspend fun scanDirectory(path: String): OperationResult + 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 = + 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 + } +} diff --git a/app/src/main/java/com/example/research/common/util/NotificationHelper.kt b/app/src/main/java/com/example/research/common/util/NotificationHelper.kt index c4a1ecd..f730c82 100644 --- a/app/src/main/java/com/example/research/common/util/NotificationHelper.kt +++ b/app/src/main/java/com/example/research/common/util/NotificationHelper.kt @@ -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,7 +110,7 @@ class NotificationHelper(private val context: Context) { notificationManager.cancel(NOTIFICATION_ID) } - fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0 + override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0 fun showUnifiedProgressNotification( title: String, diff --git a/app/src/main/java/com/example/research/feature/download/DownloadManager.kt b/app/src/main/java/com/example/research/feature/download/DownloadManager.kt index d7b8347..e956a23 100644 --- a/app/src/main/java/com/example/research/feature/download/DownloadManager.kt +++ b/app/src/main/java/com/example/research/feature/download/DownloadManager.kt @@ -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) { if (downloadState.value is DownloadState.Loading) return if (!downloadScope.isActive) return + onFlowStarted?.invoke() cancelCleanupJob = null downloadJob = downloadScope.launch { mutex.withLock { diff --git a/app/src/main/java/com/example/research/feature/download/service/DictionaryForegroundService.kt b/app/src/main/java/com/example/research/feature/download/service/DictionaryForegroundService.kt index f377c90..ed0dbc9 100644 --- a/app/src/main/java/com/example/research/feature/download/service/DictionaryForegroundService.kt +++ b/app/src/main/java/com/example/research/feature/download/service/DictionaryForegroundService.kt @@ -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,168 +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, - ) - } else if (snapshot == null && !isFinalizing && - notificationHelper.hasActiveProgressNotification() - ) { - notificationHelper.cancelNotification() - } - } - } - } - - 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) { - 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() { @@ -253,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 { @@ -279,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() } } diff --git a/app/src/main/java/com/example/research/feature/import/DictionaryImportManager.kt b/app/src/main/java/com/example/research/feature/import/DictionaryImportManager.kt index 9bc86ea..28f781a 100644 --- a/app/src/main/java/com/example/research/feature/import/DictionaryImportManager.kt +++ b/app/src/main/java/com/example/research/feature/import/DictionaryImportManager.kt @@ -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.Idle) val importState: StateFlow = mutableImportState.asStateFlow() private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var importJob: Job? = null private val importedFiles = Collections.synchronizedList(mutableListOf()) + var onFlowStarted: (() -> Unit)? = null + var onTerminal: ((ImportState) -> Unit)? = null fun importDictionaries(uris: List) { importJob?.cancel() + onFlowStarted?.invoke() importJob = managerScope.launch { mutableImportState.value = ImportState.Idle performImport(uris) @@ -113,7 +117,7 @@ class DictionaryImportManager( currentCoroutineContext().ensureActive() - mutableImportState.value = resolveImportOutcome( + val terminalState = resolveImportOutcome( importedCount = importedFiles.size, skippedNames = skippedNames, deferredErrors = deferredErrors, @@ -124,15 +128,19 @@ class DictionaryImportManager( 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) } } @@ -162,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 @@ -187,7 +195,7 @@ class DictionaryImportManager( mutableImportState.value = ImportState.Idle } - fun getAndClearImportedFiles(): List { + override fun getAndClearImportedFiles(): List { val files = importedFiles.toList() importedFiles.clear() return files