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.
This commit is contained in:
@@ -37,12 +37,17 @@ 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()
|
||||||
|
|
||||||
NotificationHelper(this).cancelNotification()
|
val notificationHelper = NotificationHelper(this)
|
||||||
|
notificationHelper.cancelProgressNotification()
|
||||||
|
|
||||||
preferencesManager = PreferencesManager(this)
|
preferencesManager = PreferencesManager(this)
|
||||||
applicationScope.launch(Dispatchers.IO) {
|
applicationScope.launch(Dispatchers.IO) {
|
||||||
@@ -80,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,7 +110,7 @@ class NotificationHelper(private val context: Context) {
|
|||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
|
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
|
||||||
|
|
||||||
fun showUnifiedProgressNotification(
|
fun showUnifiedProgressNotification(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -41,11 +41,21 @@ 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 {
|
||||||
@@ -137,6 +147,7 @@ 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 {
|
||||||
|
|||||||
+25
-185
@@ -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,168 +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,
|
|
||||||
)
|
|
||||||
} 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<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() {
|
||||||
@@ -253,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 {
|
||||||
@@ -279,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)
|
||||||
@@ -113,7 +117,7 @@ class DictionaryImportManager(
|
|||||||
|
|
||||||
currentCoroutineContext().ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
|
|
||||||
mutableImportState.value = resolveImportOutcome(
|
val terminalState = resolveImportOutcome(
|
||||||
importedCount = importedFiles.size,
|
importedCount = importedFiles.size,
|
||||||
skippedNames = skippedNames,
|
skippedNames = skippedNames,
|
||||||
deferredErrors = deferredErrors,
|
deferredErrors = deferredErrors,
|
||||||
@@ -124,15 +128,19 @@ class DictionaryImportManager(
|
|||||||
invalidMessage = { message -> message },
|
invalidMessage = { message -> message },
|
||||||
nothingImportedMessage = { context.getString(R.string.import_nothing_imported) },
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,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
|
||||||
@@ -187,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
|
||||||
|
|||||||
Reference in New Issue
Block a user