From 684b494edbd5d56c0a8a1aa02f93cd79dd99814e Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 29 Apr 2022 10:07:04 +0300 Subject: [PATCH] Fix concurrent manga downloading #154 --- .../download/domain/DownloadManager.kt | 27 ++++---- .../local/domain/LocalMangaRepository.kt | 27 ++++++-- .../koitharu/kotatsu/utils/CompositeMutex.kt | 66 +++++++++++++++++++ .../utils/progress/TimeLeftEstimator.kt | 5 +- 4 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index b8183a96b..58335ed31 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.download.domain import android.content.Context -import android.graphics.drawable.Drawable import android.net.ConnectivityManager import android.webkit.MimeTypeMap import coil.ImageLoader @@ -75,10 +74,12 @@ class DownloadManager( ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { @Suppress("NAME_SHADOWING") var manga = manga val chaptersIdsSet = chaptersIds?.toMutableSet() + val cover = loadCover(manga) + outState.value = DownloadState.Queued(startId, manga, cover) + localMangaRepository.lockManga(manga.id) semaphore.acquire() coroutineContext[WakeLockNode]?.acquire() outState.value = DownloadState.Preparing(startId, manga, null) - var cover: Drawable? = null val destination = localMangaRepository.getOutputDir() checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } val tempFileName = "${manga.id}_$startId.tmp" @@ -88,16 +89,6 @@ class DownloadManager( manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") } val repo = MangaRepository(manga.source) - cover = runCatching { - imageLoader.execute( - ImageRequest.Builder(context) - .data(manga.coverUrl) - .referer(manga.publicUrl) - .size(coverWidth, coverHeight) - .scale(Scale.FILL) - .build() - ).drawable - }.getOrNull() outState.value = DownloadState.Preparing(startId, manga, cover) val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga output = CbzMangaOutput.get(destination, data) @@ -176,6 +167,7 @@ class DownloadManager( } coroutineContext[WakeLockNode]?.release() semaphore.release() + localMangaRepository.unlockManga(manga.id) } } @@ -208,6 +200,17 @@ class DownloadManager( ) } + private suspend fun loadCover(manga: Manga) = runCatching { + imageLoader.execute( + ImageRequest.Builder(context) + .data(manga.coverUrl) + .referer(manga.publicUrl) + .size(coverWidth, coverHeight) + .scale(Scale.FILL) + .build() + ).drawable + }.getOrNull() + class Factory( private val context: Context, private val imageLoader: ImageLoader, diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 43860e451..e034d0672 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -18,6 +18,7 @@ import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.utils.AlphanumComparator +import org.koitharu.kotatsu.utils.CompositeMutex import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.resolveName @@ -34,6 +35,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma override val source = MangaSource.LOCAL private val filenameFilter = CbzFilter() + private val locks = CompositeMutex() override suspend fun getList( offset: Int, @@ -112,11 +114,18 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma return file.deleteAwait() } - suspend fun deleteChapters(manga: Manga, ids: Set) = runInterruptible(Dispatchers.IO) { - val uri = Uri.parse(manga.url) - val file = uri.toFile() - val cbz = CbzMangaOutput(file, manga) - CbzMangaOutput.filterChapters(cbz, ids) + suspend fun deleteChapters(manga: Manga, ids: Set) { + lockManga(manga.id) + try { + runInterruptible(Dispatchers.IO) { + val uri = Uri.parse(manga.url) + val file = uri.toFile() + val cbz = CbzMangaOutput(file, manga) + CbzMangaOutput.filterChapters(cbz, ids) + } + } finally { + unlockManga(manga.id) + } } @WorkerThread @@ -278,6 +287,14 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma } } + suspend fun lockManga(id: Long) { + locks.lock(id) + } + + suspend fun unlockManga(id: Long) { + locks.unlock(id) + } + private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir -> dir.listFiles(filenameFilter)?.toList().orEmpty() } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt new file mode 100644 index 000000000..e66355588 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt @@ -0,0 +1,66 @@ +package org.koitharu.kotatsu.utils + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.* +import kotlin.coroutines.resume + +class CompositeMutex : Set { + + private val data = HashMap>>() + private val mutex = Mutex() + + override val size: Int + get() = data.size + + override fun contains(element: T): Boolean { + return data.containsKey(element) + } + + override fun containsAll(elements: Collection): Boolean { + return elements.all { x -> data.containsKey(x) } + } + + override fun isEmpty(): Boolean { + return data.isEmpty() + } + + override fun iterator(): Iterator { + return data.keys.iterator() + } + + suspend fun lock(element: T) { + waitForRemoval(element) + mutex.withLock { + val lastValue = data.put(element, LinkedList>()) + check(lastValue == null) { + "CompositeMutex is double-locked for $element" + } + } + } + + suspend fun unlock(element: T) { + val continuations = mutex.withLock { + checkNotNull(data.remove(element)) { + "CompositeMutex is not locked for $element" + } + } + continuations.forEach { c -> + if (c.isActive) { + c.resume(Unit) + } + } + } + + private suspend fun waitForRemoval(element: T) { + val list = data[element] ?: return + suspendCancellableCoroutine { continuation -> + list.add(continuation) + continuation.invokeOnCancellation { + list.remove(continuation) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt index 5cb7aafc5..f998a5119 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.utils.progress import android.os.SystemClock +import java.util.concurrent.TimeUnit import kotlin.math.roundToInt import kotlin.math.roundToLong @@ -11,6 +12,7 @@ class TimeLeftEstimator { private var times = ArrayList() private var lastTick: Tick? = null + private val tooLargeTime = TimeUnit.DAYS.toMillis(1) fun tick(value: Int, total: Int) { if (total < 0) { @@ -36,7 +38,8 @@ class TimeLeftEstimator { } val timePerTick = times.average() val ticksLeft = progress.total - progress.value - return (ticksLeft * timePerTick).roundToLong() + val eta = (ticksLeft * timePerTick).roundToLong() + return if (eta < tooLargeTime) eta else NO_TIME } private class Tick(