refactor: Replace DownloadQueue with Flow (#301)

* refactor: Replace DownloadQueue with Flow

* fix: Remove DownloadQueue leftover

* fix: Download progress not progressing

* chore: Remove unnecessary downloadStatusChange trigger

Already handled by MainActivity

* fix: Chapter download state stuck in CHECKED

* fix: Chapter download state stuck in QUEUE on deletion

* fix: A regression, download progress not progressing

* refactor: Remove rx usage

* docs: Sync changelog
This commit is contained in:
Ahmad Ansori Palembani 2024-12-14 09:17:50 +07:00 committed by GitHub
parent 37535d3bcf
commit 16316d810b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 551 additions and 478 deletions

View file

@ -15,6 +15,11 @@ The format is simplified version of [Keep a Changelog](https://keepachangelog.co
- Fix slow chapter load - Fix slow chapter load
- Fix chapter bookmark state is not persistent - Fix chapter bookmark state is not persistent
## Other
- Refactor downloader
- Replace RxJava usage with Kotlin coroutines
- Replace DownloadQueue with Flow to hopefully fix ConcurrentModificationException entirely
## [1.9.2] ## [1.9.2]
### Changes ### Changes

View file

@ -22,8 +22,9 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import yokai.i18n.MR import yokai.i18n.MR
@ -39,7 +40,7 @@ class DownloadJob(val context: Context, workerParams: WorkerParameters) : Corout
private val preferences: PreferencesHelper = Injekt.get() private val preferences: PreferencesHelper = Injekt.get()
override suspend fun getForegroundInfo(): ForegroundInfo { override suspend fun getForegroundInfo(): ForegroundInfo {
val firstDL = downloadManager.queue.firstOrNull() val firstDL = downloadManager.queueState.value.firstOrNull()
val notification = DownloadNotifier(context).setPlaceholder(firstDL).build() val notification = DownloadNotifier(context).setPlaceholder(firstDL).build()
val id = Notifications.ID_DOWNLOAD_CHAPTER val id = Notifications.ID_DOWNLOAD_CHAPTER
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -70,7 +71,6 @@ class DownloadJob(val context: Context, workerParams: WorkerParameters) : Corout
} catch (_: CancellationException) { } catch (_: CancellationException) {
Result.success() Result.success()
} finally { } finally {
callListeners(false, downloadManager)
if (runExtJobAfter) { if (runExtJobAfter) {
ExtensionUpdateJob.runJobAgain(applicationContext, NetworkType.CONNECTED) ExtensionUpdateJob.runJobAgain(applicationContext, NetworkType.CONNECTED)
} }
@ -96,12 +96,6 @@ class DownloadJob(val context: Context, workerParams: WorkerParameters) : Corout
private const val TAG = "Downloader" private const val TAG = "Downloader"
private const val START_EXT_JOB_AFTER = "StartExtJobAfter" private const val START_EXT_JOB_AFTER = "StartExtJobAfter"
private val downloadChannel = MutableSharedFlow<Boolean>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val downloadFlow = downloadChannel.asSharedFlow()
fun start(context: Context, alsoStartExtJob: Boolean = false) { fun start(context: Context, alsoStartExtJob: Boolean = false) {
val request = OneTimeWorkRequestBuilder<DownloadJob>() val request = OneTimeWorkRequestBuilder<DownloadJob>()
.addTag(TAG) .addTag(TAG)
@ -118,16 +112,17 @@ class DownloadJob(val context: Context, workerParams: WorkerParameters) : Corout
context.workManager.cancelUniqueWork(TAG) context.workManager.cancelUniqueWork(TAG)
} }
fun callListeners(downloading: Boolean? = null, downloadManager: DownloadManager? = null) {
val dManager by lazy { downloadManager ?: Injekt.get() }
downloadChannel.tryEmit(downloading ?: !dManager.isPaused())
}
fun isRunning(context: Context): Boolean { fun isRunning(context: Context): Boolean {
return context.workManager return context.workManager
.getWorkInfosForUniqueWork(TAG) .getWorkInfosForUniqueWork(TAG)
.get() .get()
.let { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 } .let { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 }
} }
fun isRunningFlow(context: Context): Flow<Boolean> {
return context.workManager
.getWorkInfosForUniqueWorkFlow(TAG)
.map { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 }
}
} }
} }

View file

