diff --git a/app/src/main/java/io/legado/app/data/repository/CacheBookDownloadRepository.kt b/app/src/main/java/io/legado/app/data/repository/CacheBookDownloadRepository.kt index ca83ce2c0..388d07671 100644 --- a/app/src/main/java/io/legado/app/data/repository/CacheBookDownloadRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/CacheBookDownloadRepository.kt @@ -2,15 +2,36 @@ package io.legado.app.data.repository import io.legado.app.data.dao.BookDao import io.legado.app.domain.gateway.BookCacheDownloadGateway +import io.legado.app.help.book.isLocal import io.legado.app.model.CacheBook +import io.legado.app.model.cache.CacheDownloadRequest +import io.legado.app.model.cache.ChapterSelection import splitties.init.appCtx class CacheBookDownloadRepository( private val bookDao: BookDao ) : BookCacheDownloadGateway { + override suspend fun start(request: CacheDownloadRequest) { + val book = bookDao.getBook(request.bookUrl) ?: return + CacheBook.start(appCtx, request, isLocal = book.isLocal) + } + override suspend fun start(bookUrl: String, chapterIndices: List) { - val book = bookDao.getBook(bookUrl) ?: return - CacheBook.start(appCtx, book, chapterIndices) + start( + CacheDownloadRequest( + bookUrl = bookUrl, + selection = ChapterSelection.Indices(chapterIndices.toSet()), + ) + ) + } + + override suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int) { + start( + CacheDownloadRequest( + bookUrl = bookUrl, + selection = ChapterSelection.Range(startIndex, endIndex), + ) + ) } } diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 56ddc2c48..86ecdf744 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -30,6 +30,7 @@ import io.legado.app.data.repository.WebDavReadingProgressRepository import io.legado.app.domain.gateway.BookCacheCleanupGateway import io.legado.app.domain.gateway.AppStartupGateway import io.legado.app.domain.gateway.BookCacheDownloadGateway +import io.legado.app.domain.gateway.BookSearchGateway import io.legado.app.domain.gateway.BookSourceCallbackGateway import io.legado.app.domain.gateway.DatabaseMaintenanceGateway import io.legado.app.domain.gateway.LocalBookGateway @@ -46,6 +47,7 @@ import io.legado.app.domain.usecase.GetReadingProgressUseCase import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase +import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.ShrinkDatabaseUseCase import io.legado.app.domain.usecase.UpdateBooksGroupUseCase import io.legado.app.domain.usecase.UploadReadingProgressUseCase @@ -140,7 +142,12 @@ val appModule = module { single { WebDavReadingProgressRepository() } single { BookDomainRepositoryImpl(get(), get()) } single { ExploreRepositoryImpl(get()) } - single { SearchRepositoryImpl(get()) } + single { + SearchRepositoryImpl(get()) + } + single { get() } + single { get() } + singleOf(::SearchBooksUseCase) single { ImageLoader.Builder(get()) diff --git a/app/src/main/java/io/legado/app/domain/gateway/BookCacheDownloadGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/BookCacheDownloadGateway.kt index 86d813917..fc34a56d2 100644 --- a/app/src/main/java/io/legado/app/domain/gateway/BookCacheDownloadGateway.kt +++ b/app/src/main/java/io/legado/app/domain/gateway/BookCacheDownloadGateway.kt @@ -1,5 +1,9 @@ package io.legado.app.domain.gateway +import io.legado.app.model.cache.CacheDownloadRequest + interface BookCacheDownloadGateway { + suspend fun start(request: CacheDownloadRequest) suspend fun start(bookUrl: String, chapterIndices: List) + suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int) } diff --git a/app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt index 7bdbd5930..a744fe860 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt @@ -3,6 +3,9 @@ package io.legado.app.domain.usecase import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.model.CacheableBook import io.legado.app.domain.repository.BookDomainRepository +import io.legado.app.model.cache.CacheDownloadRequest +import io.legado.app.model.cache.CacheDownloadSource +import io.legado.app.model.cache.ChapterSelection class BatchCacheDownloadUseCase( private val bookRepository: BookDomainRepository, @@ -31,13 +34,16 @@ class BatchCacheDownloadUseCase( ): Boolean { if (book.isLocal) return false if (skipAudioBooks && book.isAudio) return false - val indices = if (downloadAllChapters) { - (0..book.lastChapterIndex).toList() - } else { - (book.durChapterIndex..book.lastChapterIndex).toList() - } - if (indices.isEmpty()) return false - bookCacheDownloadGateway.start(book.bookUrl, indices) + val startIndex = if (downloadAllChapters) 0 else book.durChapterIndex + val endIndex = book.lastChapterIndex + if (endIndex < startIndex) return false + bookCacheDownloadGateway.start( + CacheDownloadRequest( + bookUrl = book.bookUrl, + selection = ChapterSelection.Range(startIndex, endIndex), + source = CacheDownloadSource.Batch, + ) + ) return true } } diff --git a/app/src/main/java/io/legado/app/domain/usecase/CacheBookChaptersUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/CacheBookChaptersUseCase.kt index 672accb4b..3fdb32faf 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/CacheBookChaptersUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/CacheBookChaptersUseCase.kt @@ -1,6 +1,9 @@ package io.legado.app.domain.usecase import io.legado.app.domain.gateway.BookCacheDownloadGateway +import io.legado.app.model.cache.CacheDownloadRequest +import io.legado.app.model.cache.CacheDownloadSource +import io.legado.app.model.cache.ChapterSelection class CacheBookChaptersUseCase( private val bookCacheDownloadGateway: BookCacheDownloadGateway @@ -9,7 +12,25 @@ class CacheBookChaptersUseCase( suspend fun execute(bookUrl: String, chapterIndices: Iterable): Int { val indices = chapterIndices.distinct() if (indices.isEmpty()) return 0 - bookCacheDownloadGateway.start(bookUrl, indices) + bookCacheDownloadGateway.start( + CacheDownloadRequest( + bookUrl = bookUrl, + selection = ChapterSelection.Indices(indices.toSet()), + source = CacheDownloadSource.Manual, + ) + ) return indices.size } + + suspend fun executeRange(bookUrl: String, startIndex: Int, endIndex: Int): Int { + if (endIndex < startIndex) return 0 + bookCacheDownloadGateway.start( + CacheDownloadRequest( + bookUrl = bookUrl, + selection = ChapterSelection.Range(startIndex, endIndex), + source = CacheDownloadSource.Manual, + ) + ) + return endIndex - startIndex + 1 + } } 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 8f8751cd2..38501f264 100644 --- a/app/src/main/java/io/legado/app/model/CacheBook.kt +++ b/app/src/main/java/io/legado/app/model/CacheBook.kt @@ -2,22 +2,26 @@ package io.legado.app.model import android.content.Context import io.legado.app.constant.AppLog -import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.data.appDb 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.exception.ConcurrentException -import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isLocal import io.legado.app.help.coroutine.CompositeCoroutine import io.legado.app.help.coroutine.Coroutine -import io.legado.app.model.webBook.WebBook +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.CacheDownloadStateStore +import io.legado.app.model.cache.ChapterSelection +import io.legado.app.model.cache.ReadingCacheEvent +import io.legado.app.model.cache.ReadingCacheEvents import io.legado.app.service.CacheBookService import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.onEachParallel -import io.legado.app.utils.postEvent import io.legado.app.utils.startService import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -75,20 +79,20 @@ object CacheBook { if (!emitted) delay(800) } }.onStart { - postEvent(EventBus.UP_DOWNLOAD_STATE, "") updateSummary() }.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) { coroutineScope { it.download(this, context) } }.onCompletion { - postEvent(EventBus.UP_DOWNLOAD_STATE, "") updateSummary() }.collect() } } private val coordinator = CacheBookCoordinator() + private val stateStore = CacheDownloadStateStore() + val downloadStateFlow = stateStore.stateFlow private val _cacheSuccessFlow = MutableSharedFlow(extraBufferCapacity = 64) val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow() @@ -117,7 +121,7 @@ object CacheBook { get() = coordinator.taskMap fun errorIndices(bookUrl: String): Set { - return errorIndexMap[bookUrl]?.toSet().orEmpty() + return stateStore.bookState(bookUrl)?.failedIndices.orEmpty() } private fun collectQueueStats(): QueueStats { @@ -168,11 +172,54 @@ object CacheBook { } fun start(context: Context, book: Book, selectedIndices: List) { - if (!book.isLocal && selectedIndices.isNotEmpty()) { - context.startService { - action = IntentAction.start - putExtra("bookUrl", book.bookUrl) - putIntegerArrayListExtra("indices", ArrayList(selectedIndices)) + start( + context = context, + request = CacheDownloadRequest( + bookUrl = book.bookUrl, + selection = ChapterSelection.Indices(selectedIndices.toSet()), + ), + isLocal = book.isLocal, + ) + } + + fun start(context: Context, book: Book, startIndex: Int, endIndex: Int) { + start( + context = context, + request = CacheDownloadRequest( + bookUrl = book.bookUrl, + selection = ChapterSelection.Range(startIndex, endIndex), + ), + isLocal = book.isLocal, + ) + } + + fun start(context: Context, request: CacheDownloadRequest, isLocal: Boolean = false) { + if (isLocal) return + when (val selection = request.selection) { + is ChapterSelection.Range -> { + if (selection.end < selection.start) return + } + is ChapterSelection.Indices -> { + if (selection.values.isEmpty()) return + } + is ChapterSelection.Single -> Unit + } + context.startService { + action = IntentAction.start + putExtra("bookUrl", request.bookUrl) + putExtra("source", request.source.name) + when (val selection = request.selection) { + is ChapterSelection.Range -> { + putExtra("start", selection.start) + putExtra("end", selection.end) + } + is ChapterSelection.Indices -> { + putIntegerArrayListExtra("indices", ArrayList(selection.values)) + } + is ChapterSelection.Single -> { + putExtra("start", selection.index) + putExtra("end", selection.index) + } } } } @@ -187,9 +234,9 @@ object CacheBook { fun removeBook(bookUrl: String): Boolean { val model = cacheBookMap.remove(bookUrl) ?: return false model.stop() + stateStore.removeBook(bookUrl) updateSummary() _queueChangedFlow.tryEmit(bookUrl) - postEvent(EventBus.UP_DOWNLOAD, bookUrl) return true } @@ -211,6 +258,7 @@ object CacheBook { successDownloadSet.clear() errorDownloadMap.clear() errorIndexMap.clear() + stateStore.clear() updateSummary() } @@ -241,15 +289,26 @@ object CacheBook { get() = lastQueueStats.waitingCount > 0 || lastQueueStats.downloadingCount > 0 private fun onTaskQueuesChanged(bookUrl: String) { + cacheBookMap[bookUrl]?.let { model -> + stateStore.updateBookQueue( + bookUrl = bookUrl, + waitingCount = model.queueCounts().first, + runningIndices = model.downloadingIndices(), + ) + _downloadingIndicesFlow.tryEmit(bookUrl to model.downloadingIndices()) + _downloadErrorFlow.tryEmit(bookUrl to errorIndices(bookUrl)) + } updateSummary() _queueChangedFlow.tryEmit(bookUrl) - postEvent(EventBus.UP_DOWNLOAD, bookUrl) } - private fun onTaskRemoved(bookUrl: String) { + private fun onTaskRemoved(bookUrl: String, clearState: Boolean = false) { cacheBookMap.remove(bookUrl) + if (clearState) { + stateStore.removeBook(bookUrl) + } updateSummary() - postEvent(EventBus.UP_DOWNLOAD, bookUrl) + _queueChangedFlow.tryEmit(bookUrl) } class CacheBookModel( @@ -257,40 +316,50 @@ object CacheBook { @Volatile var book: Book ) { - private val waitDownloadSet = linkedSetOf() + private val queue = CacheDownloadQueue() private val onDownloadSet = linkedSetOf() private val pausedDownloadSet = hashSetOf() private val chapterTasks = hashMapOf>() private val tasks = CompositeCoroutine() + private val repository = CacheDownloadRepository() private var isStopped = false private var waitingRetry = false private var isLoading = false - init { - postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) - } - private fun notifyDownloadSetChanged() { _downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet()) + if (cacheBookMap[book.bookUrl] === this) { + stateStore.updateBookQueue( + bookUrl = book.bookUrl, + waitingCount = queue.waitingCount(), + runningIndices = onDownloadSet.toSet(), + ) + } } private fun notifyErrorChanged() { - val errors = errorIndexMap[book.bookUrl]?.toSet() ?: emptySet() + val errors = errorIndices(book.bookUrl) _downloadErrorFlow.tryEmit(book.bookUrl to errors) } @Synchronized - fun queueCounts(): Pair = waitDownloadSet.size to onDownloadSet.size + fun queueCounts(): Pair = queue.waitingCount() to onDownloadSet.size @Synchronized - fun waitingIndices(): Set = waitDownloadSet.toSet() + fun isWaiting(index: Int): Boolean = queue.isWaiting(index) + + @Synchronized + fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index) + + @Synchronized + fun waitingIndices(): Set = queue.waitingIndices() @Synchronized fun downloadingIndices(): Set = onDownloadSet.toSet() @Synchronized fun isRun(): Boolean { - return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading + return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading } @Synchronized @@ -309,7 +378,7 @@ object CacheBook { @Synchronized fun stop() { - waitDownloadSet.clear() + queue.clear() pausedDownloadSet.clear() chapterTasks.clear() tasks.clear() @@ -322,18 +391,39 @@ object CacheBook { @Synchronized fun addDownload(start: Int, end: Int) { - addDownloads(start..end) + addRequest( + CacheDownloadRequest( + book.bookUrl, + ChapterSelection.Range(start, end), + CacheDownloadSource.ReadPreload, + ) + ) } @Synchronized fun addDownloads(indices: Iterable) { + val values = indices.toSet() + if (values.isEmpty()) return + addRequest( + CacheDownloadRequest( + book.bookUrl, + ChapterSelection.Indices(values), + CacheDownloadSource.Manual, + ) + ) + } + + @Synchronized + fun addRequest(request: CacheDownloadRequest) { isStopped = false - for (i in indices) { - pausedDownloadSet.remove(i) - if (!onDownloadSet.contains(i)) { - waitDownloadSet.add(i) + when (val selection = request.selection) { + is ChapterSelection.Range -> { + pausedDownloadSet.removeAll { it in selection.start..selection.end } } + is ChapterSelection.Indices -> selection.values.forEach { pausedDownloadSet.remove(it) } + is ChapterSelection.Single -> pausedDownloadSet.remove(selection.index) } + queue.enqueue(request) cacheBookMap[book.bookUrl] = this isLoading = false notifyDownloadSetChanged() @@ -351,6 +441,7 @@ object CacheBook { successDownloadSet.add(chapter.primaryStr()) errorDownloadMap.remove(chapter.primaryStr()) errorIndexMap[book.bookUrl]?.remove(chapter.index) + stateStore.markSuccess(book.bookUrl, chapter.index) notifyDownloadSetChanged() notifyErrorChanged() _cacheSuccessFlow.tryEmit(chapter) @@ -363,6 +454,7 @@ object CacheBook { errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc } errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() } .add(chapter.index) + stateStore.markFailed(book.bookUrl, chapter.index) } onDownloadSet.remove(chapter.index) chapterTasks.remove(chapter.index) @@ -372,7 +464,7 @@ object CacheBook { private fun onPostError(chapter: BookChapter, error: Throwable) { val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0 if (retryCount < 3 && !isStopped) { - waitDownloadSet.add(chapter.index) + queue.enqueue(ChapterSelection.Single(chapter.index)) } else { AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error) } @@ -391,14 +483,16 @@ object CacheBook { private fun onCancel(index: Int) { onDownloadSet.remove(index) chapterTasks.remove(index) - if (!isStopped && !pausedDownloadSet.remove(index)) waitDownloadSet.add(index) + if (!isStopped && !pausedDownloadSet.remove(index)) { + queue.enqueue(ChapterSelection.Single(index)) + } notifyDownloadSetChanged() } @Synchronized private fun onFinally() { val bookUrl = book.bookUrl - if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { + if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) { CacheBook.onTaskRemoved(bookUrl) } else { CacheBook.onTaskQueuesChanged(bookUrl) @@ -408,7 +502,7 @@ object CacheBook { @Synchronized fun removeDownload(index: Int): Boolean { - val removedWaiting = waitDownloadSet.remove(index) + val removedWaiting = queue.removeChapter(index) val task = chapterTasks.remove(index) val removedRunning = onDownloadSet.contains(index) || task != null if (removedRunning) { @@ -420,8 +514,8 @@ object CacheBook { } if (!removedWaiting && !removedRunning) return false notifyDownloadSetChanged() - if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { - CacheBook.onTaskRemoved(book.bookUrl) + if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) { + CacheBook.onTaskRemoved(book.bookUrl, clearState = true) } else { CacheBook.onTaskQueuesChanged(book.bookUrl) } @@ -433,42 +527,39 @@ object CacheBook { */ @Synchronized fun download(scope: CoroutineScope, context: CoroutineContext) { - val chapterIndex = waitDownloadSet.firstOrNull() - if (chapterIndex == null) { + val candidate = queue.next(book.bookUrl, onDownloadSet) + if (candidate == null) { if (!isLoading && onDownloadSet.isEmpty()) { CacheBook.onTaskRemoved(book.bookUrl) } return } + val chapterIndex = candidate.chapterIndex if (onDownloadSet.contains(chapterIndex)) { - waitDownloadSet.remove(chapterIndex) return } - val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run { - waitDownloadSet.remove(chapterIndex) + val chapter = repository.getChapter(book.bookUrl, chapterIndex) ?: run { return } if (chapter.isVolume) { - postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter)) _cacheSuccessFlow.tryEmit(chapter) - waitDownloadSet.remove(chapterIndex) return } - if (BookHelp.hasImageContent(book, chapter)) { - waitDownloadSet.remove(chapterIndex) + if (repository.hasImageContent(book, chapter)) { return } - waitDownloadSet.remove(chapterIndex) onDownloadSet.add(chapterIndex) notifyDownloadSetChanged() - if (BookHelp.hasContent(book, chapter)) { - val task = Coroutine.async(scope, context, executeContext = context) { - BookHelp.getContent(book, chapter)?.let { - BookHelp.saveImages(bookSource, book, chapter, it, 1) - } - }.onSuccess { + if (repository.hasContent(book, chapter)) { + val task = repository.saveCachedImagesTask( + scope = scope, + context = context, + bookSource = bookSource, + book = book, + chapter = chapter, + ).onSuccess { onSuccess(chapter) }.onError { onPreError(chapter, it) @@ -485,14 +576,14 @@ object CacheBook { return } - val task = WebBook.getContent( - scope, - bookSource, - book, - chapter, + val task = repository.downloadContentTask( + scope = scope, + bookSource = bookSource, + book = book, + chapter = chapter, context = context, start = CoroutineStart.LAZY, - executeContext = context + executeContext = context, ).onSuccess { content -> onSuccess(chapter) downloadFinish(chapter, content) @@ -515,11 +606,11 @@ object CacheBook { suspend fun downloadAwait(chapter: BookChapter): String { synchronized(this) { onDownloadSet.add(chapter.index) - waitDownloadSet.remove(chapter.index) + queue.removeChapter(chapter.index) notifyDownloadSetChanged() } try { - val content = WebBook.getContentAwait(bookSource, book, chapter) + val content = repository.downloadContentAwait(bookSource, book, chapter) onSuccess(chapter) ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index) @@ -544,15 +635,16 @@ object CacheBook { ) { if (onDownloadSet.contains(chapter.index)) return onDownloadSet.add(chapter.index) - waitDownloadSet.remove(chapter.index) + queue.removeChapter(chapter.index) notifyDownloadSetChanged() - WebBook.getContent( - scope, - bookSource, - book, - chapter, + repository.downloadContentTask( + scope = scope, + bookSource = bookSource, + book = book, + chapter = chapter, start = CoroutineStart.LAZY, + context = IO, executeContext = IO, semaphore = semaphore ).onSuccess { content -> @@ -579,15 +671,15 @@ object CacheBook { resetPageOffset: Boolean = false, canceled: Boolean = false ) { - if (ReadBook.book?.bookUrl == book.bookUrl) { - ReadBook.contentLoadFinish( + ReadingCacheEvents.emit( + ReadingCacheEvent.ContentReady( book = book, chapter = chapter, content = content, resetPageOffset = resetPageOffset, - canceled = canceled + canceled = canceled, ) - } + ) } } } 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 348812dcb..520f0a6c7 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/app/src/main/java/io/legado/app/model/ReadBook.kt @@ -26,6 +26,8 @@ import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.globalExecutor import io.legado.app.model.localBook.TextFile +import io.legado.app.model.cache.ReadingCacheEvent +import io.legado.app.model.cache.ReadingCacheEvents import io.legado.app.model.webBook.WebBook import io.legado.app.service.BaseReadAloudService import io.legado.app.service.CacheBookService @@ -88,6 +90,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { private val nextChapterLoadingLock = Mutex() var readStartTime: Long = System.currentTimeMillis() + init { + launch { + ReadingCacheEvents.events.collect { event -> + when (event) { + is ReadingCacheEvent.ContentReady -> { + if (book?.bookUrl == event.book.bookUrl) { + contentLoadFinish( + book = event.book, + chapter = event.chapter, + content = event.content, + resetPageOffset = event.resetPageOffset, + canceled = event.canceled, + ) + } + } + } + } + } + } + /* 跳转进度前进度记录 */ var lastBookProgress: BookProgress? = null 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 new file mode 100644 index 000000000..77754e55c --- /dev/null +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt @@ -0,0 +1,46 @@ +package io.legado.app.model.cache + +data class CacheDownloadRequest( + val bookUrl: String, + val selection: ChapterSelection, + val source: CacheDownloadSource = CacheDownloadSource.Manual, +) + +sealed interface ChapterSelection { + data class Range(val start: Int, val end: Int) : ChapterSelection + data class Indices(val values: Set) : ChapterSelection + data class Single(val index: Int) : ChapterSelection +} + +enum class CacheDownloadSource { + Manual, + Batch, + ReadPreload, +} + +data class CacheDownloadCandidate( + val bookUrl: String, + val chapterIndex: Int, +) + +data class CacheDownloadQueueSnapshot( + val waitingCount: Int, +) + +data class CacheDownloadState( + val isRunning: Boolean = false, + val totalWaiting: Int = 0, + val totalRunning: Int = 0, + val totalSuccess: Int = 0, + val totalFailure: Int = 0, + val books: Map = emptyMap(), +) + +data class CacheBookDownloadState( + val bookUrl: String, + val waitingCount: Int = 0, + val runningIndices: Set = emptySet(), + val failedIndices: Set = emptySet(), + val successIndices: Set = emptySet(), + val successCount: Int = 0, +) diff --git a/app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt b/app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt new file mode 100644 index 000000000..b1779bd27 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt @@ -0,0 +1,118 @@ +package io.legado.app.model.cache + +class CacheDownloadQueue { + + private data class RangeCursor( + val start: Int, + val end: Int, + var next: Int = start, + ) { + fun contains(index: Int): Boolean = index in next..end + fun remainingCount( + emittedIndices: Set, + removedIndices: Set, + ): Int { + if (next > end) return 0 + val rawCount = end - next + 1 + val emittedCount = emittedIndices.count { it in next..end } + val removedCount = removedIndices.count { it in next..end && it !in emittedIndices } + val excludedCount = emittedCount + removedCount + return rawCount - excludedCount + } + } + + private val ranges = ArrayDeque() + private val indices = linkedSetOf() + private val emittedIndices = hashSetOf() + private val removedIndices = hashSetOf() + + fun enqueue(request: CacheDownloadRequest) { + enqueue(request.selection) + } + + fun enqueue(selection: ChapterSelection) { + when (selection) { + is ChapterSelection.Range -> addRange(selection.start, selection.end) + is ChapterSelection.Indices -> addIndices(selection.values) + is ChapterSelection.Single -> addIndex(selection.index) + } + } + + fun next(bookUrl: String, runningIndices: Set): CacheDownloadCandidate? { + while (indices.isNotEmpty()) { + val index = indices.first() + indices.remove(index) + if (index in runningIndices || index in removedIndices) continue + emittedIndices.add(index) + return CacheDownloadCandidate(bookUrl, index) + } + + while (ranges.isNotEmpty()) { + val cursor = ranges.first() + while (cursor.next <= cursor.end) { + val index = cursor.next++ + if (index in removedIndices || index in runningIndices) continue + if (emittedIndices.add(index)) { + return CacheDownloadCandidate(bookUrl, index) + } + } + ranges.removeFirst() + } + return null + } + + fun removeChapter(index: Int): Boolean { + val removed = indices.remove(index) || isWaiting(index) + removedIndices.add(index) + return removed + } + + fun clear() { + ranges.clear() + indices.clear() + emittedIndices.clear() + removedIndices.clear() + } + + fun snapshot(): CacheDownloadQueueSnapshot { + return CacheDownloadQueueSnapshot(waitingCount = waitingCount()) + } + + fun waitingCount(): Int { + val indexCount = indices.count { it !in emittedIndices && it !in removedIndices } + val rangeCount = ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) } + return indexCount + rangeCount + } + + fun isWaiting(index: Int): Boolean { + if (index in emittedIndices || index in removedIndices) return false + return indices.contains(index) || ranges.any { it.contains(index) } + } + + fun waitingIndices(): Set { + return buildSet { + indices.filterTo(this) { it !in emittedIndices && it !in removedIndices } + ranges.forEach { cursor -> + for (index in cursor.next..cursor.end) { + if (index !in emittedIndices && index !in removedIndices) add(index) + } + } + } + } + + private fun addRange(start: Int, end: Int) { + if (end < start) return + removedIndices.removeAll { it in start..end } + ranges.add(RangeCursor(start, end)) + } + + private fun addIndices(values: Iterable) { + values.forEach { addIndex(it) } + } + + private fun addIndex(index: Int) { + emittedIndices.remove(index) + removedIndices.remove(index) + indices.add(index) + } +} 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 new file mode 100644 index 000000000..2f561a90d --- /dev/null +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt @@ -0,0 +1,72 @@ +package io.legado.app.model.cache + +import io.legado.app.data.appDb +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.BookHelp +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.webBook.WebBook +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.sync.Semaphore +import kotlin.coroutines.CoroutineContext + +class CacheDownloadRepository { + + fun getChapter(bookUrl: String, index: Int): BookChapter? { + return appDb.bookChapterDao.getChapter(bookUrl, index) + } + + fun hasImageContent(book: Book, chapter: BookChapter): Boolean { + return BookHelp.hasImageContent(book, chapter) + } + + fun hasContent(book: Book, chapter: BookChapter): Boolean { + return BookHelp.hasContent(book, chapter) + } + + fun saveCachedImagesTask( + scope: CoroutineScope, + context: CoroutineContext, + bookSource: BookSource, + book: Book, + chapter: BookChapter, + ): Coroutine { + return Coroutine.async(scope, context, executeContext = context) { + BookHelp.getContent(book, chapter)?.let { + BookHelp.saveImages(bookSource, book, chapter, it, 1) + } + } + } + + fun downloadContentTask( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + chapter: BookChapter, + context: CoroutineContext, + start: CoroutineStart = CoroutineStart.LAZY, + executeContext: CoroutineContext = context, + semaphore: Semaphore? = null, + ): Coroutine { + return WebBook.getContent( + scope = scope, + bookSource = bookSource, + book = book, + bookChapter = chapter, + context = context, + start = start, + executeContext = executeContext, + semaphore = semaphore, + ) + } + + suspend fun downloadContentAwait( + bookSource: BookSource, + book: Book, + chapter: BookChapter, + ): String { + return WebBook.getContentAwait(bookSource, book, chapter) + } +} 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 new file mode 100644 index 000000000..7ebdb150b --- /dev/null +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt @@ -0,0 +1,92 @@ +package io.legado.app.model.cache + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class CacheDownloadStateStore { + + private val _stateFlow = MutableStateFlow(CacheDownloadState()) + val stateFlow = _stateFlow.asStateFlow() + + val state: CacheDownloadState + get() = _stateFlow.value + + fun updateBookQueue( + bookUrl: String, + waitingCount: Int, + runningIndices: Set, + ) { + updateBook(bookUrl) { current -> + current.copy( + waitingCount = waitingCount, + runningIndices = runningIndices, + ) + } + } + + fun markSuccess(bookUrl: String, chapterIndex: Int) { + updateBook(bookUrl) { current -> + val successIndices = current.successIndices + chapterIndex + current.copy( + runningIndices = current.runningIndices - chapterIndex, + failedIndices = current.failedIndices - chapterIndex, + successIndices = successIndices, + successCount = successIndices.size, + ) + } + } + + fun markFailed(bookUrl: String, chapterIndex: Int) { + updateBook(bookUrl) { current -> + current.copy( + runningIndices = current.runningIndices - chapterIndex, + failedIndices = current.failedIndices + chapterIndex, + ) + } + } + + fun clearFailure(bookUrl: String, chapterIndex: Int) { + updateBook(bookUrl) { current -> + current.copy(failedIndices = current.failedIndices - chapterIndex) + } + } + + fun removeBook(bookUrl: String) { + _stateFlow.update { state -> + state.copy(books = state.books - bookUrl).recalculate() + } + } + + fun clear() { + _stateFlow.value = CacheDownloadState() + } + + fun bookState(bookUrl: String): CacheBookDownloadState? { + return state.books[bookUrl] + } + + private fun updateBook( + bookUrl: String, + transform: (CacheBookDownloadState) -> CacheBookDownloadState, + ) { + _stateFlow.update { state -> + val current = state.books[bookUrl] ?: CacheBookDownloadState(bookUrl) + state.copy(books = state.books + (bookUrl to transform(current))).recalculate() + } + } + + private fun CacheDownloadState.recalculate(): CacheDownloadState { + val totalWaiting = books.values.sumOf { it.waitingCount } + val totalRunning = books.values.sumOf { it.runningIndices.size } + val totalFailure = books.values.sumOf { it.failedIndices.size } + val totalSuccess = books.values.sumOf { it.successCount } + return copy( + isRunning = totalWaiting > 0 || totalRunning > 0, + totalWaiting = totalWaiting, + totalRunning = totalRunning, + totalFailure = totalFailure, + totalSuccess = totalSuccess, + ) + } +} diff --git a/app/src/main/java/io/legado/app/model/cache/ReadingCacheEvents.kt b/app/src/main/java/io/legado/app/model/cache/ReadingCacheEvents.kt new file mode 100644 index 000000000..c93e1386c --- /dev/null +++ b/app/src/main/java/io/legado/app/model/cache/ReadingCacheEvents.kt @@ -0,0 +1,25 @@ +package io.legado.app.model.cache + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +sealed interface ReadingCacheEvent { + data class ContentReady( + val book: Book, + val chapter: BookChapter, + val content: String, + val resetPageOffset: Boolean, + val canceled: Boolean, + ) : ReadingCacheEvent +} + +object ReadingCacheEvents { + private val _events = MutableSharedFlow(extraBufferCapacity = 32) + val events = _events.asSharedFlow() + + fun emit(event: ReadingCacheEvent) { + _events.tryEmit(event) + } +} diff --git a/app/src/main/java/io/legado/app/service/CacheBookService.kt b/app/src/main/java/io/legado/app/service/CacheBookService.kt index 4dde1513e..06fcb772b 100644 --- a/app/src/main/java/io/legado/app/service/CacheBookService.kt +++ b/app/src/main/java/io/legado/app/service/CacheBookService.kt @@ -7,17 +7,18 @@ import io.legado.app.R import io.legado.app.base.BaseService import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog -import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.data.appDb import io.legado.app.help.book.update import io.legado.app.model.CacheBook +import io.legado.app.model.cache.CacheDownloadRequest +import io.legado.app.model.cache.CacheDownloadSource +import io.legado.app.model.cache.ChapterSelection import io.legado.app.model.webBook.WebBook import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.main.MainActivity import io.legado.app.utils.activityPendingIntent -import io.legado.app.utils.postEvent import io.legado.app.utils.servicePendingIntent import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher @@ -75,7 +76,6 @@ class CacheBookService : BaseService() { delay(1000) notificationContent = CacheBook.downloadSummary upCacheBookNotification() - postEvent(EventBus.UP_DOWNLOAD, "") } } } @@ -111,18 +111,39 @@ class CacheBookService : BaseService() { cachePool.close() CacheBook.close() super.onDestroy() - postEvent(EventBus.UP_DOWNLOAD, "") } - private fun addDownloadData(bookUrl: String?, indices: List) { + private fun addDownloadData(bookUrl: String?, indices: Iterable) { bookUrl ?: return - if (indices.isEmpty()) return + val values = indices.toSet() + if (values.isEmpty()) return + addDownloadRequest( + CacheDownloadRequest( + bookUrl = bookUrl, + selection = ChapterSelection.Indices(values), + source = CacheDownloadSource.Manual, + ) + ) + } + private fun addDownloadData(bookUrl: String?, start: Int, end: Int) { + bookUrl ?: return + if (end < start) return + addDownloadRequest( + CacheDownloadRequest( + bookUrl = bookUrl, + selection = ChapterSelection.Range(start, end), + source = CacheDownloadSource.Manual, + ) + ) + } + + private fun addDownloadRequest(request: CacheDownloadRequest) { execute { - val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute + val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@execute val book = cacheBook.book - val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl) + val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl) if (chapterCount == 0) { cacheBook.setLoading() @@ -132,7 +153,7 @@ class CacheBookService : BaseService() { kotlin.runCatching { WebBook.getBookInfoAwait(cacheBook.bookSource, book) }.onFailure { - removeDownload(bookUrl) + removeDownload(request.bookUrl) AppLog.put( "《$name》目录为空且加载详情页失败\n${it.localizedMessage}", it, @@ -147,7 +168,7 @@ class CacheBookService : BaseService() { book.totalChapterNum = 0 book.update() } - removeDownload(bookUrl) + removeDownload(request.bookUrl) AppLog.put( "《$name》目录为空且加载目录失败\n${it.localizedMessage}", it, @@ -163,7 +184,7 @@ class CacheBookService : BaseService() { } //添加章节到下载队列 - cacheBook.addDownloads(indices) + cacheBook.addRequest(request) notificationContent = CacheBook.downloadSummary upCacheBookNotification() @@ -174,14 +195,9 @@ class CacheBookService : BaseService() { } } - private fun addDownloadData(bookUrl: String?, start: Int, end: Int) { - addDownloadData(bookUrl, (start..end).toList()) - } - private fun removeDownload(bookUrl: String?) { CacheBook.cacheBookMap[bookUrl]?.stop() - postEvent(EventBus.UP_DOWNLOAD, "") if (downloadJob == null && CacheBook.isRun) { download() return 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 22580da41..ebcc7a698 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 @@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap import kotlin.math.min data class BookCacheManageUiState( @@ -94,6 +97,11 @@ class BookCacheManageViewModel( private val clearBookCacheUseCase: ClearBookCacheUseCase, ) : BaseViewModel(application) { + private companion object { + const val DOWNLOAD_BATCH_SIZE = 512 + const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L + } + private val _uiState = MutableStateFlow(BookCacheManageUiState()) val uiState = _uiState.asStateFlow() @@ -103,6 +111,9 @@ class BookCacheManageViewModel( private var observeJob: Job? = null private var fullReloadJob: Job? = null private val bookReloadJobs = hashMapOf() + private val pendingDownloadRefreshBookUrls = ConcurrentHashMap.newKeySet() + @Volatile + private var pendingDownloadSummaryRefresh = false fun onIntent(intent: BookCacheManageIntent) { when (intent) { @@ -140,32 +151,24 @@ class BookCacheManageViewModel( } viewModelScope.launch { CacheBook.cacheSuccessFlow.collect { chapter -> - scheduleBookReload(chapter.bookUrl) + scheduleDownloadStatusRefresh(chapter.bookUrl) } } viewModelScope.launch { - CacheBook.downloadingIndicesFlow.collect { state -> - scheduleBookReload(state.first) + CacheBook.downloadStateFlow.collect { state -> + state.books.keys.forEach { scheduleDownloadStatusRefresh(it) } + pendingDownloadSummaryRefresh = true } } viewModelScope.launch { CacheBook.queueChangedFlow.collect { bookUrl -> - scheduleBookReload(bookUrl) + scheduleDownloadStatusRefresh(bookUrl) } } viewModelScope.launch { - CacheBook.downloadErrorFlow.collect { state -> - scheduleBookReload(state.first) - } - } - viewModelScope.launch { - CacheBook.downloadSummaryFlow.collect { - _uiState.update { - it.copy( - downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks), - version = it.version + 1, - ) - } + while (isActive) { + delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS) + flushDownloadStatusRefresh() } } } @@ -216,6 +219,31 @@ class BookCacheManageViewModel( } } + private fun scheduleDownloadStatusRefresh(bookUrl: String) { + if (bookUrl.isNotBlank()) { + pendingDownloadRefreshBookUrls.add(bookUrl) + } + pendingDownloadSummaryRefresh = true + } + + private suspend fun flushDownloadStatusRefresh() { + val bookUrls = pendingDownloadRefreshBookUrls.toList() + bookUrls.forEach { pendingDownloadRefreshBookUrls.remove(it) } + val shouldRefreshSummary = pendingDownloadSummaryRefresh || bookUrls.isNotEmpty() + pendingDownloadSummaryRefresh = false + bookUrls.forEach { bookUrl -> + reloadBook(bookUrl) + } + if (shouldRefreshSummary) { + _uiState.update { + it.copy( + downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks), + version = it.version + 1, + ) + } + } + } + private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) { if (bookUrl.isBlank()) return bookReloadJobs.remove(bookUrl)?.cancel() @@ -278,8 +306,7 @@ class BookCacheManageViewModel( private fun buildBookItem(book: Book): BookCacheBookItem? { val cacheFiles = BookHelp.getChapterFiles(book) val model = CacheBook.cacheBookMap[book.bookUrl] - val waitingIndices = model?.waitingIndices().orEmpty() - val downloadingIndices = model?.downloadingIndices().orEmpty() + val (waitingCount, downloadingCount) = model?.queueCounts() ?: (0 to 0) val errorIndices = errorIndices(book.bookUrl) val totalCount = bookChapterDao.getChapterCount(book.bookUrl) val cachedFileCount = cacheFiles.count { it.endsWith(".nb") } @@ -293,8 +320,8 @@ class BookCacheManageViewModel( author = book.getRealAuthor(), totalCount = totalCount, cachedCount = cachedCount, - waitingCount = waitingIndices.size, - downloadingCount = downloadingIndices.size, + waitingCount = waitingCount, + downloadingCount = downloadingCount, errorCount = errorIndices.size, isNotShelf = book.isNotShelf, ) @@ -305,8 +332,6 @@ class BookCacheManageViewModel( val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl) val cacheFiles = BookHelp.getChapterFiles(book) val model = CacheBook.cacheBookMap[bookUrl] - val waitingIndices = model?.waitingIndices().orEmpty() - val downloadingIndices = model?.downloadingIndices().orEmpty() val errorIndices = errorIndices(bookUrl) return chapters.map { chapter -> BookCacheChapterItem( @@ -314,8 +339,8 @@ class BookCacheManageViewModel( title = chapter.title, index = chapter.index, isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume, - isWaiting = waitingIndices.contains(chapter.index), - isDownloading = downloadingIndices.contains(chapter.index), + isWaiting = model?.isWaiting(chapter.index) == true, + isDownloading = model?.isDownloading(chapter.index) == true, isError = errorIndices.contains(chapter.index), ) } @@ -377,10 +402,14 @@ class BookCacheManageViewModel( private fun startAllDownloads() { val items = uiState.value.shelfBooks + uiState.value.notShelfBooks execute { - items.sumOf { item -> - val chapterIndices = downloadableChapterIndices(item.bookUrl) - cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices) + var count = 0 + items.forEach { item -> + downloadableChapterIndexBatches(item.bookUrl).forEach { chapterIndices -> + count += cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices) + } + currentCoroutineContext().ensureActive() } + count }.onSuccess { count -> if (count > 0) { _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章")) @@ -396,7 +425,11 @@ class BookCacheManageViewModel( private fun startBookDownload(bookUrl: String) { execute { - cacheBookChaptersUseCase.execute(bookUrl, downloadableChapterIndices(bookUrl)) + var count = 0 + downloadableChapterIndexBatches(bookUrl).forEach { chapterIndices -> + count += cacheBookChaptersUseCase.execute(bookUrl, chapterIndices) + } + count }.onSuccess { count -> if (count > 0) { _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章")) @@ -410,22 +443,32 @@ class BookCacheManageViewModel( } } - private fun downloadableChapterIndices(bookUrl: String): List { - val book = bookDao.getBook(bookUrl) ?: return emptyList() + private fun downloadableChapterIndexBatches( + bookUrl: String, + batchSize: Int = DOWNLOAD_BATCH_SIZE, + ): Sequence> = sequence { + val book = bookDao.getBook(bookUrl) ?: return@sequence val cacheFiles = BookHelp.getChapterFiles(book) val model = CacheBook.cacheBookMap[bookUrl] - val waitingIndices = model?.waitingIndices().orEmpty() - val downloadingIndices = model?.downloadingIndices().orEmpty() - return bookChapterDao.getChapterCacheInfoList(bookUrl) - .asSequence() - .filterNot { chapter -> + var batch = ArrayList(batchSize) + for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) { + if ( chapter.isVolume || cacheFiles.contains(chapter.getFileName()) || - waitingIndices.contains(chapter.index) || - downloadingIndices.contains(chapter.index) + model?.isWaiting(chapter.index) == true || + model?.isDownloading(chapter.index) == true + ) { + continue } - .map { it.index } - .toList() + batch.add(chapter.index) + if (batch.size == batchSize) { + yield(batch) + batch = ArrayList(batchSize) + } + } + if (batch.isNotEmpty()) { + yield(batch) + } } private fun deleteBookCache(bookUrl: String) { diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt index 386ee4630..3eb379758 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt @@ -37,7 +37,9 @@ import io.legado.app.utils.cnCompare import io.legado.app.utils.move import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -155,6 +157,10 @@ class BookshelfManageScreenViewModel( private val updateBooksGroupUseCase: UpdateBooksGroupUseCase ) : BaseViewModel(application) { + private companion object { + const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L + } + private val _uiState = MutableStateFlow(BookshelfManageScreenUiState()) val uiState = _uiState.asStateFlow() @@ -165,8 +171,11 @@ class BookshelfManageScreenViewModel( private var booksJob: Job? = null private var groupsJob: Job? = null private var cacheLoadJob: Job? = null - private val cacheCountJobs = ConcurrentHashMap() private var observersStarted = false + private val pendingDownloadStatusBookUrls = ConcurrentHashMap.newKeySet() + private val pendingCacheCountRefreshBookUrls = ConcurrentHashMap.newKeySet() + @Volatile + private var pendingDownloadRunningRefresh = false fun dispatch(intent: BookshelfManageScreenIntent) { when (intent) { @@ -367,28 +376,26 @@ class BookshelfManageScreenViewModel( observersStarted = true viewModelScope.launch { CacheBook.cacheSuccessFlow.collect { chapter -> - onChapterCached(chapter) + scheduleCacheCountRefresh(chapter.bookUrl) } } viewModelScope.launch { - CacheBook.downloadingIndicesFlow.collect { (bookUrl, _) -> - syncDownloadRunning() - if (bookUrl.isNotEmpty()) { - emitBookChanged(bookUrl) + CacheBook.downloadStateFlow.collect { state -> + state.books.keys.forEach { bookUrl -> + scheduleDownloadStatusRefresh(bookUrl) } + scheduleDownloadStatusRefresh() } } viewModelScope.launch { - CacheBook.downloadErrorFlow.collect { (bookUrl, _) -> - syncDownloadRunning() - if (bookUrl.isNotEmpty()) { - emitBookChanged(bookUrl) - } + CacheBook.queueChangedFlow.collect { bookUrl -> + scheduleDownloadStatusRefresh(bookUrl) } } - viewModelScope.launch { - CacheBook.downloadSummaryFlow.collect { - syncDownloadRunning() + viewModelScope.launch(Dispatchers.IO) { + while (isActive) { + delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS) + flushDownloadStatusRefresh() } } viewModelScope.launch { @@ -432,8 +439,6 @@ class BookshelfManageScreenViewModel( private fun loadCacheCounts(books: List) { cacheLoadJob?.cancel() - cacheCountJobs.values.forEach { it.cancel() } - cacheCountJobs.clear() cacheLoadJob = viewModelScope.launch(Dispatchers.IO) { val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl } cacheCounts.keys.toList().forEach { bookUrl -> @@ -451,22 +456,48 @@ class BookshelfManageScreenViewModel( } } - private fun onChapterCached(chapter: BookChapter) { - val bookUrl = chapter.bookUrl - scheduleCacheCountRefresh(bookUrl) + private fun scheduleCacheCountRefresh(bookUrl: String) { + if (bookUrl.isNotBlank()) { + pendingCacheCountRefreshBookUrls.add(bookUrl) + } + pendingDownloadRunningRefresh = true } - private fun scheduleCacheCountRefresh(bookUrl: String) { - cacheCountJobs.remove(bookUrl)?.cancel() - cacheCountJobs[bookUrl] = viewModelScope.launch(Dispatchers.IO) { - val book = bookDao.getBook(bookUrl) ?: return@launch - if (!uiState.value.books.any { it.bookUrl == bookUrl }) { - return@launch - } - cacheCounts[bookUrl] = calculateCacheCount(book) - emitBookChanged(bookUrl) - cacheCountJobs.remove(bookUrl) + private fun scheduleDownloadStatusRefresh(bookUrl: String = "") { + if (bookUrl.isNotBlank()) { + pendingDownloadStatusBookUrls.add(bookUrl) } + pendingDownloadRunningRefresh = true + } + + private suspend fun flushDownloadStatusRefresh() { + val cacheRefreshBookUrls = pendingCacheCountRefreshBookUrls.toList() + cacheRefreshBookUrls.forEach { pendingCacheCountRefreshBookUrls.remove(it) } + val statusBookUrls = pendingDownloadStatusBookUrls.toList() + statusBookUrls.forEach { pendingDownloadStatusBookUrls.remove(it) } + val shouldSyncDownloadRunning = pendingDownloadRunningRefresh || + cacheRefreshBookUrls.isNotEmpty() || + statusBookUrls.isNotEmpty() + pendingDownloadRunningRefresh = false + val changedBookUrls = linkedSetOf() + val visibleBookUrls = uiState.value.books.mapTo(hashSetOf()) { it.bookUrl } + cacheRefreshBookUrls.forEach { bookUrl -> + if (visibleBookUrls.contains(bookUrl)) { + bookDao.getBook(bookUrl)?.let { book -> + cacheCounts[bookUrl] = calculateCacheCount(book) + changedBookUrls.add(bookUrl) + } + } + } + statusBookUrls.forEach { bookUrl -> + if (visibleBookUrls.contains(bookUrl)) { + changedBookUrls.add(bookUrl) + } + } + if (shouldSyncDownloadRunning) { + syncDownloadRunning() + } + emitBooksChanged(changedBookUrls) } private fun calculateCacheCount(book: Book): Int { @@ -496,7 +527,7 @@ class BookshelfManageScreenViewModel( syncDownloadRunning() } else { execute { - cacheBookChaptersUseCase.execute(book.bookUrl, 0..book.lastChapterIndex) + cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex) }.onFinally { syncDownloadRunning() } @@ -873,4 +904,12 @@ class BookshelfManageScreenViewModel( _effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl)) } + private fun emitBooksChanged(bookUrls: Set) { + if (bookUrls.isEmpty()) return + _uiState.update { it.copy(cacheVersion = it.cacheVersion + 1) } + bookUrls.forEach { bookUrl -> + _effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl)) + } + } + } diff --git a/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt index d61365160..b1819b88a 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt @@ -303,8 +303,7 @@ abstract class BaseReadBookActivity : val end = editEnd.text!!.toString().let { if (it.isEmpty()) book.totalChapterNum else it.toInt() } - val indices = (start - 1..end - 1).toList() - CacheBook.start(this@BaseReadBookActivity, book, indices) + CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1) } } cancelButton() diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt index cee70dd93..c78908c07 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt @@ -21,6 +21,7 @@ import io.legado.app.help.bookmark.BookmarkExporter import io.legado.app.help.config.AppConfig import io.legado.app.model.CacheBook import io.legado.app.model.ReadBook +import io.legado.app.model.cache.CacheBookDownloadState import io.legado.app.model.localBook.LocalBook import io.legado.app.ui.config.readConfig.ReadConfig import io.legado.app.ui.widget.components.importComponents.BaseImportUiState @@ -89,8 +90,7 @@ data class TocDomainItem( ) private data class DownloadContext( - val downloadingPair: Pair>, - val errorPair: Pair>, + val downloadState: CacheBookDownloadState?, val cachedFiles: Set ) @@ -206,11 +206,11 @@ class TocViewModel( .distinctUntilChanged() private val downloadContextFlow = combine( - CacheBook.downloadingIndicesFlow, - CacheBook.downloadErrorFlow, + bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged(), + CacheBook.downloadStateFlow, _cacheFileNames - ) { downloading, errors, cached -> - DownloadContext(downloading, errors, cached) + ) { bookUrl, state, cached -> + DownloadContext(state.books[bookUrl], cached) } private val uiConfigFlow = combine( @@ -263,15 +263,13 @@ class TocViewModel( } } - val (downloadingPair, errorPair, cachedFiles) = downloadCtx - val downloadingIndices = - if (downloadingPair.first == book.bookUrl) downloadingPair.second else emptySet() - val errorIndices = - if (errorPair.first == book.bookUrl) errorPair.second else emptySet() + val runningIndices = downloadCtx.downloadState?.runningIndices.orEmpty() + val errorIndices = downloadCtx.downloadState?.failedIndices.orEmpty() + val cachedFiles = downloadCtx.cachedFiles processedChapters.map { chapter -> val downloadState = when { - chapter.index in downloadingIndices -> DownloadState.DOWNLOADING + chapter.index in runningIndices -> DownloadState.DOWNLOADING chapter.index in errorIndices -> DownloadState.ERROR chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS else -> DownloadState.NONE diff --git a/app/src/test/java/io/legado/app/model/cache/CacheDownloadQueueTest.kt b/app/src/test/java/io/legado/app/model/cache/CacheDownloadQueueTest.kt new file mode 100644 index 000000000..199e48ce9 --- /dev/null +++ b/app/src/test/java/io/legado/app/model/cache/CacheDownloadQueueTest.kt @@ -0,0 +1,78 @@ +package io.legado.app.model.cache + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CacheDownloadQueueTest { + + @Test + fun rangeReturnsChaptersLazily() { + val queue = CacheDownloadQueue() + + queue.enqueue(ChapterSelection.Range(2, 4)) + + assertEquals(3, queue.waitingCount()) + assertEquals(2, queue.next("book", emptySet())?.chapterIndex) + assertEquals(2, queue.waitingCount()) + assertEquals(3, queue.next("book", emptySet())?.chapterIndex) + assertEquals(4, queue.next("book", emptySet())?.chapterIndex) + assertNull(queue.next("book", emptySet())) + } + + @Test + fun removeChapterSkipsChapterInsideRange() { + val queue = CacheDownloadQueue() + + queue.enqueue(ChapterSelection.Range(0, 3)) + assertTrue(queue.removeChapter(1)) + + assertEquals(listOf(0, 2, 3), drain(queue)) + assertFalse(queue.isWaiting(1)) + } + + @Test + fun nextDoesNotReturnRunningChapter() { + val queue = CacheDownloadQueue() + + queue.enqueue(ChapterSelection.Indices(setOf(1, 2))) + + assertEquals(2, queue.next("book", setOf(1))?.chapterIndex) + assertNull(queue.next("book", emptySet())) + } + + @Test + fun explicitRequeueCanRetryConsumedChapter() { + val queue = CacheDownloadQueue() + + queue.enqueue(ChapterSelection.Range(1, 1)) + assertEquals(1, queue.next("book", emptySet())?.chapterIndex) + queue.enqueue(ChapterSelection.Single(1)) + + assertEquals(1, queue.next("book", emptySet())?.chapterIndex) + } + + @Test + fun reEnqueueRangeRestoresRemovedChapter() { + val queue = CacheDownloadQueue() + + queue.enqueue(ChapterSelection.Range(0, 2)) + assertTrue(queue.removeChapter(1)) + assertEquals(0, queue.next("book", emptySet())?.chapterIndex) + queue.enqueue(ChapterSelection.Range(1, 1)) + + assertEquals(1, queue.next("book", emptySet())?.chapterIndex) + assertEquals(2, queue.next("book", emptySet())?.chapterIndex) + } + + private fun drain(queue: CacheDownloadQueue): List { + val result = mutableListOf() + while (true) { + val next = queue.next("book", emptySet()) ?: break + result.add(next.chapterIndex) + } + return result + } +} 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 new file mode 100644 index 000000000..6779812df --- /dev/null +++ b/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt @@ -0,0 +1,61 @@ +package io.legado.app.model.cache + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CacheDownloadStateStoreTest { + + @Test + fun queueAndRunningCountsAreDerivedFromBooks() { + val store = CacheDownloadStateStore() + + store.updateBookQueue("a", waitingCount = 2, runningIndices = setOf(1)) + store.updateBookQueue("b", waitingCount = 3, runningIndices = setOf(4, 5)) + + val state = store.state + assertTrue(state.isRunning) + assertEquals(5, state.totalWaiting) + assertEquals(3, state.totalRunning) + } + + @Test + fun successAndFailureUpdateBookState() { + val store = CacheDownloadStateStore() + + store.updateBookQueue("a", waitingCount = 0, runningIndices = setOf(1, 2)) + store.markFailed("a", 1) + store.markSuccess("a", 2) + + val bookState = store.state.books.getValue("a") + assertEquals(setOf(1), bookState.failedIndices) + assertEquals(emptySet(), bookState.runningIndices) + assertEquals(1, bookState.successCount) + assertEquals(1, store.state.totalFailure) + assertEquals(1, store.state.totalSuccess) + } + + @Test + fun duplicateSuccessDoesNotInflateCount() { + val store = CacheDownloadStateStore() + + store.markSuccess("a", 1) + store.markSuccess("a", 1) + + assertEquals(1, store.state.books.getValue("a").successCount) + assertEquals(1, store.state.totalSuccess) + } + + @Test + fun removeBookRecalculatesRunningState() { + val store = CacheDownloadStateStore() + + store.updateBookQueue("a", waitingCount = 1, runningIndices = emptySet()) + store.removeBook("a") + + assertFalse(store.state.isRunning) + assertEquals(0, store.state.totalWaiting) + assertEquals(emptyMap(), store.state.books) + } +}