diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt index 455e94890..cedc34987 100644 --- a/app/src/main/java/io/legado/app/model/CacheBook.kt +++ b/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 @@ -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) { diff --git a/app/src/main/java/io/legado/app/model/CacheBookModel.kt b/app/src/main/java/io/legado/app/model/CacheBookModel.kt index 345092665..e0e151d9c 100644 --- a/app/src/main/java/io/legado/app/model/CacheBookModel.kt +++ b/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 @@ -58,9 +63,13 @@ class CacheBookModel( private val tasks = CompositeCoroutine() private val repository = CacheDownloadRepository() private val retryCountMap = hashMapOf() + @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 diff --git a/app/src/main/java/io/legado/app/model/ReadBook.kt b/app/src/main/java/io/legado/app/model/ReadBook.kt index 885bb69b0..ea7c6f405 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/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) } }