HapeLee 5 месяцев назад
Родитель
Сommit
af84848a58

+ 17 - 2
app/src/main/java/io/legado/app/model/CacheBook.kt

@@ -128,6 +128,10 @@ object CacheBook {
     @Volatile
     private var lastQueueStats = QueueStats(0, 0)
 
+    @Volatile
+    private var lastSummaryUpdateTime = 0L
+    private const val SUMMARY_UPDATE_THROTTLE_MS = 100L
+
     private val successDownloadCount = AtomicInteger(0)
 
     val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
@@ -177,6 +181,17 @@ object CacheBook {
     }
 
     private fun updateSummary() {
+        val now = System.currentTimeMillis()
+        if (now - lastSummaryUpdateTime < SUMMARY_UPDATE_THROTTLE_MS) {
+            return
+        }
+        lastSummaryUpdateTime = now
+        val stats = collectQueueStats()
+        lastQueueStats = stats
+        _downloadSummaryFlow.value = buildSummary(stats)
+    }
+
+    private fun updateSummaryImmediate() {
         val stats = collectQueueStats()
         lastQueueStats = stats
         _downloadSummaryFlow.value = buildSummary(stats)
@@ -482,7 +497,7 @@ object CacheBook {
         } else {
             stateStore.clearRuntimeState()
         }
-        updateSummary()
+        updateSummaryImmediate()
     }
 
     fun shutdownPreservingPaused() {
@@ -502,7 +517,7 @@ object CacheBook {
         pendingRemoveRequests.clear()
         clearPendingAdmissions()
         stateStore.clearRuntimeState()
-        updateSummary()
+        updateSummaryImmediate()
     }
 
     fun setWorkingState(value: Boolean) {

+ 40 - 13
app/src/main/java/io/legado/app/model/CacheBookModel.kt

@@ -29,6 +29,11 @@ class CacheBookModel(
     private val host: Host,
 ) {
 
+    private companion object {
+        /** Download timeout: 2 minutes */
+        const val DOWNLOAD_TIMEOUT_MS = 120_000L
+    }
+
     interface Host {
         val stateStore: CacheDownloadStateStore
         val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
@@ -58,9 +63,13 @@ class CacheBookModel(
     private val tasks = CompositeCoroutine()
     private val repository = CacheDownloadRepository()
     private val retryCountMap = hashMapOf<Int, Int>()
+    @Volatile
     private var isStopped = false
+    @Volatile
     private var waitingRetry = false
+    @Volatile
     private var isLoading = false
+    @Volatile
     private var isPaused = false
 
     @Synchronized
@@ -91,7 +100,6 @@ class CacheBookModel(
     @Synchronized
     fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
 
-    @Synchronized
     fun isPaused(): Boolean = isPaused
 
     @Synchronized
@@ -120,12 +128,10 @@ class CacheBookModel(
         return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading || chapterTasks.isNotEmpty()
     }
 
-    @Synchronized
     fun isStop(): Boolean {
         return isStopped || (!isRun() && !waitingRetry)
     }
 
-    @Synchronized
     fun isLoading(): Boolean = isLoading
 
     @Synchronized
@@ -498,20 +504,23 @@ class CacheBookModel(
         chapter: BookChapter,
         chapterIndex: Int,
     ) {
-        task.onSuccess {
+        task.onSuccess(IO) {
             onSuccess(chapter)
             (it as? String)?.let { content ->
                 emitPendingReadContent(chapter, content)
             }
-        }.onError {
+        }.onError(IO) {
             onPreError(chapter, it)
-            delay(1000)
-            onPostError(chapter, it)
+            try {
+                delay(1000)
+            } finally {
+                onPostError(chapter, it)
+            }
             emitPendingReadError(chapter, it)
-        }.onCancel {
+        }.onCancel(IO) {
             onCancel(chapterIndex)
             emitPendingReadCanceled(chapter)
-        }.onFinally {
+        }.onFinally(IO) {
             chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) }
             onFinally()
         }
@@ -548,10 +557,27 @@ class CacheBookModel(
         chapter: BookChapter,
         semaphore: Semaphore?,
         resetPageOffset: Boolean = false
-    ) {
+    ): Boolean {
         if (!markChapterDownloadStarted(chapter.index)) {
-            markPendingReadRequest(chapter.index, resetPageOffset)
-            return
+            // Chapter is already in onDownloadSet. Check if the task is actually alive.
+            val hasLiveTask = synchronized(this) {
+                chapterTasks.containsKey(chapter.index)
+            }
+            if (!hasLiveTask) {
+                // Stale entry: onDownloadSet has the index but no live task.
+                // Clean up and retry.
+                synchronized(this) {
+                    onDownloadSet.remove(chapter.index)
+                }
+                notifyDownloadSetChanged()
+                if (!markChapterDownloadStarted(chapter.index)) {
+                    markPendingReadRequest(chapter.index, resetPageOffset)
+                    return false
+                }
+            } else {
+                markPendingReadRequest(chapter.index, resetPageOffset)
+                return true
+            }
         }
         repository.downloadContentTask(
             scope = scope,
@@ -562,7 +588,7 @@ class CacheBookModel(
             context = IO,
             executeContext = IO,
             semaphore = semaphore
-        ).onSuccess { content ->
+        ).timeout(DOWNLOAD_TIMEOUT_MS).onSuccess { content ->
             onSuccess(chapter)
             ReadBook.downloadedChapters.add(chapter.index)
             ReadBook.downloadFailChapters.remove(chapter.index)
@@ -580,6 +606,7 @@ class CacheBookModel(
         }.onFinally {
             host.onTaskQueuesChanged(book.bookUrl)
         }.start()
+        return true
     }
 
     @Synchronized

+ 36 - 15
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -30,6 +30,7 @@ import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.CacheBookService
 import io.legado.app.ui.book.read.page.entities.TextChapter
+import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.utils.postEvent
@@ -42,17 +43,17 @@ import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.TimeoutCancellationException
 import kotlinx.coroutines.cancelChildren
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.receiveAsFlow
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.Semaphore
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 import splitties.init.appCtx
@@ -750,7 +751,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         val book = book ?: return removeLoading(chapter.index)
         val bookSource = bookSource
         if (bookSource != null) {
-            CacheBook.getOrCreate(bookSource, book).download(scope, chapter, semaphore)
+            val started =
+                CacheBook.getOrCreate(bookSource, book).download(scope, chapter, semaphore)
+            if (!started) {
+                removeLoading(chapter.index)
+            }
         } else {
             val msg = if (book.isLocal) "无内容" else "没有书源"
             contentLoadFinish(
@@ -824,7 +829,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                     }
                     callBack?.upMenuView()
                     var available = false
-                    for (page in textChapter.layoutChannel) {
+                    collectLayoutPages(textChapter) { page ->
                         val index = page.index
                         if (!available && page.containPos(durChapterPos)) {
                             if (upContent) {
@@ -849,7 +854,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         ensureActive()
                         prevTextChapter = textChapter
                     }
-                    textChapter.layoutChannel.receiveAsFlow().collect()
+                    collectLayoutPages(textChapter) {}
                     if (upContent) callBack?.upContent(offset, resetPageOffset)
                 }
 
@@ -858,10 +863,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         ensureActive()
                         nextTextChapter = textChapter
                     }
-                    for (page in textChapter.layoutChannel) {
-                        if (page.index > 1) {
-                            continue
-                        }
+                    collectLayoutPages(textChapter) { page ->
+                        if (page.index > 1) return@collectLayoutPages
                         if (upContent) callBack?.upContent(offset, resetPageOffset)
                     }
                 }
@@ -881,6 +884,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         job.start()
     }
 
+    /**
+     * Safely collect pages from a TextChapterLayout's channel with timeout protection.
+     * Prevents indefinite blocking if the layout job hangs without closing the channel.
+     */
+    private suspend fun collectLayoutPages(
+        textChapter: TextChapter,
+        onPage: (TextPage) -> Unit,
+    ) {
+        try {
+            withTimeout(30_000L) {
+                for (page in textChapter.layoutChannel) {
+                    ensureActive()
+                    onPage(page)
+                }
+            }
+        } catch (_: TimeoutCancellationException) {
+            AppLog.put("Layout channel timeout for chapter ${textChapter.chapter.index}")
+        }
+    }
+
     suspend fun contentLoadFinishAwait(
         book: Book,
         chapter: BookChapter,
@@ -911,7 +934,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                     }
                     callBack?.upMenuView()
                     var available = false
-                    for (page in textChapter.layoutChannel) {
+                    collectLayoutPages(textChapter) { page ->
                         val index = page.index
                         if (!available && page.containPos(durChapterPos)) {
                             if (upContent) {
@@ -936,7 +959,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                     withContext(Main) {
                         prevTextChapter = textChapter
                     }
-                    textChapter.layoutChannel.receiveAsFlow().collect()
+                    collectLayoutPages(textChapter) {}
                     if (upContent) callBack?.upContent(offset, resetPageOffset)
                 }
 
@@ -945,10 +968,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                     withContext(Main) {
                         nextTextChapter = textChapter
                     }
-                    for (page in textChapter.layoutChannel) {
-                        if (page.index > 1) {
-                            continue
-                        }
+                    collectLayoutPages(textChapter) { page ->
+                        if (page.index > 1) return@collectLayoutPages
                         if (upContent) callBack?.upContent(offset, resetPageOffset)
                     }
                 }