Jelajahi Sumber

Merge pull request #1972 from a328929/01

修复书源校验通知消失的问题,修复锁屏书源校验异常退出的问题,修复书源校验时文字显示不全的问题,修复在书源管理页面进行架构迁移时,遗留的其余问题
Kudomaga 3 bulan lalu
induk
melakukan
1517d00377

+ 3 - 0
app/src/main/AndroidManifest.xml

@@ -448,6 +448,9 @@
         <service
             android:name=".service.CacheBookService"
             android:foregroundServiceType="dataSync" />
+        <service
+            android:name=".service.BookSourceCheckService"
+            android:foregroundServiceType="dataSync" />
         <service
             android:name=".service.ExportBookService"
             android:foregroundServiceType="dataSync" />

+ 13 - 0
app/src/main/java/io/legado/app/App.kt

@@ -19,6 +19,7 @@ import com.jeremyliao.liveeventbus.logger.DefaultLogger
 import com.script.rhino.ReadOnlyJavaObject
 import com.script.rhino.RhinoScriptEngine
 import com.script.rhino.RhinoWrapFactory
+import io.legado.app.constant.AppConst.channelIdBookSourceCheck
 import io.legado.app.constant.AppConst.channelIdDownload
 import io.legado.app.constant.AppConst.channelIdReadAloud
 import io.legado.app.constant.AppConst.channelIdWeb
@@ -300,6 +301,17 @@ class App : Application(), ImageLoaderFactory {
             lockscreenVisibility = Notification.VISIBILITY_PUBLIC
         }
 