@ -5,7 +5,6 @@ import co.touchlab.kermit.Logger
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.domain.manga.models.Manga
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
@ -13,10 +12,14 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import yokai.domain.download.DownloadPreferences import yokai.domain.download.DownloadPreferences
import yokai.i18n.MR import yokai.i18n.MR
@ -65,8 +68,11 @@ class DownloadManager(val context: Context) {
/** /**
* Downloads queue, where the pending chapters are stored. * Downloads queue, where the pending chapters are stored.
*/ */
val queue: DownloadQueue val queueState
get() = downloader.queue get() = downloader.queueState
val isDownloaderRunning
get() = DownloadJob.isRunningFlow(context)
/** /**
* Tells the downloader to begin downloads. * Tells the downloader to begin downloads.
@ -75,7 +81,6 @@ class DownloadManager(val context: Context) {
*/ */
fun startDownloads(): Boolean { fun startDownloads(): Boolean {
val hasStarted = downloader.start() val hasStarted = downloader.start()
DownloadJob.callListeners(downloadManager = this)
return hasStarted return hasStarted
} }
@ -99,22 +104,21 @@ class DownloadManager(val context: Context) {
* *
* @param isNotification value that determines if status is set (needed for view updates) * @param isNotification value that determines if status is set (needed for view updates)
*/ */
fun clearQueue(isNotification: Boolean = false) { fun clearQueue() {
deletePendingDownloads(*downloader.queue.toTypedArray()) deletePendingDownloads(*queueState.value.toTypedArray())
downloader.removeFromQueue(isNotification) downloader.clearQueue()
DownloadJob.callListeners(false, this) downloader.stop()
} }
fun startDownloadNow(chapter: Chapter) { fun startDownloadNow(chapter: Chapter) {
val download = downloader.queue.find { it.chapter.id == chapter.id } ?: return val download = queueState.value.find { it.chapter.id == chapter.id } ?: return
val queue = downloader.queue.toMutableList() val queue = queueState.value.toMutableList()
queue.remove(download) queue.remove(download)
queue.add(0, download) queue.add(0, download)
reorderQueue(queue) reorderQueue(queue)
if (isPaused()) { if (isPaused()) {
if (DownloadJob.isRunning(context)) { if (DownloadJob.isRunning(context)) {
downloader.start() downloader.start()
DownloadJob.callListeners(true, this)
} else { } else {
DownloadJob.start(context) DownloadJob.start(context)
} }
@ -127,24 +131,12 @@ class DownloadManager(val context: Context) {
* @param downloads value to set the download queue to * @param downloads value to set the download queue to
*/ */
fun reorderQueue(downloads: List<Download>) { fun reorderQueue(downloads: List<Download>) {
val wasPaused = isPaused() downloader.updateQueue(downloads)
if (downloads.isEmpty()) {
DownloadJob.stop(context)
downloader.queue.clear()
return
}
downloader.pause()
downloader.queue.clear()
downloader.queue.addAll(downloads)
if (!wasPaused) {
downloader.start()
DownloadJob.callListeners(true, this)
}
} }
fun isPaused() = !downloader.isRunning fun isPaused() = !downloader.isRunning
fun hasQueue() = downloader.queue.isNotEmpty() fun hasQueue() = queueState.value.isNotEmpty()
/** /**
* Tells the downloader to enqueue the given list of chapters. * Tells the downloader to enqueue the given list of chapters.
@ -164,10 +156,7 @@ class DownloadManager(val context: Context) {
*/ */
fun addDownloadsToStartOfQueue(downloads: List<Download>) { fun addDownloadsToStartOfQueue(downloads: List<Download>) {
if (downloads.isEmpty()) return if (downloads.isEmpty()) return
queue.toMutableList().apply { reorderQueue(downloads + queueState.value)
addAll(0, downloads)
reorderQueue(this)
}
if (!DownloadJob.isRunning(context)) DownloadJob.start(context) if (!DownloadJob.isRunning(context)) DownloadJob.start(context)
} }
@ -212,7 +201,7 @@ class DownloadManager(val context: Context) {
* @param chapter the chapter to check. * @param chapter the chapter to check.
*/ */
fun getChapterDownloadOrNull(chapter: Chapter): Download? { fun getChapterDownloadOrNull(chapter: Chapter): Download? {
return downloader.queue return queueState.value
.firstOrNull { it.chapter.id == chapter.id && it.chapter.manga_id == chapter.manga_id } .firstOrNull { it.chapter.id == chapter.id && it.chapter.manga_id == chapter.manga_id }
} }
@ -249,27 +238,15 @@ class DownloadManager(val context: Context) {
* @param manga the manga of the chapters. * @param manga the manga of the chapters.
* @param source the source of the chapters. * @param source the source of the chapters.
*/ */
@OptIn(DelicateCoroutinesApi::class)
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source, force: Boolean = false) { fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source, force: Boolean = false) {
val filteredChapters = if (force) chapters else getChaptersToDelete(chapters, manga) launchIO {
GlobalScope.launch(Dispatchers.IO) { val filteredChapters = if (force) chapters else getChaptersToDelete(chapters, manga)
val wasPaused = isPaused()
if (filteredChapters.isEmpty()) { if (filteredChapters.isEmpty()) {
return@launch return@launchIO
} }
downloader.pause()
downloader.queue.remove(filteredChapters) removeFromDownloadQueue(filteredChapters)
if (!wasPaused && downloader.queue.isNotEmpty()) {
downloader.start()
DownloadJob.callListeners(true)
} else if (downloader.queue.isEmpty() && DownloadJob.isRunning(context)) {
DownloadJob.callListeners(false)
DownloadJob.stop(context)
} else if (downloader.queue.isEmpty()) {
DownloadJob.callListeners(false)
downloader.stop()
}
queue.remove(filteredChapters)
val chapterDirs = val chapterDirs =
provider.findChapterDirs(filteredChapters, manga, source) + provider.findTempChapterDirs( provider.findChapterDirs(filteredChapters, manga, source) + provider.findTempChapterDirs(
filteredChapters, filteredChapters,
@ -278,10 +255,27 @@ class DownloadManager(val context: Context) {
) )
chapterDirs.forEach { it.delete() } chapterDirs.forEach { it.delete() }
cache.removeChapters(filteredChapters, manga) cache.removeChapters(filteredChapters, manga)
if (cache.getDownloadCount(manga, true) == 0) { // Delete manga directory if empty if (cache.getDownloadCount(manga, true) == 0) { // Delete manga directory if empty
chapterDirs.firstOrNull()?.parentFile?.delete() chapterDirs.firstOrNull()?.parentFile?.delete()
} }
queue.updateListeners() }
}
private fun removeFromDownloadQueue(chapters: List<Chapter>) {
val wasRunning = downloader.isRunning
if (wasRunning) {
downloader.pause()
}
downloader.removeFromQueue(chapters)
if (wasRunning) {
if (queueState.value.isEmpty()) {
downloader.stop()
} else if (queueState.value.isNotEmpty()) {
downloader.start()
}
} }
} }
@ -345,9 +339,7 @@ class DownloadManager(val context: Context) {
fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) { fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) {
launchIO { launchIO {
if (removeQueued) { if (removeQueued) {
downloader.removeFromQueue(manga, true) downloader.removeFromQueue(manga)
queue.remove(manga)
queue.updateListeners()
} }
provider.findMangaDir(manga, source)?.delete() provider.findMangaDir(manga, source)?.delete()
cache.removeManga(manga) cache.removeManga(manga)
@ -418,9 +410,6 @@ class DownloadManager(val context: Context) {
cache.forceRenewCache() cache.forceRenewCache()
} }
fun addListener(listener: DownloadQueue.DownloadListener) = queue.addListener(listener)
fun removeListener(listener: DownloadQueue.DownloadListener) = queue.removeListener(listener)
private fun getChaptersToDelete(chapters: List<Chapter>, manga: Manga): List<Chapter> { private fun getChaptersToDelete(chapters: List<Chapter>, manga: Manga): List<Chapter> {
// Retrieve the categories that are set to exclude from being deleted on read // Retrieve the categories that are set to exclude from being deleted on read
return if (!preferences.removeBookmarkedChapters().get()) { return if (!preferences.removeBookmarkedChapters().get()) {
@ -429,4 +418,33 @@ class DownloadManager(val context: Context) {
chapters chapters
} }
} }
fun statusFlow(): Flow<Download> = queueState
.flatMapLatest { downloads ->
downloads
.map { download ->
download.statusFlow.drop(1).map { download }
}
.merge()
}
.onStart {
emitAll(
queueState.value.filter { download -> download.status == Download.State.DOWNLOADING }.asFlow(),
)
}
fun progressFlow(): Flow<Download> = queueState
.flatMapLatest { downloads ->
downloads
.map { download ->
download.progressFlow.drop(1).map { download }
}
.merge()
}
.onStart {
emitAll(
queueState.value.filter { download -> download.status == Download.State.DOWNLOADING }
.asFlow(),
)
}
} }

View file

@ -59,6 +59,12 @@ class DownloadStore(
} }
} }
fun removeAll(downloads: List<Download>) {
preferences.edit {
downloads.forEach { remove(getKey(it)) }
}
}
/** /**
* Removes all the downloads from the store. * Removes all the downloads from the store.
*/ */

View file

@ -5,11 +5,9 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.domain.manga.models.Manga
@ -32,22 +30,31 @@ import java.io.File
import java.util.* import java.util.*
import java.util.zip.* import java.util.zip.*
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response import okhttp3.Response
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import yokai.core.archive.ZipWriter import yokai.core.archive.ZipWriter
import yokai.core.metadata.COMIC_INFO_FILE import yokai.core.metadata.COMIC_INFO_FILE
@ -61,16 +68,7 @@ import yokai.util.lang.getString
/** /**
* This class is the one in charge of downloading chapters. * This class is the one in charge of downloading chapters.
* *
* Its [queue] contains the list of chapters to download. In order to download them, the downloader * Its queue contains the list of chapters to download.
* subscriptions must be running and the list of chapters must be sent to them by [downloadsRelay].
*
* The queue manipulation must be done in one thread (currently the main thread) to avoid unexpected
* behavior, but it's safe to read it from multiple threads.
*
* @param context the application context.
* @param provider the downloads directory provider.
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
* @param sourceManager the source manager.
*/ */
class Downloader( class Downloader(
private val context: Context, private val context: Context,
@ -92,7 +90,8 @@ class Downloader(
/** /**
* Queue where active downloads are kept. * Queue where active downloads are kept.
*/ */
val queue = DownloadQueue(store) private val _queueState = MutableStateFlow<List<Download>>(emptyList())
val queueState = _queueState.asStateFlow()
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@ -101,21 +100,14 @@ class Downloader(
*/ */
private val notifier by lazy { DownloadNotifier(context) } private val notifier by lazy { DownloadNotifier(context) }
/** private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
* Downloader subscription. private var downloaderJob: Job? = null
*/
private var subscription: Subscription? = null
/**
* Relay to send a list of downloads to the downloader.
*/
private val downloadsRelay = PublishRelay.create<List<Download>>()
/** /**
* Whether the downloader is running. * Whether the downloader is running.
*/ */
val isRunning: Boolean val isRunning: Boolean
get() = subscription != null get() = downloaderJob?.isActive ?: false
/** /**
* Whether the downloader is paused * Whether the downloader is paused
@ -126,8 +118,7 @@ class Downloader(
init { init {
launchNow { launchNow {
val chapters = async { store.restore() } val chapters = async { store.restore() }
queue.addAll(chapters.await()) addAllToQueue(chapters.await())
DownloadJob.callListeners()
} }
} }
@ -138,17 +129,17 @@ class Downloader(
* @return true if the downloader is started, false otherwise. * @return true if the downloader is started, false otherwise.
*/ */
fun start(): Boolean { fun start(): Boolean {
if (subscription != null || queue.isEmpty()) { if (isRunning || queueState.value.isEmpty()) {
return false return false
} }
initializeSubscription()
val pending = queue.filter { it.status != Download.State.DOWNLOADED } val pending = queueState.value.filter { it.status != Download.State.DOWNLOADED }
pending.forEach { if (it.status != Download.State.QUEUE) it.status = Download.State.QUEUE } pending.forEach { if (it.status != Download.State.QUEUE) it.status = Download.State.QUEUE }
isPaused = false isPaused = false
downloadsRelay.call(pending) launchDownloaderJob()
return pending.isNotEmpty() return pending.isNotEmpty()
} }
@ -156,8 +147,8 @@ class Downloader(
* Stops the downloader. * Stops the downloader.
*/ */
fun stop(reason: String? = null) { fun stop(reason: String? = null) {
destroySubscription() cancelDownloaderJob()
queue queueState.value
.filter { it.status == Download.State.DOWNLOADING } .filter { it.status == Download.State.DOWNLOADING }
.forEach { it.status = Download.State.ERROR } .forEach { it.status = Download.State.ERROR }
@ -166,104 +157,109 @@ class Downloader(
return return
} }
DownloadJob.stop(context) if (isPaused && queueState.value.isNotEmpty()) {
if (isPaused && queue.isNotEmpty()) {
handler.postDelayed({ notifier.onDownloadPaused() }, 150) handler.postDelayed({ notifier.onDownloadPaused() }, 150)
} else { } else {
notifier.dismiss() notifier.dismiss()
} }
DownloadJob.callListeners(false)
isPaused = false isPaused = false
DownloadJob.stop(context)
} }
/** /**
* Pauses the downloader * Pauses the downloader
*/ */
fun pause() { fun pause() {
destroySubscription() cancelDownloaderJob()
queue queueState.value
.filter { it.status == Download.State.DOWNLOADING } .filter { it.status == Download.State.DOWNLOADING }
.forEach { it.status = Download.State.QUEUE } .forEach { it.status = Download.State.QUEUE }
isPaused = true isPaused = true
} }
/** fun clearQueue() {
* Removes everything from the queue. cancelDownloaderJob()
*
* @param isNotification value that determines if status is set (needed for view updates)
*/
fun removeFromQueue(isNotification: Boolean = false) {
destroySubscription()
// Needed to update the chapter view internalClearQueue()
if (isNotification) {
queue
.filter { it.status == Download.State.QUEUE }
.forEach { it.status = Download.State.NOT_DOWNLOADED }
}
queue.clear()
notifier.dismiss() notifier.dismiss()
} }
/** private fun launchDownloaderJob() {
* Removes everything from the queue for a certain manga
*
* @param isNotification value that determines if status is set (needed for view updates)
*/
fun removeFromQueue(manga: Manga, isNotification: Boolean = false) {
// Needed to update the chapter view
if (isNotification) {
queue.filter { it.status == Download.State.QUEUE && it.manga.id == manga.id }
.forEach { it.status = Download.State.NOT_DOWNLOADED }
}
queue.remove(manga)
if (queue.isEmpty()) {
if (DownloadJob.isRunning(context)) DownloadJob.stop(context)
stop()
}
notifier.dismiss()
}
/**
* Prepares the subscriptions to start downloading.
*/
private fun initializeSubscription() {
if (isRunning) return if (isRunning) return
subscription = downloadsRelay.concatMapIterable { it } downloaderJob = scope.launch {
// Concurrently download from 5 different sources val activeDownloadsFlow = queueState.transformLatest { queue ->
.groupBy { it.source } while (true) {
.flatMap( val activeDownloads = queue.asSequence()
{ bySource -> // Ignore completed downloads, leave them in the queue
bySource.concatMap { download -> .filter {
Observable.fromCallable { val statusValue = it.status.value
runBlocking { downloadChapter(download) } Download.State.NOT_DOWNLOADED.value <= statusValue && statusValue <= Download.State.DOWNLOADING.value
download }
}.subscribeOn(Schedulers.io()) .groupBy { it.source }
.toList()
// Concurrently download from 5 different sources
.take(5)
.map { (_, downloads) -> downloads.first() }
emit(activeDownloads)
if (activeDownloads.isEmpty()) break
// Suspend until a download enters the ERROR state
val activeDownloadsErroredFlow =
combine(activeDownloads.map(Download::statusFlow)) { states ->
states.contains(Download.State.ERROR)
}.filter { it }
activeDownloadsErroredFlow.first()
}
}.distinctUntilChanged()
// Use supervisorScope to cancel child jobs when the downloader job is cancelled
supervisorScope {
val downloadJobs = mutableMapOf<Download, Job>()
activeDownloadsFlow.collectLatest { activeDownloads ->
val downloadJobsToStop = downloadJobs.filter { it.key !in activeDownloads }
downloadJobsToStop.forEach { (download, job) ->
job.cancel()
downloadJobs.remove(download)
} }
},
5, val downloadsToStart = activeDownloads.filter { it !in downloadJobs }
) downloadsToStart.forEach { download ->
.onBackpressureLatest() downloadJobs[download] = launchDownloadJob(download)
.observeOn(AndroidSchedulers.mainThread()) }
.subscribe( }
{ }
completeDownload(it) }
}, }
{ error ->
Logger.e(error) private fun CoroutineScope.launchDownloadJob(download: Download) = launchIO {
notifier.onError(error.message) try {
stop() downloadChapter(download)
},
) // Remove successful download from queue
if (download.status == Download.State.DOWNLOADED) {
removeFromQueue(download)
}
if (areAllDownloadsFinished()) {
stop()
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Logger.e(e)
notifier.onError(e.message)
stop()
}
} }
/** /**
* Destroys the downloader subscriptions. * Destroys the downloader subscriptions.
*/ */
private fun destroySubscription() { private fun cancelDownloaderJob() {
subscription?.unsubscribe() downloaderJob?.cancel()
subscription = null downloaderJob = null
} }
/** /**
@ -279,7 +275,7 @@ class Downloader(
} }
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchIO val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchIO
val wasEmpty = queue.isEmpty() val wasEmpty = queueState.value.isEmpty()
// Called in background thread, the operation can be slow with SAF. // Called in background thread, the operation can be slow with SAF.
val chaptersWithoutDir = async { val chaptersWithoutDir = async {
chapters chapters
@ -292,22 +288,17 @@ class Downloader(
// Runs in main thread (synchronization needed). // Runs in main thread (synchronization needed).
val chaptersToQueue = chaptersWithoutDir.await() val chaptersToQueue = chaptersWithoutDir.await()
// Filter out those already enqueued. // Filter out those already enqueued.
.filter { chapter -> queue.none { it.chapter.id == chapter.id } } .filter { chapter -> queueState.value.none { it.chapter.id == chapter.id } }
// Create a download for each one. // Create a download for each one.
.map { Download(source, manga, it) } .map { Download(source, manga, it) }
if (chaptersToQueue.isNotEmpty()) { if (chaptersToQueue.isNotEmpty()) {
queue.addAll(chaptersToQueue) addAllToQueue(chaptersToQueue)
if (isRunning) {
// Send the list of downloads to the downloader.
downloadsRelay.call(chaptersToQueue)
}
// Start downloader if needed // Start downloader if needed
if (autoStart && wasEmpty) { if (autoStart && wasEmpty) {
val queuedDownloads = queue.count { it.source !is UnmeteredSource } val queuedDownloads = queueState.value.count { it.source !is UnmeteredSource }
val maxDownloadsFromSource = queue val maxDownloadsFromSource = queueState.value
.groupBy { it.source } .groupBy { it.source }
.filterKeys { it !is UnmeteredSource } .filterKeys { it !is UnmeteredSource }
.maxOfOrNull { it.value.size } ?: 0 .maxOfOrNull { it.value.size } ?: 0
@ -670,25 +661,86 @@ class Downloader(
dir.createFile(COMIC_INFO_FILE)?.writeText(xml.encodeToString(ComicInfo.serializer(), comicInfo)) dir.createFile(COMIC_INFO_FILE)?.writeText(xml.encodeToString(ComicInfo.serializer(), comicInfo))
} }
/**
* Completes a download. This method is called in the main thread.
*/
private fun completeDownload(download: Download) {
// Delete successful downloads from queue
if (download.status == Download.State.DOWNLOADED) {
// Remove downloaded chapter from queue
queue.remove(download)
}
if (areAllDownloadsFinished()) {
stop()
}
}
/** /**
* Returns true if all the queued downloads are in DOWNLOADED or ERROR state. * Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
*/ */
private fun areAllDownloadsFinished(): Boolean { private fun areAllDownloadsFinished(): Boolean {
return queue.none { it.status <= Download.State.DOWNLOADING } return queueState.value.none { it.status <= Download.State.DOWNLOADING }
}
private fun addAllToQueue(downloads: List<Download>) {
_queueState.update {
downloads.forEach { download ->
download.status = Download.State.QUEUE
}
store.addAll(downloads)
it + downloads
}
}
fun removeFromQueue(download: Download) {
_queueState.update {
store.remove(download)
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
it - download
}
}
private inline fun removeFromQueueIf(predicate: (Download) -> Boolean) {
_queueState.update { queue ->
val downloads = queue.filter { predicate(it) }
store.removeAll(downloads)
downloads.forEach { download ->
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
}
queue - downloads
}
}
fun removeFromQueue(chapter: Chapter) {
removeFromQueueIf { it.chapter.id == chapter.id }
}
fun removeFromQueue(chapters: List<Chapter>) {
removeFromQueueIf { it.chapter.id in chapters.map { it.id } }
}
fun removeFromQueue(manga: Manga) {
removeFromQueueIf { it.manga.id == manga.id }
}
private fun internalClearQueue() {
_queueState.update {
it.forEach { download ->
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
}
store.clear()
emptyList()
}
}
fun updateQueue(downloads: List<Download>) {
val wasRunning = isRunning
if (downloads.isEmpty()) {
clearQueue()
DownloadJob.stop(context)
return
}
pause()
internalClearQueue()
addAllToQueue(downloads)
if (wasRunning) {
start()
}
} }
companion object { companion object {

View file

@ -4,8 +4,15 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.domain.manga.models.Manga
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import rx.subjects.PublishSubject
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) { class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
@ -17,17 +24,31 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
val downloadedImages: Int val downloadedImages: Int
get() = pages?.count { it.status == Page.State.READY } ?: 0 get() = pages?.count { it.status == Page.State.READY } ?: 0
@Volatile @Transient @Transient
var status: State = State.default private val _statusFlow = MutableStateFlow(State.NOT_DOWNLOADED)
@Transient
val statusFlow = _statusFlow.asStateFlow()
var status: State
get() = _statusFlow.value
set(status) { set(status) {
field = status _statusFlow.value = status
statusSubject?.onNext(this)
statusCallback?.invoke(this)
} }
@Transient private var statusSubject: PublishSubject<Download>? = null @Transient
val progressFlow = flow {
if (pages == null) {
emit(0)
while (pages == null) {
delay(50)
}
}
@Transient private var statusCallback: ((Download) -> Unit)? = null val progressFlows = pages!!.map(Page::progressFlow)
emitAll(combine(progressFlows) { it.average().roundToInt() })
}
.distinctUntilChanged()
.debounce(50)
val pageProgress: Int val pageProgress: Int
get() { get() {
@ -41,21 +62,13 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
return pages.map(Page::progress).average().roundToInt() return pages.map(Page::progress).average().roundToInt()
} }
fun setStatusSubject(subject: PublishSubject<Download>?) { enum class State(val value: Int) {
statusSubject = subject CHECKED(-1),
} NOT_DOWNLOADED(0),
QUEUE(1),
fun setStatusCallback(f: ((Download) -> Unit)?) { DOWNLOADING(2),
statusCallback = f DOWNLOADED(3),
} ERROR(4),
enum class State {
CHECKED,
NOT_DOWNLOADED,
QUEUE,
DOWNLOADING,
DOWNLOADED,
ERROR,
; ;
companion object { companion object {

View file

@ -1,131 +1,84 @@
package eu.kanade.tachiyomi.data.download.model package eu.kanade.tachiyomi.data.download.model
import com.jakewharton.rxrelay.PublishRelay import androidx.annotation.CallSuper
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.data.download.DownloadStore import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.domain.manga.models.Manga import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.combine
import rx.subjects.PublishSubject import kotlinx.coroutines.flow.debounce
import java.util.concurrent.* import kotlinx.coroutines.flow.distinctUntilChanged
class DownloadQueue( sealed class DownloadQueue {
private val store: DownloadStore, interface Listener {
private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>(), val progressJobs: MutableMap<Download, Job>
) :
List<Download> by queue {
private val statusSubject = PublishSubject.create<Download>() // Override with presenterScope or viewScope
val queueListenerScope: CoroutineScope
private val updatedRelay = PublishRelay.create<Unit>() fun onPageProgressUpdate(download: Download) {
onProgressUpdate(download)
private val downloadListeners: MutableList<DownloadListener> = CopyOnWriteArrayList<DownloadListener>()
private var scope = MainScope()
fun addAll(downloads: List<Download>) {
downloads.forEach { download ->
download.setStatusSubject(statusSubject)
download.setStatusCallback(::setPagesFor)
download.status = Download.State.QUEUE
} }
queue.addAll(downloads) fun onProgressUpdate(download: Download)
store.addAll(downloads) fun onQueueUpdate(download: Download)
updatedRelay.call(Unit)
}
fun remove(download: Download) { // Subscribe on presenter/controller creation on UI thread
val removed = queue.remove(download) @CallSuper
store.remove(download) fun onStatusChange(download: Download) {
download.setStatusSubject(null) when (download.status) {
download.setStatusCallback(null) Download.State.DOWNLOADING -> {
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) { launchProgressJob(download)
download.status = Download.State.NOT_DOWNLOADED // Initial update of the downloaded pages
} onQueueUpdate(download)
callListeners(download) }
if (removed) { Download.State.DOWNLOADED -> {
updatedRelay.call(Unit) cancelProgressJob(download)
}
}
fun updateListeners() { onProgressUpdate(download)
val listeners = downloadListeners.toList() onQueueUpdate(download)
listeners.forEach { it.updateDownloads() } }
} Download.State.ERROR -> cancelProgressJob(download)
else -> {
fun remove(chapter: Chapter) { /* unused */
find { it.chapter.id == chapter.id }?.let { remove(it) } }
}
fun remove(chapters: List<Chapter>) {
for (chapter in chapters) { remove(chapter) }
}
fun remove(manga: Manga) {
filter { it.manga.id == manga.id }.forEach { remove(it) }
}
fun clear() {
queue.forEach { download ->
download.setStatusSubject(null)
download.setStatusCallback(null)
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
} }
callListeners(download)
} }
queue.clear()
store.clear()
updatedRelay.call(Unit)
}
private fun setPagesFor(download: Download) { /**
if (download.status == Download.State.DOWNLOADING) { * Observe the progress of a download and notify the view.
if (download.pages != null) { *
for (page in download.pages!!) * @param download the download to observe its progress.
scope.launch { */
page.statusFlow.collectLatest { private fun launchProgressJob(download: Download) {
callListeners(download) val job = queueListenerScope.launchUI {
} while (download.pages == null) {
delay(50)
}
val progressFlows = download.pages!!.map(Page::progressFlow)
combine(progressFlows, Array<Int>::sum)
.distinctUntilChanged()
.debounce(50)
.collectLatest {
onPageProgressUpdate(download)
} }
} }
callListeners(download)
} else if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) { // Avoid leaking jobs
// setPagesSubject(download.pages, null) progressJobs.remove(download)?.cancel()
if (download.status == Download.State.ERROR) {
callListeners(download) progressJobs[download] = job
}
} else {
callListeners(download)
} }
}
private fun callListeners(download: Download) { /**
val iterator = downloadListeners.iterator() * Unsubscribes the given download from the progress subscriptions.
while (iterator.hasNext()) { *
iterator.next().updateDownload(download) * @param download the download to unsubscribe.
*/
private fun cancelProgressJob(download: Download) {
progressJobs.remove(download)?.cancel()
} }
} }
// private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) {
// if (pages != null) {
// for (page in pages) {
// page.setStatusSubject(subject)
// }
// }
// }
fun addListener(listener: DownloadListener) {
downloadListeners.add(listener)
}
fun removeListener(listener: DownloadListener) {
downloadListeners.remove(listener)
}
interface DownloadListener {
fun updateDownload(download: Download)
fun updateDownloads()
}
} }

View file

@ -64,7 +64,7 @@ class NotificationReceiver : BroadcastReceiver() {
downloadManager.pauseDownloads() downloadManager.pauseDownloads()
} }
// Clear the download queue // Clear the download queue
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true) ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue()
// Delete image from path and dismiss notification // Delete image from path and dismiss notification
ACTION_DELETE_IMAGE -> deleteImage( ACTION_DELETE_IMAGE -> deleteImage(
context, context,

View file

@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.ui.base.presenter package eu.kanade.tachiyomi.ui.base.presenter
import androidx.annotation.CallSuper
import java.lang.ref.WeakReference
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import java.lang.ref.WeakReference
open class BaseCoroutinePresenter<T> { open class BaseCoroutinePresenter<T> {
var presenterScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) var presenterScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -24,6 +25,7 @@ open class BaseCoroutinePresenter<T> {
open fun onCreate() { open fun onCreate() {
} }
@CallSuper
open fun onDestroy() { open fun onDestroy() {
presenterScope.cancel() presenterScope.cancel()
weakView = null weakView = null

View file

@ -4,7 +4,9 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter
import eu.kanade.tachiyomi.util.system.launchUI
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -12,7 +14,8 @@ import uy.kohesive.injekt.injectLazy
/** /**
* Presenter of [DownloadBottomSheet]. * Presenter of [DownloadBottomSheet].
*/ */
class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>() { class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>(),
DownloadQueue.Listener {
/** /**
* Download manager. * Download manager.
@ -20,15 +23,27 @@ class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>() {
val downloadManager: DownloadManager by injectLazy() val downloadManager: DownloadManager by injectLazy()
var items = listOf<DownloadHeaderItem>() var items = listOf<DownloadHeaderItem>()
override val progressJobs = mutableMapOf<Download, Job>()
override val queueListenerScope get() = presenterScope
/** /**
* Property to get the queue from the download manager. * Property to get the queue from the download manager.
*/ */
val downloadQueue: DownloadQueue val downloadQueueState
get() = downloadManager.queue get() = downloadManager.queueState
override fun onCreate() {
presenterScope.launchUI {
downloadManager.statusFlow().collect(::onStatusChange)
}
presenterScope.launchUI {
downloadManager.progressFlow().collect(::onPageProgressUpdate)
}
}
fun getItems() { fun getItems() {
presenterScope.launch { presenterScope.launch {
val items = downloadQueue val items = downloadQueueState.value
.groupBy { it.source } .groupBy { it.source }
.map { entry -> .map { entry ->
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply { DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
@ -85,4 +100,22 @@ class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>() {
fun cancelDownloads(downloads: List<Download>) { fun cancelDownloads(downloads: List<Download>) {
downloadManager.deletePendingDownloads(*downloads.toTypedArray()) downloadManager.deletePendingDownloads(*downloads.toTypedArray())
} }
override fun onStatusChange(download: Download) {
super.onStatusChange(download)
view?.update(downloadManager.isRunning)
}
override fun onQueueUpdate(download: Download) {
view?.onUpdateDownloadedPages(download)
}
override fun onProgressUpdate(download: Download) {
view?.onUpdateProgress(download)
}
override fun onPageProgressUpdate(download: Download) {
super.onPageProgressUpdate(download)
view?.onUpdateDownloadedPages(download)
}
} }

View file

@ -117,14 +117,14 @@ class DownloadBottomSheet @JvmOverloads constructor(
fun update(isRunning: Boolean) { fun update(isRunning: Boolean) {
presenter.getItems() presenter.getItems()
onQueueStatusChange(isRunning) onQueueStatusChange(isRunning)
if (binding.downloadFab.isInvisible != presenter.downloadQueue.isEmpty()) { if (binding.downloadFab.isInvisible != presenter.downloadQueueState.value.isEmpty()) {
binding.downloadFab.isInvisible = presenter.downloadQueue.isEmpty() binding.downloadFab.isInvisible = presenter.downloadQueueState.value.isEmpty()
} }
prepareMenu() prepareMenu()
} }
private fun updateDLTitle() { private fun updateDLTitle() {
val extCount = presenter.downloadQueue.firstOrNull() val extCount = presenter.downloadQueueState.value.firstOrNull()
binding.titleText.text = if (extCount != null) { binding.titleText.text = if (extCount != null) {
context.getString( context.getString(
MR.strings.downloading_, MR.strings.downloading_,
@ -143,8 +143,8 @@ class DownloadBottomSheet @JvmOverloads constructor(
private fun onQueueStatusChange(running: Boolean) { private fun onQueueStatusChange(running: Boolean) {
val oldRunning = isRunning val oldRunning = isRunning
isRunning = running isRunning = running
if (binding.downloadFab.isInvisible != presenter.downloadQueue.isEmpty()) { if (binding.downloadFab.isInvisible != presenter.downloadQueueState.value.isEmpty()) {
binding.downloadFab.isInvisible = presenter.downloadQueue.isEmpty() binding.downloadFab.isInvisible = presenter.downloadQueueState.value.isEmpty()
} }
updateFab() updateFab()
if (oldRunning != running) { if (oldRunning != running) {
@ -210,7 +210,7 @@ class DownloadBottomSheet @JvmOverloads constructor(
private fun setInformationView() { private fun setInformationView() {
updateDLTitle() updateDLTitle()
setBottomSheet() setBottomSheet()
if (presenter.downloadQueue.isEmpty()) { if (presenter.downloadQueueState.value.isEmpty()) {
binding.emptyView.show( binding.emptyView.show(
R.drawable.ic_download_off_24dp, R.drawable.ic_download_off_24dp,
MR.strings.nothing_is_downloading, MR.strings.nothing_is_downloading,
@ -224,10 +224,10 @@ class DownloadBottomSheet @JvmOverloads constructor(
val menu = binding.sheetToolbar.menu val menu = binding.sheetToolbar.menu
updateFab() updateFab()
// Set clear button visibility. // Set clear button visibility.
menu.findItem(R.id.clear_queue)?.isVisible = !presenter.downloadQueue.isEmpty() menu.findItem(R.id.clear_queue)?.isVisible = presenter.downloadQueueState.value.isNotEmpty()
// Set reorder button visibility. // Set reorder button visibility.
menu.findItem(R.id.reorder)?.isVisible = !presenter.downloadQueue.isEmpty() menu.findItem(R.id.reorder)?.isVisible = presenter.downloadQueueState.value.isNotEmpty()
} }
private fun updateFab() { private fun updateFab() {
@ -274,7 +274,7 @@ class DownloadBottomSheet @JvmOverloads constructor(
} }
private fun setBottomSheet() { private fun setBottomSheet() {
val hasQueue = presenter.downloadQueue.isNotEmpty() val hasQueue = presenter.downloadQueueState.value.isNotEmpty()
if (hasQueue) { if (hasQueue) {
sheetBehavior?.skipCollapsed = !hasQueue sheetBehavior?.skipCollapsed = !hasQueue
if (sheetBehavior.isHidden()) sheetBehavior?.collapse() if (sheetBehavior.isHidden()) sheetBehavior?.collapse()
@ -320,7 +320,6 @@ class DownloadBottomSheet @JvmOverloads constructor(
} }
} }
presenter.reorder(downloads) presenter.reorder(downloads)
controller?.updateChapterDownload(download, false)
} }
/** /**

View file

@ -68,7 +68,7 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) :
if (binding.downloadProgress.max == 1) { if (binding.downloadProgress.max == 1) {
binding.downloadProgress.max = pages.size * 100 binding.downloadProgress.max = pages.size * 100
} }
binding.downloadProgress.progress = download.pageProgress binding.downloadProgress.setProgressCompat(download.pageProgress, true)
} }
/** /**

View file

@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.ui.extension
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.extension.ExtensionInstallerJob import eu.kanade.tachiyomi.extension.ExtensionInstallerJob
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
@ -12,7 +10,6 @@ import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.ui.migration.BaseMigrationPresenter import eu.kanade.tachiyomi.ui.migration.BaseMigrationPresenter
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -31,7 +28,7 @@ typealias ExtensionIntallInfo = Pair<InstallStep, PackageInstaller.SessionInfo?>
/** /**
* Presenter of [ExtensionBottomSheet]. * Presenter of [ExtensionBottomSheet].
*/ */
class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>(), DownloadQueue.DownloadListener { class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>() {
private var extensions = emptyList<ExtensionItem>() private var extensions = emptyList<ExtensionItem>()
@ -43,7 +40,7 @@ class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>(),
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
downloadManager.addListener(this)
presenterScope.launch { presenterScope.launch {
val extensionJob = async { val extensionJob = async {
extensionManager.findAvailableExtensions() extensionManager.findAvailableExtensions()
@ -289,11 +286,4 @@ class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>(),
extensionManager.trust(pkgName, versionCode, signatureHash) extensionManager.trust(pkgName, versionCode, signatureHash)
} }
} }
override fun updateDownload(download: Download) = updateDownloads()
override fun updateDownloads() {
presenterScope.launchUI {
view?.updateDownloadStatus(!downloadManager.isPaused())
}
}
} }

View file

@ -521,8 +521,4 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
return if (index == -1) POSITION_NONE else index return if (index == -1) POSITION_NONE else index
} }
} }
fun updateDownloadStatus(isRunning: Boolean) {
(controller.activity as? MainActivity)?.downloadStatusChanged(isRunning)
}
} }

View file

@ -61,7 +61,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.preference.Preference import eu.kanade.tachiyomi.core.preference.Preference
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.download.DownloadJob
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
@ -1059,7 +1058,6 @@ open class LibraryController(
presenter.getLibrary() presenter.getLibrary()
isPoppingIn = true isPoppingIn = true
} }
DownloadJob.callListeners()
binding.recyclerCover.isClickable = false binding.recyclerCover.isClickable = false
binding.recyclerCover.isFocusable = false binding.recyclerCover.isFocusable = false
singleCategory = presenter.categories.size <= 1 singleCategory = presenter.categories.size <= 1
@ -2199,8 +2197,4 @@ open class LibraryController(
} }
} }
} }
fun updateDownloadStatus(isRunning: Boolean) {
(activity as? MainActivity)?.downloadStatusChanged(isRunning)
}
} }

View file

@ -11,8 +11,6 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.removeCover import eu.kanade.tachiyomi.data.database.models.removeCover
import eu.kanade.tachiyomi.data.database.models.seriesType import eu.kanade.tachiyomi.data.database.models.seriesType
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.preference.DelayedLibrarySuggestionsJob import eu.kanade.tachiyomi.data.preference.DelayedLibrarySuggestionsJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
@ -44,7 +42,6 @@ import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata
import eu.kanade.tachiyomi.util.mapStatus import eu.kanade.tachiyomi.util.mapStatus
import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNonCancellableIO import eu.kanade.tachiyomi.util.system.launchNonCancellableIO
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.withIOContext import eu.kanade.tachiyomi.util.system.withIOContext
import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.system.withUIContext
import java.util.* import java.util.*
@ -95,7 +92,7 @@ class LibraryPresenter(
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val chapterFilter: ChapterFilter = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(), private val trackManager: TrackManager = Injekt.get(),
) : BaseCoroutinePresenter<LibraryController>(), DownloadQueue.DownloadListener { ) : BaseCoroutinePresenter<LibraryController>() {
private val getCategories: GetCategories by injectLazy() private val getCategories: GetCategories by injectLazy()
private val setMangaCategories: SetMangaCategories by injectLazy() private val setMangaCategories: SetMangaCategories by injectLazy()
private val updateCategories: UpdateCategories by injectLazy() private val updateCategories: UpdateCategories by injectLazy()
@ -189,7 +186,7 @@ class LibraryPresenter(
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
downloadManager.addListener(this)
if (!controllerIsSubClass) { if (!controllerIsSubClass) {
lastLibraryItems?.let { libraryItems = it } lastLibraryItems?.let { libraryItems = it }
lastCategories?.let { categories = it } lastCategories?.let { categories = it }
@ -1640,13 +1637,6 @@ class LibraryPresenter(
} }
} }
override fun updateDownload(download: Download) = updateDownloads()
override fun updateDownloads() {
presenterScope.launchUI {
view?.updateDownloadStatus(!downloadManager.isPaused())
}
}
data class ItemPreferences( data class ItemPreferences(
val filterDownloaded: Int, val filterDownloaded: Int,
val filterUnread: Int, val filterUnread: Int,

View file

@ -62,12 +62,12 @@ import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.Router import com.bluelinelabs.conductor.Router
import com.getkeepsafe.taptargetview.TapTarget import com.getkeepsafe.taptargetview.TapTarget
import com.getkeepsafe.taptargetview.TapTargetView import com.getkeepsafe.taptargetview.TapTargetView
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadJob
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
@ -458,7 +458,7 @@ open class MainActivity : BaseActivity<MainActivityBinding>() {
} }
} }
DownloadJob.downloadFlow.onEach(::downloadStatusChanged).launchIn(lifecycleScope) downloadManager.isDownloaderRunning.onEach(::downloadStatusChanged).launchIn(lifecycleScope)
lifecycleScope lifecycleScope
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
@ -947,7 +947,6 @@ open class MainActivity : BaseActivity<MainActivityBinding>() {
extensionManager.getExtensionUpdates(false) extensionManager.getExtensionUpdates(false)
} }
setExtensionsBadge() setExtensionsBadge()
DownloadJob.callListeners(downloadManager = downloadManager)
showDLQueueTutorial() showDLQueueTutorial()
reEnableBackPressedCallBack() reEnableBackPressedCallBack()
} }
@ -1504,12 +1503,16 @@ open class MainActivity : BaseActivity<MainActivityBinding>() {
} }
} }
fun BadgeDrawable.updateQueueSize(queueSize: Int) {
number = queueSize
}
fun downloadStatusChanged(downloading: Boolean) { fun downloadStatusChanged(downloading: Boolean) {
lifecycleScope.launchUI { lifecycleScope.launchUI {
val hasQueue = downloading || downloadManager.hasQueue() val hasQueue = downloading || downloadManager.hasQueue()
if (hasQueue) { if (hasQueue) {
val badge = nav.getOrCreateBadge(R.id.nav_recents) val badge = nav.getOrCreateBadge(R.id.nav_recents)
badge.number = downloadManager.queue.size badge.updateQueueSize(downloadManager.queueState.value.size)
if (downloading) badge.backgroundColor = -870219 else badge.backgroundColor = Color.GRAY if (downloading) badge.backgroundColor = -870219 else badge.backgroundColor = Color.GRAY
showDLQueueTutorial() showDLQueueTutorial()
} else { } else {

View file

@ -792,7 +792,6 @@ class MangaDetailsController :
binding.swipeRefresh.isRefreshing = enabled binding.swipeRefresh.isRefreshing = enabled
} }
//region Recycler methods
fun updateChapterDownload(download: Download) { fun updateChapterDownload(download: Download) {
getHolder(download.chapter)?.notifyStatus( getHolder(download.chapter)?.notifyStatus(
download.status, download.status,
@ -1802,7 +1801,7 @@ class MangaDetailsController :
override fun onDestroyActionMode(mode: ActionMode?) { override fun onDestroyActionMode(mode: ActionMode?) {
actionMode = null actionMode = null
setStatusBarAndToolbar() setStatusBarAndToolbar()
if (startingRangeChapterPos != null && rangeMode == RangeMode.Download) { if (startingRangeChapterPos != null && rangeMode in setOf(RangeMode.Download, RangeMode.RemoveDownload)) {
val item = adapter?.getItem(startingRangeChapterPos!!) as? ChapterItem val item = adapter?.getItem(startingRangeChapterPos!!) as? ChapterItem
(binding.recycler.findViewHolderForAdapterPosition(startingRangeChapterPos!!) as? ChapterHolder)?.notifyStatus( (binding.recycler.findViewHolderForAdapterPosition(startingRangeChapterPos!!) as? ChapterHolder)?.notifyStatus(
item?.status ?: Download.State.NOT_DOWNLOADED, item?.status ?: Download.State.NOT_DOWNLOADED,

View file

@ -60,6 +60,7 @@ import eu.kanade.tachiyomi.util.manga.MangaUtil
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.e
import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNonCancellableIO import eu.kanade.tachiyomi.util.system.launchNonCancellableIO
import eu.kanade.tachiyomi.util.system.launchNow import eu.kanade.tachiyomi.util.system.launchNow
@ -72,8 +73,11 @@ import java.io.FileOutputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -108,7 +112,8 @@ class MangaDetailsPresenter(
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val chapterFilter: ChapterFilter = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(),
private val storageManager: StorageManager = Injekt.get(), private val storageManager: StorageManager = Injekt.get(),
) : BaseCoroutinePresenter<MangaDetailsController>(), DownloadQueue.DownloadListener { ) : BaseCoroutinePresenter<MangaDetailsController>(),
DownloadQueue.Listener {
private val getAvailableScanlators: GetAvailableScanlators by injectLazy() private val getAvailableScanlators: GetAvailableScanlators by injectLazy()
private val getCategories: GetCategories by injectLazy() private val getCategories: GetCategories by injectLazy()
private val getChapter: GetChapter by injectLazy() private val getChapter: GetChapter by injectLazy()
@ -174,6 +179,9 @@ class MangaDetailsPresenter(
var allChapterScanlators: Set<String> = emptySet() var allChapterScanlators: Set<String> = emptySet()
override val progressJobs: MutableMap<Download, Job> = mutableMapOf()
override val queueListenerScope get() = presenterScope
override fun onCreate() { override fun onCreate() {
val controller = view ?: return val controller = view ?: return
@ -181,10 +189,24 @@ class MangaDetailsPresenter(
if (!::manga.isInitialized) runBlocking { refreshMangaFromDb() } if (!::manga.isInitialized) runBlocking { refreshMangaFromDb() }
syncData() syncData()
downloadManager.addListener(this) presenterScope.launchUI {
downloadManager.statusFlow()
.filter { it.manga.id == mangaId }
.catch { error -> Logger.e(error) }
.collect(::onStatusChange)
}
presenterScope.launchUI {
downloadManager.progressFlow()
.filter { it.manga.id == mangaId }
.catch { error -> Logger.e(error) }
.collect(::onQueueUpdate)
}
presenterScope.launchIO {
downloadManager.queueState.collectLatest(::onQueueUpdate)
}
runBlocking { runBlocking {
tracks = getTrack.awaitAllByMangaId(manga.id!!) tracks = getTrack.awaitAllByMangaId(mangaId)
} }
} }
@ -219,11 +241,6 @@ class MangaDetailsPresenter(
refreshTracking(false) refreshTracking(false)
} }
override fun onDestroy() {
super.onDestroy()
downloadManager.removeListener(this)
}
fun fetchChapters(andTracking: Boolean = true) { fun fetchChapters(andTracking: Boolean = true) {
presenterScope.launch { presenterScope.launch {
getChapters() getChapters()
@ -252,12 +269,12 @@ class MangaDetailsPresenter(
return chapters return chapters
} }
private suspend fun getChapters() { private suspend fun getChapters(queue: List<Download> = downloadManager.queueState.value) {
val chapters = getChapter.awaitAll(mangaId, isScanlatorFiltered()).map { it.toModel() } val chapters = getChapter.awaitAll(mangaId, isScanlatorFiltered()).map { it.toModel() }
allChapters = if (!isScanlatorFiltered()) chapters else getChapter.awaitAll(mangaId, false).map { it.toModel() } allChapters = if (!isScanlatorFiltered()) chapters else getChapter.awaitAll(mangaId, false).map { it.toModel() }
// Find downloaded chapters // Find downloaded chapters
setDownloadedChapters(chapters) setDownloadedChapters(chapters, queue)
allChapterScanlators = allChapters.mapNotNull { it.chapter.scanlator }.toSet() allChapterScanlators = allChapters.mapNotNull { it.chapter.scanlator }.toSet()
this.chapters = applyChapterFilters(chapters) this.chapters = applyChapterFilters(chapters)
@ -274,33 +291,17 @@ class MangaDetailsPresenter(
* *
* @param chapters the list of chapter from the database. * @param chapters the list of chapter from the database.
*/ */
private fun setDownloadedChapters(chapters: List<ChapterItem>) { private fun setDownloadedChapters(chapters: List<ChapterItem>, queue: List<Download>) {
for (chapter in chapters) { for (chapter in chapters) {
if (downloadManager.isChapterDownloaded(chapter, manga)) { if (downloadManager.isChapterDownloaded(chapter, manga)) {
chapter.status = Download.State.DOWNLOADED chapter.status = Download.State.DOWNLOADED
} else if (downloadManager.hasQueue()) { } else if (queue.isNotEmpty()) {
chapter.status = downloadManager.queue.find { it.chapter.id == chapter.id } chapter.status = queue.find { it.chapter.id == chapter.id }
?.status ?: Download.State.default ?.status ?: Download.State.default
} }
} }
} }
override fun updateDownload(download: Download) {
chapters.find { it.id == download.chapter.id }?.download = download
presenterScope.launchUI {
view?.updateChapterDownload(download)
}
}
override fun updateDownloads() {
presenterScope.launch(Dispatchers.Default) {
getChapters()
withContext(Dispatchers.Main) {
view?.updateChapters(chapters)
}
}
}
/** /**
* Converts a chapter from the database to an extended model, allowing to store new fields. * Converts a chapter from the database to an extended model, allowing to store new fields.
*/ */
@ -310,7 +311,7 @@ class MangaDetailsPresenter(
model.isLocked = isLockedFromSearch model.isLocked = isLockedFromSearch
// Find an active download for this chapter. // Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == id } val download = downloadManager.queueState.value.find { it.chapter.id == id }
if (download != null) { if (download != null) {
// If there's an active download, assign it. // If there's an active download, assign it.
@ -387,14 +388,15 @@ class MangaDetailsPresenter(
* @param chapter the chapter to delete. * @param chapter the chapter to delete.
*/ */
fun deleteChapter(chapter: ChapterItem) { fun deleteChapter(chapter: ChapterItem) {
downloadManager.deleteChapters(listOf(chapter), manga, source, true)
this.chapters.find { it.id == chapter.id }?.apply { this.chapters.find { it.id == chapter.id }?.apply {
if (chapter.chapter.bookmark && !preferences.removeBookmarkedChapters().get()) return@apply if (chapter.chapter.bookmark && !preferences.removeBookmarkedChapters().get()) return@apply
status = Download.State.QUEUE status = Download.State.NOT_DOWNLOADED
download = null download = null
} }
view?.updateChapters(this.chapters) view?.updateChapters(this.chapters)
downloadManager.deleteChapters(listOf(chapter), manga, source, true)
} }
/** /**
@ -402,22 +404,21 @@ class MangaDetailsPresenter(
* @param chapters the list of chapters to delete. * @param chapters the list of chapters to delete.
*/ */
fun deleteChapters(chapters: List<ChapterItem>, update: Boolean = true, isEverything: Boolean = false) { fun deleteChapters(chapters: List<ChapterItem>, update: Boolean = true, isEverything: Boolean = false) {
launchIO {
if (isEverything) {
downloadManager.deleteManga(manga, source)
} else {
downloadManager.deleteChapters(chapters, manga, source)
}
}
chapters.forEach { chapter -> chapters.forEach { chapter ->
this.chapters.find { it.id == chapter.id }?.apply { this.chapters.find { it.id == chapter.id }?.apply {
if (chapter.chapter.bookmark && !preferences.removeBookmarkedChapters().get() && !isEverything) return@apply if (chapter.chapter.bookmark && !preferences.removeBookmarkedChapters().get() && !isEverything) return@apply
status = Download.State.QUEUE status = Download.State.NOT_DOWNLOADED
download = null download = null
} }
} }
if (update) view?.updateChapters(this.chapters) if (update) view?.updateChapters(this.chapters)
if (isEverything) {
downloadManager.deleteManga(manga, source)
} else {
downloadManager.deleteChapters(chapters, manga, source)
}
} }
suspend fun refreshMangaFromDb(): Manga { suspend fun refreshMangaFromDb(): Manga {
@ -1150,6 +1151,32 @@ class MangaDetailsPresenter(
return if (date <= 0L) null else date return if (date <= 0L) null else date
} }
override fun onStatusChange(download: Download) {
super.onStatusChange(download)
chapters.find { it.id == download.chapter.id }?.status = download.status
onPageProgressUpdate(download)
}
private suspend fun onQueueUpdate(queue: List<Download>) = withIOContext {
getChapters(queue)
withUIContext {
view?.updateChapters(chapters)
}
}
override fun onQueueUpdate(download: Download) {
// already handled by onStatusChange
}
override fun onProgressUpdate(download: Download) {
// already handled by onStatusChange
}
override fun onPageProgressUpdate(download: Download) {
chapters.find { it.id == download.chapter.id }?.download = download
view?.updateChapterDownload(download)
}
companion object { companion object {
const val MULTIPLE_VOLUMES = 1 const val MULTIPLE_VOLUMES = 1
const val TENS_OF_CHAPTERS = 2 const val TENS_OF_CHAPTERS = 2

View file

@ -629,13 +629,8 @@ class RecentsController(bundle: Bundle? = null) :
} }
} }
fun updateChapterDownload(download: Download, updateDLSheet: Boolean = true) { fun updateChapterDownload(download: Download) {
if (view == null) return if (view == null || !this::adapter.isInitialized) return
if (updateDLSheet) {
binding.downloadBottomSheet.dlBottomSheet.update(!presenter.downloadManager.isPaused())
binding.downloadBottomSheet.dlBottomSheet.onUpdateProgress(download)
binding.downloadBottomSheet.dlBottomSheet.onUpdateDownloadedPages(download)
}
val id = download.chapter.id ?: return val id = download.chapter.id ?: return
val item = adapter.getItemByChapterId(id) ?: return val item = adapter.getItemByChapterId(id) ?: return
val holder = binding.recycler.findViewHolderForItemId(item.id!!) as? RecentMangaHolder ?: return val holder = binding.recycler.findViewHolderForItemId(item.id!!) as? RecentMangaHolder ?: return

View file

@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.data.database.models.ChapterHistory
import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.HistoryImpl import eu.kanade.tachiyomi.data.database.models.HistoryImpl
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.data.download.DownloadJob
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.download.model.DownloadQueue
@ -31,6 +30,7 @@ import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -55,7 +55,8 @@ class RecentsPresenter(
val preferences: PreferencesHelper = Injekt.get(), val preferences: PreferencesHelper = Injekt.get(),
val downloadManager: DownloadManager = Injekt.get(), val downloadManager: DownloadManager = Injekt.get(),
private val chapterFilter: ChapterFilter = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(),
) : BaseCoroutinePresenter<RecentsController>(), DownloadQueue.DownloadListener { ) : BaseCoroutinePresenter<RecentsController>(),
DownloadQueue.Listener {
private val handler: DatabaseHandler by injectLazy() private val handler: DatabaseHandler by injectLazy()
private val getChapter: GetChapter by injectLazy() private val getChapter: GetChapter by injectLazy()
@ -99,10 +100,27 @@ class RecentsPresenter(
private val isOnFirstPage: Boolean private val isOnFirstPage: Boolean
get() = pageOffset == 0 get() = pageOffset == 0
override val progressJobs = mutableMapOf<Download, Job>()
override val queueListenerScope get() = presenterScope
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
downloadManager.addListener(this) presenterScope.launchUI {
DownloadJob.downloadFlow.onEach(::downloadStatusChanged).launchIn(presenterScope) downloadManager.statusFlow().collect(::onStatusChange)
}
presenterScope.launchUI {
downloadManager.progressFlow().collect(::onProgressUpdate)
}
presenterScope.launchIO {
downloadManager.queueState.collectLatest {
setDownloadedChapters(recentItems, it)
withUIContext {
view?.showLists(recentItems, true)
view?.updateDownloadStatus(!downloadManager.isPaused())
}
}
}
downloadManager.isDownloaderRunning.onEach(::downloadStatusChanged).launchIn(presenterScope)
LibraryUpdateJob.updateFlow.onEach(::onUpdateManga).launchIn(presenterScope) LibraryUpdateJob.updateFlow.onEach(::onUpdateManga).launchIn(presenterScope)
if (lastRecents != null) { if (lastRecents != null) {
if (recentItems.isEmpty()) { if (recentItems.isEmpty()) {
@ -482,7 +500,6 @@ class RecentsPresenter(
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
downloadManager.removeListener(this)
lastRecents = recentItems lastRecents = recentItems
} }
@ -500,12 +517,12 @@ class RecentsPresenter(
* *
* @param chapters the list of chapter from the database. * @param chapters the list of chapter from the database.
*/ */
private fun setDownloadedChapters(chapters: List<RecentMangaItem>) { private fun setDownloadedChapters(chapters: List<RecentMangaItem>, queue: List<Download> = downloadManager.queueState.value) {
for (item in chapters.filter { it.chapter.id != null }) { for (item in chapters.filter { it.chapter.id != null }) {
if (downloadManager.isChapterDownloaded(item.chapter, item.mch.manga)) { if (downloadManager.isChapterDownloaded(item.chapter, item.mch.manga)) {
item.status = Download.State.DOWNLOADED item.status = Download.State.DOWNLOADED
} else if (downloadManager.hasQueue()) { } else if (queue.isNotEmpty()) {
item.download = downloadManager.queue.find { it.chapter.id == item.chapter.id } item.download = queue.find { it.chapter.id == item.chapter.id }
item.status = item.download?.status ?: Download.State.default item.status = item.download?.status ?: Download.State.default
} }
@ -514,8 +531,8 @@ class RecentsPresenter(
downloadInfo.chapterId = chapter.id downloadInfo.chapterId = chapter.id
if (downloadManager.isChapterDownloaded(chapter, item.mch.manga)) { if (downloadManager.isChapterDownloaded(chapter, item.mch.manga)) {
downloadInfo.status = Download.State.DOWNLOADED downloadInfo.status = Download.State.DOWNLOADED
} else if (downloadManager.hasQueue()) { } else if (queue.isNotEmpty()) {
downloadInfo.download = downloadManager.queue.find { it.chapter.id == chapter.id } downloadInfo.download = queue.find { it.chapter.id == chapter.id }
downloadInfo.status = downloadInfo.download?.status ?: Download.State.default downloadInfo.status = downloadInfo.download?.status ?: Download.State.default
} }
downloadInfo downloadInfo
@ -523,32 +540,6 @@ class RecentsPresenter(
} }
} }
override fun updateDownload(download: Download) {
recentItems.find {
download.chapter.id == it.chapter.id ||
download.chapter.id in it.mch.extraChapters.map { ch -> ch.id }
}?.apply {
if (chapter.id != download.chapter.id) {
val downloadInfo = downloadInfo.find { it.chapterId == download.chapter.id }
?: return@apply
downloadInfo.download = download
} else {
this.download = download
}
}
presenterScope.launchUI { view?.updateChapterDownload(download) }
}
override fun updateDownloads() {
presenterScope.launch {
setDownloadedChapters(recentItems)
withContext(Dispatchers.Main) {
view?.showLists(recentItems, true)
view?.updateDownloadStatus(!downloadManager.isPaused())
}
}
}
private fun downloadStatusChanged(downloading: Boolean) { private fun downloadStatusChanged(downloading: Boolean) {
presenterScope.launchUI { presenterScope.launchUI {
view?.updateDownloadStatus(downloading) view?.updateDownloadStatus(downloading)
@ -704,6 +695,18 @@ class RecentsPresenter(
} }
} }
override fun onProgressUpdate(download: Download) {
// don't do anything
}
override fun onQueueUpdate(download: Download) {
view?.updateChapterDownload(download)
}
override fun onPageProgressUpdate(download: Download) {
view?.updateChapterDownload(download)
}
enum class GroupType { enum class GroupType {
BySeries, BySeries,
ByWeek, ByWeek,