feat(cache): 添加漫画图片缓存功能及图片缓存进度跟踪功能 (#1610)

* feat(cache): 添加图片缓存进度跟踪功能

- 在 BookHelp 中新增 countImagesInContent 函数用于统计章节中的图片数量
- 修改 saveImages 函数支持进度回调,实现图片下载进度追踪
- 新增 countImageCachedChapters 函数用于统计已缓存的图片章节
- 在 OkHttpStreamFetcher 中实现本地图片加载逻辑,支持从缓存读取图片
- 添加 CacheChapterProgress 相关数据类用于跟踪章节下载进度
- 在 CacheDownloadStateStore 中实现章节进度更新和清除功能
- 修改 CacheBookModel 支持内容和图片分阶段下载,并报告进度
- 更新 ReadManga 中的图片缓存逻辑,在内容下载后自动缓存图片
- 在 BookCacheManageScreen 和 ViewModel 中添加进度指示器显示下载状态
- 优化缓存章节计数逻辑,区分图片书籍和其他类型书籍的缓存判断

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cache): 修复 PR #1610 Code Review 指出的问题

- 图片缓存完成后保留正文内容,避免阅读器无限加载
- 将 ReadManga 中的磁盘 I/O 移至 IO 线程
- 使用 Mutex 保证图片下载进度回调顺序递增

* fix(cache): 修复图片缓存任务卡住与 UI 刷新性能问题

- startImageCacheTask 提前返回时清理 onDownloadSet 并同步队列状态
- 新增 hasImageFilesCached 轻量检查,进度更新时避免全量文件扫描

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
AugusChen
2026-07-01 01:02:07 +08:00
committed by GitHub
co-authored by Cursor
parent 3b65bd66b2
commit 44b1b582c0
10 changed files with 480 additions and 23 deletions
@@ -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)) {
@@ -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) {
@@ -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<T>,
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<T>,
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)
@@ -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
@@ -45,4 +45,19 @@ data class CacheBookDownloadState(
val failedIndices: Set<Int> = emptySet(),
val successCount: Int = 0,
val failureMessage: String? = null,
val chapterProgress: Map<Int, CacheChapterProgress> = 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
}
@@ -33,11 +33,21 @@ class CacheDownloadRepository {
book: Book,
chapter: BookChapter,
start: CoroutineStart = CoroutineStart.LAZY,
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
): Coroutine<Unit> {
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)
}
}
@@ -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 ->
@@ -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 -> "等待下载"
@@ -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<Float?, String?> {
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<Int> {
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<String>,
): 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)
@@ -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))
}
}