+        val bookSourceCheckChannel = NotificationChannel(
+            channelIdBookSourceCheck,
+            getString(R.string.check_book_source),
+            NotificationManager.IMPORTANCE_DEFAULT
+        ).apply {
+            enableLights(false)
+            enableVibration(false)
+            setSound(null, null)
+            lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+        }
+
         val webChannel = NotificationChannel(
             channelIdWeb,
             getString(R.string.web_service),
@@ -315,6 +327,7 @@ class App : Application(), ImageLoaderFactory {
         notificationManager.createNotificationChannels(
             listOf(
                 downloadChannel,
+                bookSourceCheckChannel,
                 readAloudChannel,
                 webChannel
             )

+ 1 - 0
app/src/main/java/io/legado/app/constant/AppConst.kt

@@ -17,6 +17,7 @@ object AppConst {
     const val APP_TAG = "Legado"
 
     const val channelIdDownload = "channel_download"
+    const val channelIdBookSourceCheck = "channel_book_source_check"
     const val channelIdReadAloud = "channel_read_aloud"
     const val channelIdWeb = "channel_web"
 

+ 1 - 0
app/src/main/java/io/legado/app/constant/NotificationId.kt

@@ -12,6 +12,7 @@ object NotificationId {
     const val ExportBookService = 104
     const val WebService = 105
     const val DownloadService = 106
+    const val BookSourceCheckService = 107
     const val Download = 10000
     const val ExportBook = 201
 

+ 101 - 15
app/src/main/java/io/legado/app/data/repository/BookSourceCheckRepository.kt

@@ -5,7 +5,10 @@ import io.legado.app.constant.BookSourceType
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookSource
 import io.legado.app.domain.gateway.BookSourceCheckGateway
+import io.legado.app.domain.gateway.BookSourceCheckFailure
+import io.legado.app.domain.gateway.BookSourceCheckResult
 import io.legado.app.domain.gateway.BookSourceCheckState
+import io.legado.app.domain.gateway.BookSourceCheckStatus
 import io.legado.app.domain.gateway.CheckSourceSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.exception.ContentEmptyException
@@ -36,26 +39,79 @@ class BookSourceCheckRepository(
     override val state = _state.asStateFlow()
     override suspend fun check(sourceIds: Set<String>, keyword: String) {
         if (sourceIds.isEmpty() || _state.value.isRunning) return
-        _state.value = BookSourceCheckState(isRunning = true, total = sourceIds.size)
+        _state.value = BookSourceCheckState(
+            isRunning = true,
+            total = sourceIds.size,
+            results = sourceIds.associateWith {
+                BookSourceCheckResult(BookSourceCheckStatus.Pending)
+            },
+        )
         val dispatcher = Executors.newFixedThreadPool(
             downloadCacheSettingsGateway.currentSettings.threadCount.coerceAtLeast(1)
         ).asCoroutineDispatcher()
+        var completedNormally = false
         try {
             coroutineScope {
                 sourceIds.map { id ->
-                    async(dispatcher) { checkOne(id, keyword.ifBlank { "我的" }) }
+                    async(dispatcher) {
+                        runCatching { checkOne(id, keyword.ifBlank { "我的" }) }
+                            .onFailure { error ->
+                                coroutineContext.ensureActive()
+                                updateResult(
+                                    id = id,
+                                    name = "",
+                                    status = BookSourceCheckStatus.Failed,
+                                    failure = BookSourceCheckFailure.CheckFailed,
+                                    detail = error.displayMessage(),
+                                )
+                            }
+                    }
                 }.awaitAll()
             }
+            completedNormally = true
         } finally {
             dispatcher.close()
-            _state.update { it.copy(isRunning = false, currentSourceName = "") }
+            val unfinishedStatus = if (completedNormally) {
+                BookSourceCheckStatus.Failed
+            } else {
+                BookSourceCheckStatus.Cancelled
+            }
+            _state.update { state ->
+                val results = state.results.mapValues { (_, result) ->
+                    if (result.status.isTerminal) result
+                    else BookSourceCheckResult(
+                        status = unfinishedStatus,
+                        failure = BookSourceCheckFailure.Incomplete
+                            .takeIf { completedNormally },
+                    )
+                }
+                state.copy(
+                    isRunning = false,
+                    completed = results.values.count { it.status.isTerminal },
+                    currentSourceName = "",
+                    results = results,
+                )
+            }
         }
     }
 
     private suspend fun checkOne(sourceId: String, keyword: String) {
-        val source = bookSourceRepository.getBookSource(sourceId) ?: return
+        val source = bookSourceRepository.getBookSource(sourceId)
+        if (source == null) {
+            updateResult(
+                id = sourceId,
+                name = "",
+                status = BookSourceCheckStatus.Failed,
+                failure = BookSourceCheckFailure.SourceMissing,
+            )
+            return
+        }
         val startedAt = System.currentTimeMillis()
-        updateResult(sourceId, source.bookSourceName, "开始校验")
+        updateResult(
+            sourceId,
+            source.bookSourceName,
+            BookSourceCheckStatus.Running,
+        )
         val result = runCatching {
             withTimeout(settingsGateway.currentSettings.timeoutMillis) {
                 checkSource(source, keyword)
@@ -63,7 +119,6 @@ class BookSourceCheckRepository(
         }
         result.onSuccess {
             source.respondTime = System.currentTimeMillis() - startedAt
-            updateResult(sourceId, source.bookSourceName, "校验成功", completed = true)
         }.onFailure { error ->
             coroutineContext.ensureActive()
             when (error) {
@@ -74,27 +129,54 @@ class BookSourceCheckRepository(
             source.addErrorComment(error)
             source.respondTime =
                 settingsGateway.currentSettings.timeoutMillis + System.currentTimeMillis() - startedAt
-            updateResult(
+        }
+        val saveResult = runCatching { bookSourceRepository.updateSources(source) }
+        coroutineContext.ensureActive()
+        when {
+            result.isFailure -> updateResult(
+                sourceId,
+                source.bookSourceName,
+                BookSourceCheckStatus.Failed,
+                BookSourceCheckFailure.CheckFailed,
+                result.exceptionOrNull().displayMessage(),
+            )
+
+            saveResult.isFailure -> updateResult(
                 sourceId,
                 source.bookSourceName,
-                "校验失败:${error.localizedMessage}",
-                completed = true
+                BookSourceCheckStatus.Failed,
+                BookSourceCheckFailure.SaveFailed,
+                saveResult.exceptionOrNull().displayMessage(),
+            )
+
+            else -> updateResult(
+                sourceId,
+                source.bookSourceName,
+                BookSourceCheckStatus.Succeeded,
             )
         }
-        bookSourceRepository.updateSources(source)
     }
 
     private fun updateResult(
         id: String,
         name: String,
-        message: String,
-        completed: Boolean = false
+        status: BookSourceCheckStatus,
+        failure: BookSourceCheckFailure? = null,
+        detail: String? = null,
     ) {
         _state.update { state ->
+            val previous = state.results[id]
+            val completed = when {
+                previous?.status?.isTerminal == true -> state.completed
+                status.isTerminal -> state.completed + 1
+                else -> state.completed
+            }
             state.copy(
-                completed = state.completed + if (completed) 1 else 0,
-                currentSourceName = name,
-                results = state.results + (id to message),
+                completed = completed,
+                currentSourceName = name.ifBlank { state.currentSourceName },
+                results = state.results + (
+                    id to BookSourceCheckResult(status, failure, detail)
+                ),
             )
         }
     }
@@ -161,3 +243,7 @@ class BookSourceCheckRepository(
         }
     }
 }
+
+private fun Throwable?.displayMessage(): String? =
+    this?.localizedMessage?.takeIf { it.isNotBlank() }
+        ?: this?.javaClass?.simpleName?.takeIf { it.isNotBlank() }

+ 32 - 2
app/src/main/java/io/legado/app/domain/gateway/BookSourceCheckGateway.kt

@@ -2,13 +2,43 @@ package io.legado.app.domain.gateway
 
 import kotlinx.coroutines.flow.StateFlow
 
+enum class BookSourceCheckStatus(val isTerminal: Boolean) {
+    Pending(false),
+    Running(false),
+    Succeeded(true),
+    Failed(true),
+    Cancelled(true),
+}
+
+enum class BookSourceCheckFailure {
+    CheckFailed,
+    SourceMissing,
+    SaveFailed,
+    Incomplete,
+}
+
+data class BookSourceCheckResult(
+    val status: BookSourceCheckStatus,
+    val failure: BookSourceCheckFailure? = null,
+    val detail: String? = null,
+)
+
 data class BookSourceCheckState(
     val isRunning: Boolean = false,
     val total: Int = 0,
     val completed: Int = 0,
     val currentSourceName: String = "",
-    val results: Map<String, String> = emptyMap(),
-)
+    val results: Map<String, BookSourceCheckResult> = emptyMap(),
+) {
+    val succeededCount: Int
+        get() = results.values.count { it.status == BookSourceCheckStatus.Succeeded }
+
+    val failedCount: Int
+        get() = results.values.count { it.status == BookSourceCheckStatus.Failed }
+
+    val cancelledCount: Int
+        get() = results.values.count { it.status == BookSourceCheckStatus.Cancelled }
+}
 
 interface BookSourceCheckGateway {
     val state: StateFlow<BookSourceCheckState>

+ 205 - 0
app/src/main/java/io/legado/app/service/BookSourceCheckService.kt

@@ -0,0 +1,205 @@
+package io.legado.app.service
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.os.PowerManager
+import androidx.core.app.NotificationCompat
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.R
+import io.legado.app.base.BaseService
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.IntentAction
+import io.legado.app.constant.NotificationId
+import io.legado.app.domain.gateway.BookSourceCheckGateway
+import io.legado.app.domain.usecase.StartBookSourceCheckUseCase
+import io.legado.app.help.IntentData
+import io.legado.app.ui.main.MainActivity
+import io.legado.app.utils.activityPendingIntent
+import io.legado.app.utils.servicePendingIntent
+import io.legado.app.utils.startForegroundServiceCompat
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+import splitties.systemservices.notificationManager
+import splitties.systemservices.powerManager
+
+class BookSourceCheckService : BaseService() {
+
+    companion object {
+        private const val EXTRA_REQUEST_KEY = "bookSourceCheckRequestKey"
+
+        fun start(context: Context, ids: Set<String>, keyword: String) {
+            val requestKey = IntentData.put(CheckRequest(ids, keyword))
+            val intent = Intent(context, BookSourceCheckService::class.java).apply {
+                action = IntentAction.start
+                putExtra(EXTRA_REQUEST_KEY, requestKey)
+            }
+            context.startForegroundServiceCompat(intent)
+        }
+
+        fun stop(context: Context) {
+            context.startService(
+                Intent(context, BookSourceCheckService::class.java).apply {
+                    action = IntentAction.stop
+                }
+            )
+        }
+    }
+
+    private data class CheckRequest(
+        val ids: Set<String>,
+        val keyword: String,
+    )
+
+    private val checkGateway by inject<BookSourceCheckGateway>()
+    private val startBookSourceCheck by inject<StartBookSourceCheckUseCase>()
+    private var checkJob: Job? = null
+    private var finishedNotificationShown = false
+    private var wakeLockAcquired = false
+
+    private val wakeLock: PowerManager.WakeLock by lazy {
+        powerManager.newWakeLock(
+            PowerManager.PARTIAL_WAKE_LOCK,
+            "legado:BookSourceCheckService",
+        ).apply { setReferenceCounted(false) }
+    }
+
+    private val notificationBuilder by lazy {
+        NotificationCompat.Builder(this, AppConst.channelIdBookSourceCheck)
+            .setSmallIcon(R.drawable.ic_network_check)
+            .setContentTitle(getString(R.string.check_book_source))
+            .setContentIntent(
+                activityPendingIntent(
+                    MainActivity.createBookSourceManageIntent(this),
+                    "bookSourceCheck",
+                )
+            )
+            .setOnlyAlertOnce(true)
+            .setOngoing(true)
+            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+            .addAction(
+                R.drawable.ic_stop_black_24dp,
+                getString(R.string.cancel),
+                servicePendingIntent<BookSourceCheckService>(IntentAction.stop),
+            )
+    }
+
+    override fun onCreate() {
+        super.onCreate()
+        lifecycleScope.launch {
+            checkGateway.state.collectLatest { state ->
+                if (state.isRunning) {
+                    notificationBuilder
+                        .setContentText(
+                            getString(
+                                R.string.progress_show,
+                                state.currentSourceName,
+                                state.completed,
+                                state.total,
+                            )
+                        )
+                        .setProgress(state.total, state.completed, false)
+                    notificationManager.notify(
+                        NotificationId.BookSourceCheckService,
+                        notificationBuilder.build(),
+                    )
+                }
+            }
+        }
+    }
+
+    @SuppressLint("WakelockTimeout")
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        super.onStartCommand(intent, flags, startId)
+        when (intent?.action) {
+            IntentAction.start -> {
+                val request = IntentData.get<CheckRequest>(
+                    intent.getStringExtra(EXTRA_REQUEST_KEY)
+                )
+                if (request == null) {
+                    stopWithoutResultNotification()
+                } else if (checkJob?.isActive != true && !checkGateway.state.value.isRunning) {
+                    finishedNotificationShown = false
+                    wakeLock.acquire()
+                    wakeLockAcquired = true
+                    checkJob = lifecycleScope.launch {
+                        try {
+                            startBookSourceCheck(request.ids, request.keyword)
+                        } finally {
+                            showFinishedNotification()
+                            releaseWakeLock()
+                            stopForeground(STOP_FOREGROUND_DETACH)
+                            stopSelf()
+                        }
+                    }
+                }
+            }
+
+            IntentAction.stop -> {
+                if (checkJob?.isActive == true) checkJob?.cancel()
+                else stopWithoutResultNotification()
+            }
+        }
+        return START_NOT_STICKY
+    }
+
+    override fun onDestroy() {
+        releaseWakeLock()
+        if (!finishedNotificationShown) {
+            notificationManager.cancel(NotificationId.BookSourceCheckService)
+        }
+        super.onDestroy()
+    }
+
+    override fun startForegroundNotification() {
+        notificationBuilder
+            .setContentText(getString(R.string.service_starting))
+            .setProgress(0, 0, true)
+        startForeground(
+            NotificationId.BookSourceCheckService,
+            notificationBuilder.build(),
+        )
+    }
+
+    private fun showFinishedNotification() {
+        val state = checkGateway.state.value
+        val content = if (state.cancelledCount > 0) {
+            getString(
+                R.string.book_source_check_cancelled,
+                state.succeededCount,
+                state.failedCount,
+                state.cancelledCount,
+            )
+        } else {
+            getString(
+                R.string.book_source_check_completed,
+                state.succeededCount,
+                state.failedCount,
+            )
+        }
+        notificationBuilder
+            .clearActions()
+            .setContentText(content)
+            .setProgress(0, 0, false)
+            .setOngoing(false)
+            .setAutoCancel(true)
+        notificationManager.notify(
+            NotificationId.BookSourceCheckService,
+            notificationBuilder.build(),
+        )
+        finishedNotificationShown = true
+    }
+
+    private fun stopWithoutResultNotification() {
+        stopForeground(STOP_FOREGROUND_REMOVE)
+        notificationManager.cancel(NotificationId.BookSourceCheckService)
+        stopSelf()
+    }
+
+    private fun releaseWakeLock() {
+        if (wakeLockAcquired && wakeLock.isHeld) wakeLock.release()
+        wakeLockAcquired = false
+    }
+}

+ 7 - 0
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceContract.kt

@@ -109,6 +109,13 @@ sealed interface BookSourceIntent {
 }
 
 sealed interface BookSourceEffect {
+    data class StartCheck(
+        val ids: Set<String>,
+        val keyword: String,
+    ) : BookSourceEffect
+
+    data object CancelCheck : BookSourceEffect
+
     data class ShowSnackbar(
         val message: String,
         val actionLabel: String? = null,

+ 62 - 35
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt

@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
+import io.legado.app.service.BookSourceCheckService
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.ActionItem
@@ -75,7 +76,6 @@ import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
-import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.compose.koinViewModel
 import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -92,16 +92,43 @@ fun BookSourceRouteScreen(
     onDebugSource: (String) -> Unit,
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+    val snackbarHostState = remember { SnackbarHostState() }
+    val clipboardManager = LocalClipboard.current
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is BookSourceEffect.StartCheck -> {
+                    BookSourceCheckService.start(context, effect.ids, effect.keyword)
+                }
+
+                BookSourceEffect.CancelCheck -> BookSourceCheckService.stop(context)
+
+                is BookSourceEffect.ShowSnackbar -> {
+                    val result = snackbarHostState.showSnackbar(
+                        message = effect.message,
+                        actionLabel = effect.actionLabel,
+                        withDismissAction = true,
+                    )
+                    if (result == SnackbarResult.ActionPerformed && effect.url != null) {
+                        clipboardManager.setClipEntry(
+                            ClipEntry(ClipData.newPlainText("url", effect.url))
+                        )
+                    }
+                }
+            }
+        }
+    }
     BookSourceScreen(
-        state,
-        viewModel::onIntent,
-        onBackClick,
-        onAddSource,
-        onEditSource,
-        onLoginSource,
-        onSearchSource,
-        onDebugSource,
-        viewModel.effects,
+        state = state,
+        onIntent = viewModel::onIntent,
+        snackbarHostState = snackbarHostState,
+        onBackClick = onBackClick,
+        onAddSource = onAddSource,
+        onEditSource = onEditSource,
+        onLoginSource = onLoginSource,
+        onSearchSource = onSearchSource,
+        onDebugSource = onDebugSource,
     )
 }
 
@@ -110,13 +137,13 @@ fun BookSourceRouteScreen(
 fun BookSourceScreen(
     state: BookSourceUiState,
     onIntent: (BookSourceIntent) -> Unit,
+    snackbarHostState: SnackbarHostState,
     onBackClick: () -> Unit,
     onAddSource: () -> Unit,
     onEditSource: (String) -> Unit,
     onLoginSource: (String) -> Unit,
     onSearchSource: (String, String) -> Unit,
     onDebugSource: (String) -> Unit,
-    effects: Flow<BookSourceEffect>,
 ) {
     val context = LocalContext.current
     val rules = state.items
@@ -157,27 +184,7 @@ fun BookSourceScreen(
             }
         }
     }
-    val snackbarHostState = remember { SnackbarHostState() }
-    val clipboardManager = LocalClipboard.current
     val cancelLabel = stringResource(R.string.cancel)
-    LaunchedEffect(Unit) {
-        effects.collectLatest { effect ->
-            when (effect) {
-                is BookSourceEffect.ShowSnackbar -> {
-                    val result = snackbarHostState.showSnackbar(
-                        message = effect.message,
-                        actionLabel = effect.actionLabel,
-                        withDismissAction = true,
-                    )
-                    if (result == SnackbarResult.ActionPerformed && effect.url != null) {
-                        clipboardManager.setClipEntry(
-                            ClipEntry(ClipData.newPlainText("url", effect.url))
-                        )
-                    }
-                }
-            }
-        }
-    }
     LaunchedEffect(state.checkProgress) {
         val progress = state.checkProgress ?: return@LaunchedEffect
         val result = snackbarHostState.showSnackbar(
@@ -607,10 +614,30 @@ fun BookSourceScreen(
                                 }
                             },
                             title = item.name,
-                            subtitle = listOfNotNull(
-                                item.group,
-                                item.checkMessage
-                            ).joinToString(" · ").ifBlank { null },
+                            supportingContent = if (
+                                item.group != null || item.checkMessage != null
+                            ) {
+                                {
+                                    Column {
+                                        item.group?.let { group ->
+                                            AppText(
+                                                text = group,
+                                                style = LegadoTheme.typography.bodySmall,
+                                                maxLines = 1,
+                                                overflow = TextOverflow.Ellipsis,
+                                            )
+                                        }
+                                        item.checkMessage?.let { message ->
+                                            AppText(
+                                                text = message,
+                                                style = LegadoTheme.typography.bodySmall,
+                                            )
+                                        }
+                                    }
+                                }
+                            } else {
+                                null
+                            },
                             isEnabled = item.enabled,
                             isSelected = item.id in selectedIds,
                             canReorder = canReorder,

+ 75 - 10
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt

@@ -11,11 +11,13 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.UploadRepository
+import io.legado.app.domain.gateway.BookSourceCheckFailure
 import io.legado.app.domain.gateway.BookSourceCheckGateway
+import io.legado.app.domain.gateway.BookSourceCheckResult
+import io.legado.app.domain.gateway.BookSourceCheckStatus
 import io.legado.app.domain.gateway.CheckSourceSettings
 import io.legado.app.domain.gateway.CheckSourceSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.usecase.StartBookSourceCheckUseCase
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.newCallResponseBody
@@ -36,7 +38,6 @@ import io.legado.app.utils.splitNotBlank
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -55,7 +56,6 @@ class BookSourceViewModel(
     private val otherSettingsGateway: OtherSettingsGateway,
     private val checkGateway: BookSourceCheckGateway,
     private val checkSettingsGateway: CheckSourceSettingsGateway,
-    private val startBookSourceCheck: StartBookSourceCheckUseCase,
 ) : ViewModel() {
     companion object {
         const val FILTER_ENABLED = "@enabled"
@@ -80,14 +80,27 @@ class BookSourceViewModel(
         MutableStateFlow<BaseImportUiState<BookSource>>(BaseImportUiState.Idle)
     private val _effects = MutableSharedFlow<BookSourceEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
-    private var checkJob: Job? = null
 
     init {
         viewModelScope.launch {
             var wasRunning = false
             checkGateway.state.collect { state ->
                 if (wasRunning && !state.isRunning) {
-                    _effects.tryEmit(BookSourceEffect.ShowSnackbar("书源校验完成"))
+                    val message = if (state.cancelledCount > 0) {
+                        application.getString(
+                            io.legado.app.R.string.book_source_check_cancelled,
+                            state.succeededCount,
+                            state.failedCount,
+                            state.cancelledCount,
+                        )
+                    } else {
+                        application.getString(
+                            io.legado.app.R.string.book_source_check_completed,
+                            state.succeededCount,
+                            state.failedCount,
+                        )
+                    }
+                    _effects.tryEmit(BookSourceEffect.ShowSnackbar(message))
                 }
                 wasRunning = state.isRunning
             }
@@ -160,7 +173,11 @@ class BookSourceViewModel(
         checkSettingsGateway.settings,
     ) { state, importing, check, settings ->
         state.copy(
-            items = state.items.map { it.copy(checkMessage = check.results[it.id]) }
+            items = state.items.map { item ->
+                item.copy(
+                    checkMessage = check.results[item.id]?.displayMessage(application)
+                )
+            }
                 .toImmutableList(),
             importState = importing,
             checkProgress = if (check.isRunning) application.getString(
@@ -229,10 +246,16 @@ class BookSourceViewModel(
             is BookSourceIntent.DeleteGroup -> updateGroup(intent.group, "")
             is BookSourceIntent.CheckSelectedInterval -> checkInterval(intent.ids)
             is BookSourceIntent.StartCheck -> {
-                if (checkJob?.isActive != true) {
-                    checkJob = viewModelScope.launch {
+                if (checkGateway.state.value.isRunning) {
+                    _effects.tryEmit(
+                        BookSourceEffect.ShowSnackbar(
+                            application.getString(io.legado.app.R.string.source_already_checking)
+                        )
+                    )
+                } else {
+                    viewModelScope.launch {
                         checkSettingsGateway.update(intent.options.toSettings())
-                        startBookSourceCheck(intent.ids, intent.keyword)
+                        _effects.emit(BookSourceEffect.StartCheck(intent.ids, intent.keyword))
                     }
                 }
             }
@@ -241,7 +264,7 @@ class BookSourceViewModel(
                 checkSettingsGateway.update(intent.options.toSettings())
             }
 
-            BookSourceIntent.CancelCheck -> checkJob?.cancel()
+            BookSourceIntent.CancelCheck -> _effects.tryEmit(BookSourceEffect.CancelCheck)
             is BookSourceIntent.Import -> importSources(intent.text)
             is BookSourceIntent.Export -> exportSources(intent.uri, intent.ids)
             is BookSourceIntent.Upload -> uploadSources(intent.ids)
@@ -541,6 +564,48 @@ private fun BookSourceCheckOptionsUi.toSettings() = CheckSourceSettings(
     checkContent = checkContent,
 )
 
+private fun BookSourceCheckResult.displayMessage(application: Application): String {
+    val errorDetail = detail ?: application.getString(io.legado.app.R.string.unknown_error)
+    return when (status) {
+        BookSourceCheckStatus.Pending -> application.getString(
+            io.legado.app.R.string.book_source_check_waiting
+        )
+
+        BookSourceCheckStatus.Running -> application.getString(
+            io.legado.app.R.string.book_source_check_running
+        )
+
+        BookSourceCheckStatus.Succeeded -> application.getString(
+            io.legado.app.R.string.book_source_check_succeeded
+        )
+
+        BookSourceCheckStatus.Cancelled -> application.getString(
+            io.legado.app.R.string.book_source_check_item_cancelled
+        )
+
+        BookSourceCheckStatus.Failed -> when (failure) {
+            BookSourceCheckFailure.SourceMissing -> application.getString(
+                io.legado.app.R.string.book_source_check_source_missing
+            )
+
+            BookSourceCheckFailure.SaveFailed -> application.getString(
+                io.legado.app.R.string.book_source_check_save_failed,
+                errorDetail,
+            )
+
+            BookSourceCheckFailure.Incomplete -> application.getString(
+                io.legado.app.R.string.book_source_check_incomplete
+            )
+
+            BookSourceCheckFailure.CheckFailed,
+            null -> application.getString(
+                io.legado.app.R.string.book_source_check_failed,
+                errorDetail,
+            )
+        }
+    }
+}
+
 private fun List<BookSourcePart>.filterFor(filter: String?, query: String): List<BookSourcePart> =
     filter { source ->
         val filterMatch = when (filter) {

+ 10 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -361,6 +361,16 @@
     <string name="header_padding">页眉边距</string>
     <string name="footer_padding">页脚边距</string>
     <string name="check_book_source">校验书源</string>
+    <string name="book_source_check_completed">校验完成:成功 %1$d,失败 %2$d</string>
+    <string name="book_source_check_cancelled">校验已停止:成功 %1$d,失败 %2$d,取消 %3$d</string>
+    <string name="book_source_check_waiting">等待校验</string>
+    <string name="book_source_check_running">正在校验</string>
+    <string name="book_source_check_succeeded">校验成功</string>
+    <string name="book_source_check_failed">校验失败:%1$s</string>
+    <string name="book_source_check_source_missing">校验失败:书源不存在</string>
+    <string name="book_source_check_save_failed">校验失败:无法保存结果,%1$s</string>
+    <string name="book_source_check_incomplete">校验失败:任务未完成</string>
+    <string name="book_source_check_item_cancelled">校验已取消</string>
     <string name="check_select_source">校验所选</string>
     <string name="progress_show">%1$s      进度 %2$d/%3$d</string>
     <string name="tts_fix">请安装并选择中文 TTS!</string>

+ 10 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -303,6 +303,16 @@
     <string name="padding_right">右邊距</string>
     <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="check_book_source">校驗書源</string>
+    <string name="book_source_check_completed">校驗完成:成功 %1$d,失敗 %2$d</string>
+    <string name="book_source_check_cancelled">校驗已停止:成功 %1$d,失敗 %2$d,取消 %3$d</string>
+    <string name="book_source_check_waiting">等待校驗</string>
+    <string name="book_source_check_running">正在校驗</string>
+    <string name="book_source_check_succeeded">校驗成功</string>
+    <string name="book_source_check_failed">校驗失敗:%1$s</string>
+    <string name="book_source_check_source_missing">校驗失敗:書源不存在</string>
+    <string name="book_source_check_save_failed">校驗失敗:無法保存結果,%1$s</string>
+    <string name="book_source_check_incomplete">校驗失敗:任務未完成</string>
+    <string name="book_source_check_item_cancelled">校驗已取消</string>
     <string name="check_select_source">校驗所選</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>
     <string name="tts_fix">請安裝並選擇中文 TTS!</string>

+ 10 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -305,6 +305,16 @@
     <string name="padding_right">右邊距</string>
     <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="check_book_source">校驗書源</string>
+    <string name="book_source_check_completed">校驗完成:成功 %1$d,失敗 %2$d</string>
+    <string name="book_source_check_cancelled">校驗已停止:成功 %1$d,失敗 %2$d,取消 %3$d</string>
+    <string name="book_source_check_waiting">等待校驗</string>
+    <string name="book_source_check_running">正在校驗</string>
+    <string name="book_source_check_succeeded">校驗成功</string>
+    <string name="book_source_check_failed">校驗失敗:%1$s</string>
+    <string name="book_source_check_source_missing">校驗失敗:書源不存在</string>
+    <string name="book_source_check_save_failed">校驗失敗:無法儲存結果,%1$s</string>
+    <string name="book_source_check_incomplete">校驗失敗:工作未完成</string>
+    <string name="book_source_check_item_cancelled">校驗已取消</string>
     <string name="check_select_source">校驗所選</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>
     <string name="tts_fix">請安裝並選擇中文TTS!</string>

+ 10 - 0
app/src/main/res/values/strings.xml

@@ -391,6 +391,16 @@
     <string name="header_padding">Header Padding</string>
     <string name="footer_padding">Footer Padding</string>
     <string name="check_book_source">Check book sources</string>
+    <string name="book_source_check_completed">Check completed: %1$d succeeded, %2$d failed</string>
+    <string name="book_source_check_cancelled">Check stopped: %1$d succeeded, %2$d failed, %3$d cancelled</string>
+    <string name="book_source_check_waiting">Waiting to check</string>
+    <string name="book_source_check_running">Checking</string>
+    <string name="book_source_check_succeeded">Check succeeded</string>
+    <string name="book_source_check_failed">Check failed: %1$s</string>
+    <string name="book_source_check_source_missing">Check failed: source no longer exists</string>
+    <string name="book_source_check_save_failed">Check failed: unable to save result, %1$s</string>
+    <string name="book_source_check_incomplete">Check failed: task did not finish</string>
+    <string name="book_source_check_item_cancelled">Check cancelled</string>
     <string name="check_select_source">Check the selected source</string>
     <string name="progress_show">%1$s      Progress %2$d/%3$d</string>
     <string name="tts_fix">Please install and select Chinese TTS!</string>