diff --git a/app/src/main/java/io/legado/app/help/book/BookHelp.kt b/app/src/main/java/io/legado/app/help/book/BookHelp.kt index fb994c5bf..174569078 100644 --- a/app/src/main/java/io/legado/app/help/book/BookHelp.kt +++ b/app/src/main/java/io/legado/app/help/book/BookHelp.kt @@ -37,10 +37,13 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.apache.commons.text.similarity.JaccardSimilarity import splitties.init.appCtx @@ -285,15 +288,35 @@ object BookHelp { } } + fun countImagesInContent(bookChapter: BookChapter, content: String): Int { + var count = 0 + val matcher = AppPattern.imgPattern.matcher(content) + while (matcher.find()) { + if (matcher.group(1) != null) count++ + } + return count + } + suspend fun saveImages( bookSource: BookSource, book: Book, bookChapter: BookChapter, content: String, - concurrency: Int = OtherConfig.threadCount + concurrency: Int = OtherConfig.threadCount, + onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null, ) = coroutineScope { - flowImages(bookChapter, content).onEachParallel(concurrency) { mSrc -> + val imageUrls = flowImages(bookChapter, content).toList() + val total = imageUrls.size + onProgress?.invoke(0, total) + if (total == 0) return@coroutineScope + val progressMutex = Mutex() + var completed = 0 + imageUrls.asFlow().onEachParallel(concurrency) { mSrc -> saveImage(bookSource, book, mSrc, bookChapter) + progressMutex.withLock { + completed++ + onProgress?.invoke(completed, total) + } }.collect() } @@ -464,6 +487,13 @@ object BookHelp { /** * 检测该章节是否下载 */ + fun countImageCachedChapters(book: Book): Int { + if (!book.isImage) return 0 + return appDb.bookChapterDao.getChapterList(book.bookUrl).count { chapter -> + chapter.isVolume || hasImageFilesCached(book, chapter) + } + } + fun hasContent(book: Book, bookChapter: BookChapter): Boolean { return if (book.isLocalTxt || (bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title)) @@ -479,7 +509,24 @@ object BookHelp { } /** - * 检测图片是否下载 + * UI/队列用:仅检查图片文件是否都存在,不做 bitmap 解码校验。 + */ + fun hasImageFilesCached(book: Book, bookChapter: BookChapter): Boolean { + if (!hasContent(book, bookChapter)) { + return false + } + var ret = true + forEachImageSrc(book, bookChapter) { src -> + if (!isImageExist(book, src)) { + ret = false + return@forEachImageSrc + } + } + return ret + } + + /** + * 检测图片是否下载(含 bitmap 解码校验,用于实际下载决策) */ fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean { if (!hasContent(book, bookChapter)) { diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt index dc50fe4c3..a7cc68b60 100644 --- a/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt +++ b/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt @@ -9,7 +9,9 @@ import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.util.ContentLengthInputStream import com.script.rhino.runScriptWithContext import io.legado.app.data.entities.BaseSource +import io.legado.app.data.entities.Book import io.legado.app.exception.NoStackTraceException +import io.legado.app.help.book.BookHelp import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.http.addHeaders import io.legado.app.help.http.okHttpClient @@ -45,6 +47,7 @@ class OkHttpStreamFetcher( private val coroutineContext = SupervisorJob() private val coroutineScope = CoroutineScope(coroutineContext) private lateinit var analyzedUrl: GlideUrl + private var dataSource = DataSource.REMOTE @Volatile private var call: Call? = null @@ -68,6 +71,20 @@ class OkHttpStreamFetcher( source = SourceHelp.getSource(sourceUrl) } + this.callback = callback + + if (manga) { + ReadManga.book?.let { book -> + val src = url.toString() + if (BookHelp.isImageExist(book, src)) { + Coroutine.async(coroutineScope, executeContext = IO) { + loadLocalImage(book, src) + } + return + } + } + } + analyzedUrl = AnalyzeUrl( url.toString(), source = source, @@ -77,7 +94,7 @@ class OkHttpStreamFetcher( val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl()) requestBuilder.addHeaders(analyzedUrl.headers) val request: Request = requestBuilder.build() - this.callback = callback + dataSource = DataSource.REMOTE call = if (manga) { okHttpClientManga.newCall(request) } else { @@ -86,6 +103,29 @@ class OkHttpStreamFetcher( call?.enqueue(this) } + private suspend fun loadLocalImage(book: Book, src: String) { + try { + val bytes = BookHelp.getImage(book, src).readBytes() + val decodeResult = if (ImageUtils.skipDecode(source, isCover = false)) { + ByteArrayInputStream(bytes) + } else { + runScriptWithContext(coroutineContext) { + ImageUtils.decode( + src, + bytes, + isCover = false, + source, + book + )?.inputStream() + } + } + dataSource = DataSource.LOCAL + onStreamReady(decodeResult) + } catch (e: Exception) { + callback?.onLoadFailed(e) + } + } + override fun cleanup() { kotlin.runCatching { stream?.close() @@ -105,7 +145,7 @@ class OkHttpStreamFetcher( } override fun getDataSource(): DataSource { - return DataSource.REMOTE + return dataSource } override fun onFailure(call: Call, e: IOException) { 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 6393628b9..f8fb4b508 100644 --- a/app/src/main/java/io/legado/app/model/CacheBookModel.kt +++ b/app/src/main/java/io/legado/app/model/CacheBookModel.kt @@ -4,7 +4,9 @@ import io.legado.app.constant.AppLog import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource +import io.legado.app.help.book.isImage import io.legado.app.exception.ConcurrentException +import io.legado.app.help.book.BookHelp import io.legado.app.help.coroutine.CompositeCoroutine import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.cache.CacheDownloadCandidate @@ -12,6 +14,8 @@ import io.legado.app.model.cache.CacheDownloadQueue import io.legado.app.model.cache.CacheDownloadRepository import io.legado.app.model.cache.CacheDownloadRequest import io.legado.app.model.cache.CacheDownloadSource +import io.legado.app.model.cache.CacheChapterProgress +import io.legado.app.model.cache.CacheChapterProgressPhase import io.legado.app.model.cache.CacheDownloadStateStore import io.legado.app.model.cache.ChapterSelection import kotlinx.coroutines.CancellationException @@ -423,6 +427,7 @@ class CacheBookModel( } if (repository.hasContent(book, chapter)) { + reportImageDownloadProgress(chapter, completed = 0) val task = repository.saveCachedImagesTask( scope = scope, context = context, @@ -430,8 +435,11 @@ class CacheBookModel( book = book, chapter = chapter, start = CoroutineStart.LAZY, + onProgress = { completed, total -> + reportImageDownloadProgress(chapter, completed, total) + }, ) - if (!attachTaskIfActive(task, chapter, chapterIndex)) { + if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context)) { task.cancel() return } @@ -439,6 +447,7 @@ class CacheBookModel( return } + reportContentDownloadProgress(chapterIndex) val task = repository.cacheContentTask( scope = scope, bookSource = bookSource, @@ -448,7 +457,7 @@ class CacheBookModel( start = CoroutineStart.LAZY, executeContext = context, ) - if (!attachTaskIfActive(task, chapter, chapterIndex)) { + if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context, chainImagesAfterContent = true)) { task.cancel() return } @@ -494,6 +503,9 @@ class CacheBookModel( task: Coroutine, chapter: BookChapter, chapterIndex: Int, + scope: CoroutineScope, + context: CoroutineContext, + chainImagesAfterContent: Boolean = false, ): Boolean { if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) { if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) { @@ -503,7 +515,7 @@ class CacheBookModel( } return false } - attachCallbacks(task, chapter, chapterIndex) + attachCallbacks(task, chapter, chapterIndex, scope, context, chainImagesAfterContent) chapterTasks[chapterIndex] = task tasks.add(task) return true @@ -513,12 +525,17 @@ class CacheBookModel( task: Coroutine, chapter: BookChapter, chapterIndex: Int, + scope: CoroutineScope, + context: CoroutineContext, + chainImagesAfterContent: Boolean = false, + content: String? = null, ) { task.onSuccess(IO) { - onSuccess(chapter) - (it as? String)?.let { content -> - emitPendingReadContent(chapter, content) + if (chainImagesAfterContent && book.isImage && !repository.hasImageContent(book, chapter)) { + startImageCacheTask(scope, context, chapter, chapterIndex, it as String) + return@onSuccess } + completeChapterCache(chapter, content ?: (it as? String)) }.onError(IO) { onPreError(chapter, it) try { @@ -531,11 +548,89 @@ class CacheBookModel( onCancel(chapterIndex) emitPendingReadCanceled(chapter) }.onFinally(IO) { - chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) } + if (chapterTasks[chapterIndex] === task) { + chapterTasks.remove(chapterIndex) + tasks.delete(task) + } onFinally() } } + private fun completeChapterCache(chapter: BookChapter, content: String?) { + onSuccess(chapter) + content?.let { emitPendingReadContent(chapter, it) } + } + + @Synchronized + private fun startImageCacheTask( + scope: CoroutineScope, + context: CoroutineContext, + chapter: BookChapter, + chapterIndex: Int, + content: String, + ) { + if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) { + if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) { + queue.enqueue(ChapterSelection.Single(chapterIndex)) + } else { + onDownloadSet.remove(chapterIndex) + } + host.stateStore.clearChapterProgress(book.bookUrl, chapterIndex) + notifyDownloadSetChanged() + host.onTaskQueuesChanged(book.bookUrl) + return + } + reportImageDownloadProgress(chapter, completed = 0) + val imageTask = repository.saveCachedImagesTask( + scope = scope, + context = context, + bookSource = bookSource, + book = book, + chapter = chapter, + start = CoroutineStart.LAZY, + onProgress = { completed, total -> + reportImageDownloadProgress(chapter, completed, total) + }, + ) + attachCallbacks(imageTask, chapter, chapterIndex, scope, context, content = content) + chapterTasks[chapterIndex] = imageTask + tasks.add(imageTask) + imageTask.start() + } + + private fun reportContentDownloadProgress(chapterIndex: Int) { + host.stateStore.updateChapterProgress( + book.bookUrl, + chapterIndex, + CacheChapterProgress( + phase = CacheChapterProgressPhase.CONTENT, + completed = 0, + total = 1, + ), + ) + } + + private fun reportImageDownloadProgress( + chapter: BookChapter, + completed: Int, + total: Int = imageCountInChapter(chapter), + ) { + host.stateStore.updateChapterProgress( + book.bookUrl, + chapter.index, + CacheChapterProgress( + phase = CacheChapterProgressPhase.IMAGES, + completed = completed, + total = total, + ), + ) + } + + private fun imageCountInChapter(chapter: BookChapter): Int { + val content = BookHelp.getContent(book, chapter) ?: return 0 + return BookHelp.countImagesInContent(chapter, content) + } + suspend fun downloadAwait(chapter: BookChapter): String { synchronized(this) { onDownloadSet.add(chapter.index) @@ -544,6 +639,16 @@ class CacheBookModel( } try { val content = repository.downloadContentAwait(bookSource, book, chapter) + if (book.isImage && !repository.hasImageContent(book, chapter)) { + repository.saveCachedImagesAwait( + bookSource = bookSource, + book = book, + chapter = chapter, + onProgress = { completed, total -> + reportImageDownloadProgress(chapter, completed, total) + }, + ) + } onSuccess(chapter) ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index) @@ -599,6 +704,11 @@ class CacheBookModel( executeContext = IO, semaphore = semaphore ).timeout(DOWNLOAD_TIMEOUT_MS).onSuccess { content -> + if (book.isImage && !repository.hasImageContent(book, chapter)) { + Coroutine.async(scope, IO) { + repository.saveCachedImagesAwait(bookSource, book, chapter) + }.start() + } onSuccess(chapter) ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index) diff --git a/app/src/main/java/io/legado/app/model/ReadManga.kt b/app/src/main/java/io/legado/app/model/ReadManga.kt index 58f6a751f..1531107c5 100644 --- a/app/src/main/java/io/legado/app/model/ReadManga.kt +++ b/app/src/main/java/io/legado/app/model/ReadManga.kt @@ -13,6 +13,7 @@ import io.legado.app.help.AppWebDav import io.legado.app.help.ConcurrentRateLimiter import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor +import io.legado.app.help.book.isImage import io.legado.app.help.book.isLocal import io.legado.app.help.book.isSameNameAuthor import io.legado.app.help.book.readSimulating @@ -468,6 +469,20 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{ }.start() } + private fun cacheChapterImagesIfNeeded( + book: Book, + chapter: BookChapter, + content: String? = null, + ) { + if (!book.isImage) return + val source = bookSource ?: return + Coroutine.async(downloadScope, IO) { + if (BookHelp.hasImageContent(book, chapter)) return@async + val resolvedContent = content ?: BookHelp.getContent(book, chapter) ?: return@async + BookHelp.saveImages(source, book, chapter, resolvedContent) + }.start() + } + private fun preDownload() { if (book?.isLocal == true) return executor.execute { @@ -515,6 +530,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{ val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return if (BookHelp.hasContent(book, chapter)) { downloadedChapters.add(chapter.index) + cacheChapterImagesIfNeeded(book, chapter) } else { delay(1000) if (addLoading(index)) { @@ -534,10 +550,11 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{ val book = book ?: return removeLoading(chapter.index) val bookSource = bookSource if (bookSource != null) { - downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = { + downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = { content -> + cacheChapterImagesIfNeeded(book, chapter, content) downloadedChapters.add(chapter.index) downloadFailChapters.remove(chapter.index) - contentLoadFinish(chapter, it) + contentLoadFinish(chapter, content) }, error = { downloadFailChapters[chapter.index] = (downloadFailChapters[chapter.index] ?: 0) + 1 diff --git a/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt b/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt index fe6e9a26a..b765e4828 100644 --- a/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt @@ -45,4 +45,19 @@ data class CacheBookDownloadState( val failedIndices: Set = emptySet(), val successCount: Int = 0, val failureMessage: String? = null, + val chapterProgress: Map = emptyMap(), ) + +enum class CacheChapterProgressPhase { + CONTENT, + IMAGES, +} + +data class CacheChapterProgress( + val phase: CacheChapterProgressPhase, + val completed: Int, + val total: Int, +) { + val fraction: Float + get() = if (total <= 0) 0f else completed.toFloat() / total +} diff --git a/app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt b/app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt index 99ac23d30..3200d70d7 100644 --- a/app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt @@ -33,11 +33,21 @@ class CacheDownloadRepository { book: Book, chapter: BookChapter, start: CoroutineStart = CoroutineStart.LAZY, + onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null, ): Coroutine { return Coroutine.async(scope, context, start = start, executeContext = context) { - BookHelp.getContent(book, chapter)?.let { - BookHelp.saveImages(bookSource, book, chapter, it, 1) - } + saveCachedImagesAwait(bookSource, book, chapter, onProgress) + } + } + + suspend fun saveCachedImagesAwait( + bookSource: BookSource, + book: Book, + chapter: BookChapter, + onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null, + ) { + BookHelp.getContent(book, chapter)?.let { content -> + BookHelp.saveImages(bookSource, book, chapter, content, 1, onProgress) } } diff --git a/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt b/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt index e8eb4e368..fe22aee70 100644 --- a/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt @@ -40,6 +40,7 @@ class CacheDownloadStateStore { failedIndices = current.failedIndices - chapterIndex, successCount = current.successCount + 1, failureMessage = null, + chapterProgress = current.chapterProgress - chapterIndex, ) } } @@ -50,10 +51,27 @@ class CacheDownloadStateStore { runningIndices = current.runningIndices - chapterIndex, pausedIndices = current.pausedIndices - chapterIndex, failedIndices = current.failedIndices + chapterIndex, + chapterProgress = current.chapterProgress - chapterIndex, ) } } + fun updateChapterProgress( + bookUrl: String, + chapterIndex: Int, + progress: CacheChapterProgress, + ) { + updateBook(bookUrl) { current -> + current.copy(chapterProgress = current.chapterProgress + (chapterIndex to progress)) + } + } + + fun clearChapterProgress(bookUrl: String, chapterIndex: Int) { + updateBook(bookUrl) { current -> + current.copy(chapterProgress = current.chapterProgress - chapterIndex) + } + } + fun markBookFailed(bookUrl: String, message: String) { updateBook(bookUrl) { current -> current.copy( @@ -90,6 +108,7 @@ class CacheDownloadStateStore { waitingCount = 0, runningIndices = emptySet(), successCount = 0, + chapterProgress = emptyMap(), ) } .filterValues { bookState -> diff --git a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt index 815c0a5ea..791c3becf 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt @@ -410,6 +410,14 @@ private fun BookCacheChapterRow( LegadoTheme.colorScheme.onSurfaceVariant } ) + if (item.isDownloading) { + AppLinearProgressIndicator( + progress = item.downloadProgress, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } } if (item.isWaiting || item.isDownloading) { SmallTonalButton( @@ -433,6 +441,9 @@ private fun BookCacheChapterRow( } private fun chapterStatusText(item: BookCacheChapterItem): String { + if (item.isDownloading && !item.progressLabel.isNullOrBlank()) { + return item.progressLabel + } return when { item.isDownloading -> "下载中" item.isWaiting -> "等待下载" diff --git a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt index d6ca377a8..df7760838 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt @@ -12,9 +12,14 @@ import io.legado.app.domain.usecase.CacheBookChaptersUseCase import io.legado.app.domain.usecase.ClearBookCacheUseCase import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isAudio +import io.legado.app.help.book.isImage import io.legado.app.help.book.isLocal import io.legado.app.help.book.isNotShelf import io.legado.app.model.CacheBook +import io.legado.app.model.CacheBookModel +import io.legado.app.model.cache.CacheBookDownloadState +import io.legado.app.model.cache.CacheChapterProgress +import io.legado.app.model.cache.CacheChapterProgressPhase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext @@ -71,6 +76,8 @@ data class BookCacheChapterItem( val isDownloading: Boolean, val isPaused: Boolean, val isError: Boolean, + val downloadProgress: Float? = null, + val progressLabel: String? = null, ) sealed interface BookCacheManageIntent { @@ -163,7 +170,13 @@ class BookCacheManageViewModel( } viewModelScope.launch { CacheBook.downloadStateFlow.collect { state -> - state.books.keys.forEach { scheduleDownloadStatusRefresh(it) } + state.books.forEach { (bookUrl, bookState) -> + if (bookState.runningIndices.isNotEmpty() || bookState.chapterProgress.isNotEmpty()) { + scheduleProgressUpdate(bookUrl, debounceMillis = 100) + } else { + scheduleDownloadStatusRefresh(bookUrl) + } + } pendingDownloadSummaryRefresh = true } } @@ -257,6 +270,20 @@ class BookCacheManageViewModel( } } + private fun scheduleProgressUpdate(bookUrl: String, debounceMillis: Long = 100) { + if (bookUrl.isBlank()) return + bookReloadJobs.remove(bookUrl)?.cancel() + bookReloadJobs[bookUrl] = viewModelScope.launch { + if (debounceMillis > 0) { + delay(debounceMillis) + } + updateDownloadProgressOnly(bookUrl) + if (bookReloadJobs[bookUrl] == currentCoroutineContext()[Job]) { + bookReloadJobs.remove(bookUrl) + } + } + } + private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) { if (bookUrl.isBlank()) return bookReloadJobs.remove(bookUrl)?.cancel() @@ -334,7 +361,11 @@ class BookCacheManageViewModel( val errorIndices = errorIndices(book.bookUrl) val totalCount = bookChapterDao.getChapterCount(book.bookUrl) val cachedFileCount = cacheFiles.count { it.endsWith(".nb") } - val cachedCount = min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount) + val cachedCount = if (book.isImage) { + min(BookHelp.countImageCachedChapters(book), totalCount) + } else { + min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount) + } if (totalCount == 0 && cacheFiles.isEmpty() && waitingCount == 0 && downloadingCount == 0 && pausedCount == 0 && !book.isNotShelf) { return null } @@ -362,22 +393,71 @@ class BookCacheManageViewModel( val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl) val cacheFiles = BookHelp.getChapterFiles(book) val model = CacheBook.cacheBookMap[bookUrl] + val bookState = CacheBook.downloadStateFlow.value.books[bookUrl] val errorIndices = errorIndices(bookUrl) return chapters.map { chapter -> val isPaused = model?.isPaused(chapter.index) == true + val isWaiting = !isPaused && model?.isWaiting(chapter.index) == true + val isDownloading = !isPaused && model?.isDownloading(chapter.index) == true + val bookChapter = BookChapter( + bookUrl = book.bookUrl, + url = chapter.url, + title = chapter.title, + isVolume = chapter.isVolume, + index = chapter.index, + ) + val isCached = if (book.isImage) { + when { + chapter.isVolume -> true + isDownloading -> false + else -> BookHelp.hasImageFilesCached(book, bookChapter) + } + } else { + cacheFiles.contains(chapter.getFileName()) || chapter.isVolume + } + val chapterProgress = bookState?.chapterProgress?.get(chapter.index) + val (downloadProgress, progressLabel) = chapterProgressUi( + isDownloading = isDownloading, + progress = chapterProgress, + ) BookCacheChapterItem( chapterUrl = chapter.url, title = chapter.title, index = chapter.index, - isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume, - isWaiting = !isPaused && model?.isWaiting(chapter.index) == true, - isDownloading = !isPaused && model?.isDownloading(chapter.index) == true, + isCached = isCached, + isWaiting = isWaiting, + isDownloading = isDownloading, isPaused = isPaused, isError = errorIndices.contains(chapter.index), + downloadProgress = downloadProgress, + progressLabel = progressLabel, ) } } + private fun chapterProgressUi( + isDownloading: Boolean, + progress: CacheChapterProgress?, + ): Pair { + if (!isDownloading || progress == null) return null to null + return when (progress.phase) { + CacheChapterProgressPhase.CONTENT -> { + if (progress.completed >= progress.total && progress.total > 0) { + progress.fraction to "正文已下载" + } else { + null to "下载正文" + } + } + CacheChapterProgressPhase.IMAGES -> { + if (progress.total <= 0) { + null to "下载图片" + } else { + progress.fraction to "图片 ${progress.completed}/${progress.total}" + } + } + } + } + private fun errorIndices(bookUrl: String): Set { return CacheBook.errorIndices(bookUrl) } @@ -502,7 +582,7 @@ class BookCacheManageViewModel( for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) { if ( chapter.isVolume || - cacheFiles.contains(chapter.getFileName()) || + isChapterFullyCached(book, chapter, cacheFiles) || model?.isPaused(chapter.index) == true || model?.isWaiting(chapter.index) == true || model?.isDownloading(chapter.index) == true @@ -520,6 +600,93 @@ class BookCacheManageViewModel( } } + private fun isChapterFullyCached( + book: Book, + chapter: BookChapterCacheInfo, + cacheFiles: Set, + ): Boolean { + if (chapter.isVolume) return true + val bookChapter = BookChapter( + bookUrl = book.bookUrl, + url = chapter.url, + title = chapter.title, + isVolume = chapter.isVolume, + index = chapter.index, + ) + return if (book.isImage) { + BookHelp.hasImageFilesCached(book, bookChapter) + } else { + cacheFiles.contains(chapter.getFileName()) + } + } + + private fun updateDownloadProgressOnly(bookUrl: String) { + val bookState = CacheBook.downloadStateFlow.value.books[bookUrl] ?: return + val model = CacheBook.cacheBookMap[bookUrl] + _uiState.update { state -> + fun updateBookItem(item: BookCacheBookItem): BookCacheBookItem { + if (item.bookUrl != bookUrl) return item + return applyDownloadStateToBookItem(item, bookState, model) + } + val updatedChapters = state.chaptersByBookUrl[bookUrl]?.map { item -> + val isPaused = model?.isPaused(item.index) == true + val isWaiting = !isPaused && model?.isWaiting(item.index) == true + val isDownloading = !isPaused && model?.isDownloading(item.index) == true + val chapterProgress = bookState.chapterProgress[item.index] + val (downloadProgress, progressLabel) = chapterProgressUi( + isDownloading = isDownloading, + progress = chapterProgress, + ) + item.copy( + isWaiting = isWaiting, + isDownloading = isDownloading, + isPaused = isPaused, + downloadProgress = downloadProgress, + progressLabel = progressLabel, + ) + } + val shelfBooks = state.shelfBooks.map(::updateBookItem) + val notShelfBooks = state.notShelfBooks.map(::updateBookItem) + state.copy( + shelfBooks = shelfBooks, + notShelfBooks = notShelfBooks, + chaptersByBookUrl = if (updatedChapters != null) { + state.chaptersByBookUrl + (bookUrl to updatedChapters) + } else { + state.chaptersByBookUrl + }, + downloadSummary = buildDownloadSummary(shelfBooks + notShelfBooks), + hasPausedDownloads = CacheBook.hasPausedDownloads, + version = state.version + 1, + ) + } + } + + private fun applyDownloadStateToBookItem( + item: BookCacheBookItem, + bookState: CacheBookDownloadState, + model: CacheBookModel?, + ): BookCacheBookItem { + val rawWaitingCount = bookState.waitingCount + + CacheBook.pendingAdmissionFlow.value[item.bookUrl].orZero() + val rawDownloadingCount = bookState.runningIndices.size + val isBookPaused = model?.isPaused() == true || + (CacheBook.hasPausedDownloads && CacheBook.pendingAdmissionFlow.value.containsKey(item.bookUrl)) + val pausedCount = if (isBookPaused) { + rawWaitingCount + rawDownloadingCount + bookState.pausedIndices.size + } else { + bookState.pausedIndices.size + } + val waitingCount = if (isBookPaused) 0 else rawWaitingCount + val downloadingCount = if (isBookPaused) 0 else rawDownloadingCount + return item.copy( + waitingCount = waitingCount, + downloadingCount = downloadingCount, + pausedCount = pausedCount, + errorCount = errorIndices(item.bookUrl).size, + ) + } + private fun deleteBookCache(bookUrl: String) { execute { CacheBook.removeAwait(context, bookUrl) diff --git a/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt b/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt index 0c48623d0..382fc1cb8 100644 --- a/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt +++ b/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt @@ -83,4 +83,25 @@ class CacheDownloadStateStoreTest { assertEquals(0, store.state.totalWaiting) assertEquals(0, store.state.totalRunning) } + + @Test + fun chapterProgressUpdatesAndClearsOnSuccess() { + val store = CacheDownloadStateStore() + + store.updateBookQueue("a", waitingCount = 0, runningIndices = setOf(3)) + store.updateChapterProgress( + "a", + 3, + CacheChapterProgress(CacheChapterProgressPhase.IMAGES, completed = 2, total = 10), + ) + + val progress = store.state.books.getValue("a").chapterProgress.getValue(3) + assertEquals(CacheChapterProgressPhase.IMAGES, progress.phase) + assertEquals(2, progress.completed) + assertEquals(10, progress.total) + + store.markSuccess("a", 3) + + assertFalse(store.state.books.getValue("a").chapterProgress.containsKey(3)) + } }