From 85da41be9ab2efbc917f7f725004e8402e9968cb Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 24 Aug 2024 10:10:31 +0300 Subject: [PATCH] Downloading improvements --- app/build.gradle | 7 +- .../koitharu/kotatsu/core/util/MultiMutex.kt | 23 ++-- .../koitharu/kotatsu/core/util/ext/Date.kt | 17 +++ .../koitharu/kotatsu/core/util/ext/Flow.kt | 17 +++ .../kotatsu/core/util/ext/Throwable.kt | 46 ++++++-- .../util/progress/RealtimeEtaEstimator.kt | 94 +++++++++++++++ .../core/util/progress/TimeLeftEstimator.kt | 69 ----------- .../download/domain/DownloadProgress.kt | 8 ++ .../kotatsu/download/domain/DownloadState.kt | 10 +- .../download/ui/list/DownloadItemAD.kt | 15 ++- .../download/ui/list/DownloadItemListener.kt | 6 +- .../download/ui/list/DownloadItemModel.kt | 20 ++++ .../download/ui/list/DownloadsActivity.kt | 26 +++-- .../download/ui/list/DownloadsViewModel.kt | 5 +- .../ui/worker/DownloadNotificationFactory.kt | 58 +++++++--- .../ui/worker/DownloadStartedObserver.kt | 3 +- .../download/ui/worker/DownloadWorker.kt | 107 +++++++++++------- .../download/ui/worker/PausingHandle.kt | 23 +++- .../download/ui/worker/PausingReceiver.kt | 42 ++++--- .../kotatsu/explore/ui/ExploreFragment.kt | 3 +- .../favourites/data/FavouriteCategoriesDao.kt | 2 + .../kotatsu/favourites/data/FavouritesDao.kt | 4 - .../favourites/domain/FavouritesRepository.kt | 5 - .../local/data/LocalMangaRepository.kt | 33 ++---- .../kotatsu/local/domain/MangaLock.kt | 9 ++ app/src/main/res/drawable/ic_retry.xml | 17 +++ app/src/main/res/layout/item_download.xml | 43 ++++--- app/src/main/res/layout/item_track_debug.xml | 15 +-- app/src/main/res/values/strings.xml | 8 ++ 29 files changed, 486 insertions(+), 249 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/RealtimeEtaEstimator.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/TimeLeftEstimator.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadProgress.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/local/domain/MangaLock.kt create mode 100644 app/src/main/res/drawable/ic_retry.xml diff --git a/app/build.gradle b/app/build.gradle index b00fe5abb..3cc7cb0ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 662 - versionName = '7.5-a2' + versionCode = 663 + versionName = '7.5-a3' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -56,6 +56,7 @@ android { freeCompilerArgs += [ '-opt-in=kotlin.ExperimentalStdlibApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', + '-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi', '-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=coil.annotation.ExperimentalCoilApi', @@ -82,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:f91ff0b9d0') { + implementation('com.github.KotatsuApp:kotatsu-parsers:939b6b1e46') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt index 6c0b15b9f..ce69060d5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.sync.Mutex import kotlin.contracts.InvocationKind import kotlin.contracts.contract -class MultiMutex : Set { +open class MultiMutex : Set { private val delegates = ArrayMap() @@ -20,19 +20,26 @@ class MultiMutex : Set { elements.all { x -> delegates.containsKey(x) } } - override fun isEmpty(): Boolean { - return delegates.isEmpty() + override fun isEmpty(): Boolean = delegates.isEmpty() + + override fun iterator(): Iterator = synchronized(delegates) { + delegates.keys.toList() + }.iterator() + + fun isLocked(element: T): Boolean = synchronized(delegates) { + delegates[element]?.isLocked == true } - override fun iterator(): Iterator { - return delegates.keys.iterator() + fun tryLock(element: T): Boolean { + val mutex = synchronized(delegates) { + delegates.getOrPut(element, ::Mutex) + } + return mutex.tryLock() } suspend fun lock(element: T) { val mutex = synchronized(delegates) { - delegates.getOrPut(element) { - Mutex() - } + delegates.getOrPut(element, ::Mutex) } mutex.lock() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt index 1cd3e4994..e854483d2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt @@ -1,11 +1,14 @@ package org.koitharu.kotatsu.core.util.ext +import android.content.res.Resources +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo { // TODO: Use Java 9's LocalDate.ofInstant(). @@ -33,3 +36,17 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo } fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this) + +fun Resources.formatDurationShort(millis: Long): String? { + val hours = TimeUnit.MILLISECONDS.toHours(millis).toInt() + val minutes = (TimeUnit.MILLISECONDS.toMinutes(millis) % 60).toInt() + val seconds = (TimeUnit.MILLISECONDS.toSeconds(millis) % 60).toInt() + return when { + hours == 0 && minutes == 0 && seconds == 0 -> null + hours != 0 && minutes != 0 -> getString(R.string.hours_minutes_short, hours, minutes) + hours != 0 -> getString(R.string.hours_short, hours) + minutes != 0 && seconds != 0 -> getString(R.string.minutes_seconds_short, minutes, seconds) + minutes != 0 -> getString(R.string.minutes_short, minutes) + else -> getString(R.string.seconds_short, seconds) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt index 958dba4f9..4e3d38a55 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt @@ -4,6 +4,8 @@ import android.os.SystemClock import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull @@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import org.koitharu.kotatsu.R +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger fun Flow.onFirst(action: suspend (T) -> Unit): Flow { @@ -87,6 +90,20 @@ fun Flow.zipWithPrevious(): Flow> = flow { } } +fun tickerFlow(interval: Long, timeUnit: TimeUnit): Flow = flow { + while (true) { + emit(SystemClock.elapsedRealtime()) + delay(timeUnit.toMillis(interval)) + } +} + +fun Flow.withTicker(interval: Long, timeUnit: TimeUnit) = channelFlow { + onCompletion { cause -> + close(cause) + }.combine(tickerFlow(interval, timeUnit)) { x, _ -> x } + .collectLatest { send(it) } +} + @Suppress("UNCHECKED_CAST") fun combine( flow: Flow, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index 5b8e5b23e..13b3b7cdb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.util.ext import android.content.ActivityNotFoundException import android.content.res.Resources import androidx.annotation.DrawableRes -import androidx.collection.arraySetOf import coil.network.HttpException import okio.FileNotFoundException import okio.IOException @@ -23,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED @@ -47,7 +47,20 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { is UnsupportedOperationException, -> resources.getString(R.string.operation_not_supported) - is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message) + is TooManyRequestExceptions -> { + val delay = getRetryDelay() + val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) { + resources.formatDurationShort(delay) + } else { + null + } + if (formattedTime != null) { + resources.getString(R.string.too_many_requests_message_retry, formattedTime) + } else { + resources.getString(R.string.too_many_requests_message) + } + } + is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) is FileNotFoundException -> resources.getString(R.string.file_not_found) @@ -107,7 +120,25 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe } fun Throwable.isReportable(): Boolean { - return this is Error || this.javaClass in reportableExceptions + if (this is Error) { + return true + } + if (this is CaughtException) { + return cause?.isReportable() == true + } + if (ExceptionResolver.canResolve(this)) { + return false + } + if (this is ParseException + || this.isNetworkError() + || this is CloudFlareBlockedException + || this is CloudFlareProtectedException + || this is BadBackupFormatException + || this is WrongPasswordException + ) { + return false + } + return true } fun Throwable.isNetworkError(): Boolean { @@ -119,15 +150,6 @@ fun Throwable.report() { exception.sendWithAcra() } -private val reportableExceptions = arraySetOf>( - RuntimeException::class.java, - IllegalStateException::class.java, - IllegalArgumentException::class.java, - ConcurrentModificationException::class.java, - UnsupportedOperationException::class.java, - NoDataReceivedException::class.java, -) - fun Throwable.isWebViewUnavailable(): Boolean { val trace = stackTraceToString() return trace.contains("android.webkit.WebView.") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/RealtimeEtaEstimator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/RealtimeEtaEstimator.kt new file mode 100644 index 000000000..198caa857 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/RealtimeEtaEstimator.kt @@ -0,0 +1,94 @@ +package org.koitharu.kotatsu.core.util.progress + +import android.os.SystemClock +import androidx.annotation.AnyThread +import androidx.collection.CircularArray +import java.util.concurrent.TimeUnit +import kotlin.math.roundToLong + +class RealtimeEtaEstimator { + + private val ticks = CircularArray(MAX_TICKS) + + @Volatile + private var lastChange = 0L + + @AnyThread + fun onProgressChanged(value: Int, total: Int) { + if (total <= 0 || value > total) { + reset() + return + } + val tick = Tick(value, total, SystemClock.elapsedRealtime()) + synchronized(this) { + if (!ticks.isEmpty()) { + val last = ticks.last + if (last.value == tick.value && last.total == tick.total) { + ticks.popLast() + } else { + lastChange = tick.timestamp + } + } else { + lastChange = tick.timestamp + } + ticks.addLast(tick) + } + } + + @AnyThread + fun reset() = synchronized(this) { + ticks.clear() + lastChange = 0L + } + + @AnyThread + fun getEta(): Long { + val etl = getEstimatedTimeLeft() + return if (etl == NO_TIME || etl > MAX_TIME) NO_TIME else System.currentTimeMillis() + etl + } + + @AnyThread + fun isStuck(): Boolean = synchronized(this) { + return ticks.size() >= MIN_ESTIMATE_TICKS && (SystemClock.elapsedRealtime() - lastChange) > STUCK_DELAY + } + + private fun getEstimatedTimeLeft(): Long = synchronized(this) { + val ticksCount = ticks.size() + if (ticksCount < MIN_ESTIMATE_TICKS) { + return NO_TIME + } + val percentDiff = ticks.last.percent - ticks.first.percent + val timeDiff = ticks.last.timestamp - ticks.first.timestamp + if (percentDiff <= 0 || timeDiff <= 0) { + return NO_TIME + } + val averageTime = timeDiff / percentDiff + val percentLeft = 1.0 - ticks.last.percent + return (percentLeft * averageTime).roundToLong() + } + + private class Tick( + @JvmField val value: Int, + @JvmField val total: Int, + @JvmField val timestamp: Long, + ) { + + init { + require(total > 0) { "total = $total" } + require(value >= 0) { "value = $value" } + require(value <= total) { "total = $total, value = $value" } + } + + @JvmField + val percent = value.toDouble() / total.toDouble() + } + + private companion object { + + const val MAX_TICKS = 20 + const val MIN_ESTIMATE_TICKS = 4 + const val NO_TIME = -1L + const val STUCK_DELAY = 10_000L + val MAX_TIME = TimeUnit.DAYS.toMillis(1) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/TimeLeftEstimator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/TimeLeftEstimator.kt deleted file mode 100644 index 2aa9ef8a9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/TimeLeftEstimator.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.koitharu.kotatsu.core.util.progress - -import android.os.SystemClock -import androidx.collection.IntList -import androidx.collection.MutableIntList -import java.util.concurrent.TimeUnit -import kotlin.math.roundToInt -import kotlin.math.roundToLong - -private const val MIN_ESTIMATE_TICKS = 4 -private const val NO_TIME = -1L - -class TimeLeftEstimator { - - private var times = MutableIntList() - private var lastTick: Tick? = null - private val tooLargeTime = TimeUnit.DAYS.toMillis(1) - - fun tick(value: Int, total: Int) { - if (total < 0) { - emptyTick() - return - } - if (lastTick?.value == value) { - return - } - val tick = Tick(value, total, SystemClock.elapsedRealtime()) - lastTick?.let { - val ticksCount = value - it.value - times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt()) - } - lastTick = tick - } - - fun emptyTick() { - lastTick = null - } - - fun getEstimatedTimeLeft(): Long { - val progress = lastTick ?: return NO_TIME - if (times.size < MIN_ESTIMATE_TICKS) { - return NO_TIME - } - val timePerTick = times.average() - val ticksLeft = progress.total - progress.value - val eta = (ticksLeft * timePerTick).roundToLong() - return if (eta < tooLargeTime) eta else NO_TIME - } - - fun getEta(): Long { - val etl = getEstimatedTimeLeft() - return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl - } - - private fun IntList.average(): Double { - if (isEmpty()) { - return 0.0 - } - var acc = 0L - forEach { acc += it } - return acc / size.toDouble() - } - - private class Tick( - @JvmField val value: Int, - @JvmField val total: Int, - @JvmField val time: Long, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadProgress.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadProgress.kt new file mode 100644 index 000000000..7d84e6065 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadProgress.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.download.domain + +data class DownloadProgress( + val totalChapters: Int, + val currentChapter: Int, + val totalPages: Int, + val currentPage: Int, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt index ead3cf486..46bcd7ecd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt @@ -11,12 +11,14 @@ data class DownloadState( val isIndeterminate: Boolean, val isPaused: Boolean = false, val isStopped: Boolean = false, - val error: String? = null, + val error: Throwable? = null, + val errorMessage: String? = null, val totalChapters: Int = 0, val currentChapter: Int = 0, val totalPages: Int = 0, val currentPage: Int = 0, val eta: Long = -1L, + val isStuck: Boolean = false, val localManga: LocalManga? = null, val downloadedChapters: Int = 0, val timestamp: Long = System.currentTimeMillis(), @@ -39,8 +41,9 @@ data class DownloadState( .putInt(DATA_MAX, max) .putInt(DATA_PROGRESS, progress) .putLong(DATA_ETA, eta) + .putBoolean(DATA_STUCK, isStuck) .putLong(DATA_TIMESTAMP, timestamp) - .putString(DATA_ERROR, error) + .putString(DATA_ERROR, errorMessage) .putInt(DATA_CHAPTERS, downloadedChapters) .putBoolean(DATA_INDETERMINATE, isIndeterminate) .putBoolean(DATA_PAUSED, isPaused) @@ -53,6 +56,7 @@ data class DownloadState( private const val DATA_PROGRESS = "progress" private const val DATA_CHAPTERS = "chapter_cnt" private const val DATA_ETA = "eta" + private const val DATA_STUCK = "stuck" const val DATA_TIMESTAMP = "timestamp" private const val DATA_ERROR = "error" private const val DATA_INDETERMINATE = "indeterminate" @@ -72,6 +76,8 @@ data class DownloadState( fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L) + fun isStuck(data: Data): Boolean = data.getBoolean(DATA_STUCK, false) + fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L)) fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt index 6e913b492..274dd0a4d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -45,8 +45,9 @@ fun downloadItemAD( override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> listener.onCancelClick(item) - R.id.button_resume -> listener.onResumeClick(item, skip = false) - R.id.button_skip -> listener.onResumeClick(item, skip = true) + R.id.button_resume -> listener.onResumeClick(item) + R.id.button_skip -> listener.onSkipClick(item) + R.id.button_skip_all -> listener.onSkipAllClick(item) R.id.button_pause -> listener.onPauseClick(item) R.id.imageView_expand -> listener.onExpandClick(item) else -> listener.onItemClick(item, v) @@ -65,6 +66,7 @@ fun downloadItemAD( binding.buttonPause.setOnClickListener(clickListener) binding.buttonResume.setOnClickListener(clickListener) binding.buttonSkip.setOnClickListener(clickListener) + binding.buttonSkipAll.setOnClickListener(clickListener) binding.imageViewExpand.setOnClickListener(clickListener) itemView.setOnClickListener(clickListener) itemView.setOnLongClickListener(clickListener) @@ -136,9 +138,14 @@ fun downloadItemAD( binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) binding.textViewPercent.isVisible = true - binding.textViewDetails.textAndVisible = if (item.isPaused) item.error else item.getEtaString() + binding.textViewDetails.textAndVisible = when { + item.isPaused -> item.getErrorMessage(context) + item.isStuck -> context.getString(R.string.stuck) + else -> item.getEtaString() + } binding.buttonCancel.isVisible = true binding.buttonResume.isVisible = item.isPaused + binding.buttonResume.setText(if (item.error == null) R.string.resume else R.string.retry) binding.buttonSkip.isVisible = item.isPaused && item.error != null binding.buttonPause.isVisible = item.canPause } @@ -171,7 +178,7 @@ fun downloadItemAD( binding.progressBar.isVisible = false binding.progressBar.isEnabled = true binding.textViewPercent.isVisible = false - binding.textViewDetails.textAndVisible = item.error + binding.textViewDetails.textAndVisible = item.getErrorMessage(context) binding.buttonCancel.isVisible = false binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt index 449911419..f19ac1294 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt @@ -8,7 +8,11 @@ interface DownloadItemListener : OnListItemClickListener { fun onPauseClick(item: DownloadItemModel) - fun onResumeClick(item: DownloadItemModel, skip: Boolean) + fun onResumeClick(item: DownloadItemModel) + + fun onSkipClick(item: DownloadItemModel) + + fun onSkipAllClick(item: DownloadItemModel) fun onExpandClick(item: DownloadItemModel) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt index 2cd973695..e783abf92 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt @@ -1,15 +1,22 @@ package org.koitharu.kotatsu.download.ui.list +import android.content.Context +import android.graphics.Color import android.text.format.DateUtils +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.color import androidx.work.WorkInfo import coil.memory.MemoryCache import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import java.time.Instant import java.util.UUID +import com.google.android.material.R as materialR data class DownloadItemModel( val id: UUID, @@ -21,6 +28,7 @@ data class DownloadItemModel( val max: Int, val progress: Int, val eta: Long, + val isStuck: Boolean, val timestamp: Instant, val chaptersDownloaded: Int, val isExpanded: Boolean, @@ -51,6 +59,18 @@ data class DownloadItemModel( null } + fun getErrorMessage(context: Context): CharSequence? = if (error != null) { + buildSpannedString { + bold { + color(context.getThemeColor(materialR.attr.colorError, Color.RED)) { + append(error) + } + } + } + } else { + null + } + override fun compareTo(other: DownloadItemModel): Int { return timestamp.compareTo(other.timestamp) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index 91021b970..c9db51f41 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.download.ui.list -import android.content.Context -import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem @@ -22,7 +20,7 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.worker.PausingReceiver +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import javax.inject.Inject @@ -34,6 +32,9 @@ class DownloadsActivity : BaseActivity(), @Inject lateinit var coil: ImageLoader + @Inject + lateinit var scheduler: DownloadWorker.Scheduler + private val viewModel by viewModels() private lateinit var selectionController: ListSelectionController @@ -102,11 +103,19 @@ class DownloadsActivity : BaseActivity(), } override fun onPauseClick(item: DownloadItemModel) { - sendBroadcast(PausingReceiver.getPauseIntent(this, item.id)) + scheduler.pause(item.id) } - override fun onResumeClick(item: DownloadItemModel, skip: Boolean) { - sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip)) + override fun onResumeClick(item: DownloadItemModel) { + scheduler.resume(item.id) + } + + override fun onSkipClick(item: DownloadItemModel) { + scheduler.skip(item.id) + } + + override fun onSkipAllClick(item: DownloadItemModel) { + scheduler.skipAll(item.id) } override fun onSelectionChanged(controller: ListSelectionController, count: Int) { @@ -171,9 +180,4 @@ class DownloadsActivity : BaseActivity(), menu.findItem(R.id.action_remove)?.isVisible = canRemove return super.onPrepareActionMode(controller, mode, menu) } - - companion object { - - fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index 09c42f904..a58e152f3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -143,7 +143,7 @@ class DownloadsViewModel @Inject constructor( var isResumed = false for (work in snapshot) { if (work.workState == WorkInfo.State.RUNNING && work.isPaused) { - workScheduler.resume(work.id, skipError = false) + workScheduler.resume(work.id) isResumed = true } } @@ -156,7 +156,7 @@ class DownloadsViewModel @Inject constructor( val snapshot = works.value ?: return for (work in snapshot) { if (work.id.mostSignificantBits in ids) { - workScheduler.resume(work.id, skipError = false) + workScheduler.resume(work.id) } } onActionDone.call(ReversibleAction(R.string.downloads_resumed, null)) @@ -268,6 +268,7 @@ class DownloadsViewModel @Inject constructor( max = DownloadState.getMax(workData), progress = DownloadState.getProgress(workData), eta = DownloadState.getEta(workData), + isStuck = DownloadState.isStuck(workData), timestamp = DownloadState.getTimestamp(workData), chaptersDownloaded = DownloadState.getDownloadedChapters(workData), isExpanded = isExpanded, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index cd6c5b72d..6c0ebd85d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.download.ui.worker import android.app.Notification import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.graphics.drawable.Drawable import android.text.format.DateUtils import androidx.core.app.NotificationChannelCompat @@ -21,8 +22,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow +import org.koitharu.kotatsu.core.util.ext.isReportable import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.domain.DownloadState @@ -57,7 +60,7 @@ class DownloadNotificationFactory @AssistedInject constructor( private val queueIntent = PendingIntentCompat.getActivity( context, 0, - DownloadsActivity.newIntent(context), + Intent(context, DownloadsActivity::class.java), 0, false, ) @@ -82,7 +85,15 @@ class DownloadNotificationFactory @AssistedInject constructor( NotificationCompat.Action( R.drawable.ic_action_resume, context.getString(R.string.resume), - PausingReceiver.createResumePendingIntent(context, uuid, skipError = false), + PausingReceiver.createResumePendingIntent(context, uuid), + ) + } + + private val actionRetry by lazy { + NotificationCompat.Action( + R.drawable.ic_retry, + context.getString(R.string.retry), + actionResume.actionIntent, ) } @@ -90,7 +101,7 @@ class DownloadNotificationFactory @AssistedInject constructor( NotificationCompat.Action( R.drawable.ic_action_skip, context.getString(R.string.skip), - PausingReceiver.createResumePendingIntent(context, uuid, skipError = true), + PausingReceiver.createSkipPendingIntent(context, uuid), ) } @@ -160,8 +171,14 @@ class DownloadNotificationFactory @AssistedInject constructor( } else { null } - if (state.error != null) { - builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error)) + if (state.errorMessage != null) { + builder.setContentText( + context.getString( + R.string.download_summary_pattern, + percent, + state.errorMessage, + ), + ) } else { builder.setContentText(percent) } @@ -170,9 +187,11 @@ class DownloadNotificationFactory @AssistedInject constructor( builder.setOngoing(true) builder.setSmallIcon(R.drawable.ic_stat_paused) builder.addAction(actionCancel) - builder.addAction(actionResume) - if (state.error != null) { + if (state.errorMessage != null) { + builder.addAction(actionRetry) builder.addAction(actionSkip) + } else { + builder.addAction(actionResume) } } @@ -180,18 +199,27 @@ class DownloadNotificationFactory @AssistedInject constructor( builder.setProgress(0, 0, false) builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSubText(context.getString(R.string.error)) - builder.setContentText(state.error) + builder.setContentText(state.errorMessage) builder.setAutoCancel(true) builder.setOngoing(false) builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) - builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error)) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.errorMessage)) + if (state.error.isReportable()) { + builder.addAction( + NotificationCompat.Action( + 0, + context.getString(R.string.report), + ErrorReporterReceiver.getPendingIntent(context, state.error), + ), + ) + } } else -> { builder.setProgress(state.max, state.progress, false) - builder.setContentText(getProgressString(state.percent, state.eta)) + builder.setContentText(getProgressString(state.percent, state.eta, state.isStuck)) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) @@ -202,20 +230,20 @@ class DownloadNotificationFactory @AssistedInject constructor( return builder.build() } - private fun getProgressString(percent: Float, eta: Long): CharSequence? { + private fun getProgressString(percent: Float, eta: Long, isStuck: Boolean): CharSequence? { val percentString = if (percent >= 0f) { context.getString(R.string.percent_string_pattern, (percent * 100).format()) } else { null } - val etaString = if (eta > 0L) { - DateUtils.getRelativeTimeSpanString( + val etaString = when { + eta <= 0L -> null + isStuck -> context.getString(R.string.stuck) + else -> DateUtils.getRelativeTimeSpanString( eta, System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS, ) - } else { - null } return when { percentString == null && etaString == null -> null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt index 6c42b9275..7ec69616e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.download.ui.worker +import android.content.Intent import android.view.View import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.FlowCollector @@ -18,7 +19,7 @@ class DownloadStartedObserver( snackbar.anchorView = it.bottomNav } snackbar.setAction(R.string.details) { - it.context.startActivity(DownloadsActivity.newIntent(it.context)) + it.context.startActivity(Intent(it.context, DownloadsActivity::class.java)) } snackbar.show() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 703a7c1e7..f3fc3661a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -61,8 +62,10 @@ import org.koitharu.kotatsu.core.util.ext.getWorkInputData import org.koitharu.kotatsu.core.util.ext.getWorkSpec import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.withTicker import org.koitharu.kotatsu.core.util.ext.writeAllCancellable -import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator +import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator +import org.koitharu.kotatsu.download.domain.DownloadProgress import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges @@ -70,6 +73,7 @@ import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput +import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.model.Manga @@ -91,6 +95,7 @@ class DownloadWorker @AssistedInject constructor( @MangaHttpClient private val okHttp: OkHttpClient, private val cache: PagesCache, private val localMangaRepository: LocalMangaRepository, + private val mangaLock: MangaLock, private val mangaDataRepository: MangaDataRepository, private val mangaRepositoryFactory: MangaRepository.Factory, private val settings: AppSettings, @@ -108,7 +113,7 @@ class DownloadWorker @AssistedInject constructor( private val currentState: DownloadState get() = checkNotNull(lastPublishedState) - private val timeLeftEstimator = TimeLeftEstimator() + private val etaEstimator = RealtimeEtaEstimator() private val notificationThrottler = Throttler(400) override suspend fun doWork(): Result { @@ -130,17 +135,16 @@ class DownloadWorker @AssistedInject constructor( notificationManager.notify(id.hashCode(), notification) } Result.failure( - currentState.copy(eta = -1L).toWorkData(), + currentState.copy(eta = -1L, isStuck = false).toWorkData(), ) - } catch (e: IOException) { - e.printStackTraceDebug() - Result.retry() } catch (e: Exception) { e.printStackTraceDebug() Result.failure( currentState.copy( - error = e.getDisplayMessage(applicationContext.resources), + error = e, + errorMessage = e.getDisplayMessage(applicationContext.resources), eta = -1L, + isStuck = false, ).toWorkData(), ) } finally { @@ -169,7 +173,7 @@ class DownloadWorker @AssistedInject constructor( var manga = subject val chaptersToSkip = excludedIds.toMutableSet() val pausingReceiver = PausingReceiver(id, PausingHandle.current()) - withMangaLock(manga) { + mangaLock.withLock(manga) { ContextCompat.registerReceiver( applicationContext, pausingReceiver, @@ -229,15 +233,23 @@ class DownloadWorker @AssistedInject constructor( } } } - }.collect { + }.map { + DownloadProgress( + totalChapters = chapters.size, + currentChapter = chapterIndex, + totalPages = pages.size, + currentPage = pageCounter.getAndIncrement(), + ) + }.withTicker(2L, TimeUnit.SECONDS).collect { progress -> publishState( currentState.copy( - totalChapters = chapters.size, - currentChapter = chapterIndex, - totalPages = pages.size, - currentPage = pageCounter.incrementAndGet(), + totalChapters = progress.totalChapters, + currentChapter = progress.currentChapter, + totalPages = progress.totalPages, + currentPage = progress.currentPage, isIndeterminate = false, - eta = timeLeftEstimator.getEta(), + eta = etaEstimator.getEta(), + isStuck = etaEstimator.isStuck(), ), ) } @@ -248,15 +260,20 @@ class DownloadWorker @AssistedInject constructor( } publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) } - publishState(currentState.copy(isIndeterminate = true, eta = -1L)) + publishState(currentState.copy(isIndeterminate = true, eta = -1L, isStuck = false)) output.mergeWithExisting() output.finish() val localManga = LocalMangaInput.of(output.rootFile).getManga() localStorageChanges.emit(localManga) - publishState(currentState.copy(localManga = localManga, eta = -1L)) + publishState(currentState.copy(localManga = localManga, eta = -1L, isStuck = false)) } catch (e: Exception) { if (e !is CancellationException) { - publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources))) + publishState( + currentState.copy( + error = e, + errorMessage = e.getDisplayMessage(applicationContext.resources), + ), + ) } throw e } finally { @@ -281,12 +298,19 @@ class DownloadWorker @AssistedInject constructor( try { return block() } catch (e: IOException) { - if (countDown <= 0) { + val retryDelay = if (e is TooManyRequestExceptions) { + e.getRetryDelay() + } else { + DOWNLOAD_ERROR_DELAY + } + if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) { publishState( currentState.copy( isPaused = true, - error = e.getDisplayMessage(applicationContext.resources), + error = e, + errorMessage = e.getDisplayMessage(applicationContext.resources), eta = -1L, + isStuck = false, ), ) countDown = MAX_FAILSAFE_ATTEMPTS @@ -298,15 +322,10 @@ class DownloadWorker @AssistedInject constructor( return null } } finally { - publishState(currentState.copy(isPaused = false, error = null)) + publishState(currentState.copy(isPaused = false, error = null, errorMessage = null)) } } else { countDown-- - val retryDelay = if (e is TooManyRequestExceptions) { - e.retryAfter + DOWNLOAD_ERROR_DELAY - } else { - DOWNLOAD_ERROR_DELAY - } delay(retryDelay) } } @@ -316,7 +335,7 @@ class DownloadWorker @AssistedInject constructor( private suspend fun checkIsPaused() { val pausingHandle = PausingHandle.current() if (pausingHandle.isPaused) { - publishState(currentState.copy(isPaused = true, eta = -1L)) + publishState(currentState.copy(isPaused = true, eta = -1L, isStuck = false)) try { pausingHandle.awaitResumed() } finally { @@ -354,9 +373,9 @@ class DownloadWorker @AssistedInject constructor( val previousState = currentState lastPublishedState = state if (previousState.isParticularProgress && state.isParticularProgress) { - timeLeftEstimator.tick(state.progress, state.max) + etaEstimator.onProgressChanged(state.progress, state.max) } else { - timeLeftEstimator.emptyTick() + etaEstimator.reset() notificationThrottler.reset() } val notification = notificationFactory.create(state) @@ -399,13 +418,6 @@ class DownloadWorker @AssistedInject constructor( return result } - private suspend inline fun withMangaLock(manga: Manga, block: () -> T) = try { - localMangaRepository.lockManga(manga.id) - block() - } finally { - localMangaRepository.unlockManga(manga.id) - } - @Reusable class Scheduler @Inject constructor( @ApplicationContext private val context: Context, @@ -458,15 +470,21 @@ class DownloadWorker @AssistedInject constructor( workManager.cancelAllWorkByTag(TAG).await() } - fun pause(id: UUID) { - val intent = PausingReceiver.getPauseIntent(context, id) - context.sendBroadcast(intent) - } + fun pause(id: UUID) = context.sendBroadcast( + PausingReceiver.getPauseIntent(context, id), + ) - fun resume(id: UUID, skipError: Boolean) { - val intent = PausingReceiver.getResumeIntent(context, id, skipError) - context.sendBroadcast(intent) - } + fun resume(id: UUID) = context.sendBroadcast( + PausingReceiver.getResumeIntent(context, id), + ) + + fun skip(id: UUID) = context.sendBroadcast( + PausingReceiver.getSkipIntent(context, id), + ) + + fun skipAll(id: UUID) = context.sendBroadcast( + PausingReceiver.getSkipAllIntent(context, id), + ) suspend fun delete(id: UUID) { workManager.deleteWork(id) @@ -526,7 +544,8 @@ class DownloadWorker @AssistedInject constructor( const val MAX_FAILSAFE_ATTEMPTS = 2 const val MAX_PAGES_PARALLELISM = 4 - const val DOWNLOAD_ERROR_DELAY = 500L + const val DOWNLOAD_ERROR_DELAY = 2_000L + const val MAX_RETRY_DELAY = 7_200_000L // 2 hours const val SLOWDOWN_DELAY = 200L const val MANGA_ID = "manga_id" const val CHAPTERS_IDS = "chapters" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt index 0c9eb6653..e02205230 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt @@ -10,7 +10,10 @@ import kotlin.coroutines.CoroutineContext class PausingHandle : AbstractCoroutineContextElement(PausingHandle) { private val paused = MutableStateFlow(false) - private val isSkipError = MutableStateFlow(false) + private val skipError = MutableStateFlow(false) + + @Volatile + private var skipAllErrors = false @get:AnyThread val isPaused: Boolean @@ -27,18 +30,30 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) { } @AnyThread - fun resume(skipError: Boolean) { - isSkipError.value = skipError + fun resume() { + skipError.value = false paused.value = false } + @AnyThread + fun skip() { + skipError.value = true + paused.value = false + } + + @AnyThread + fun skipAll() { + skipAllErrors = true + skip() + } + suspend fun yield() { if (paused.value) { paused.first { !it } } } - fun skipCurrentError(): Boolean = isSkipError.compareAndSet(expect = true, update = false) + fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = skipAllErrors) companion object : CoroutineContext.Key { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt index c03313734..ce216ad3a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt @@ -21,8 +21,9 @@ class PausingReceiver( return } when (intent.action) { - ACTION_RESUME -> pausingHandle.resume(skipError = false) - ACTION_SKIP -> pausingHandle.resume(skipError = true) + ACTION_RESUME -> pausingHandle.resume() + ACTION_SKIP -> pausingHandle.skip() + ACTION_SKIP_ALL -> pausingHandle.skipAll() ACTION_PAUSE -> pausingHandle.pause() } } @@ -32,6 +33,7 @@ class PausingReceiver( private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP" + private const val ACTION_SKIP_ALL = "org.koitharu.kotatsu.download.SKIP_ALL" private const val EXTRA_UUID = "uuid" private const val SCHEME = "workuid" @@ -39,20 +41,18 @@ class PausingReceiver( addAction(ACTION_PAUSE) addAction(ACTION_RESUME) addAction(ACTION_SKIP) + addAction(ACTION_SKIP_ALL) addDataScheme(SCHEME) - addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) + addDataPath(id.toString(), PatternMatcher.PATTERN_LITERAL) } - fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE) - .setData(Uri.parse("$SCHEME://$id")) - .setPackage(context.packageName) - .putExtra(EXTRA_UUID, id.toString()) + fun getPauseIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_PAUSE) - fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent( - if (skipError) ACTION_SKIP else ACTION_RESUME, - ).setData(Uri.parse("$SCHEME://$id")) - .setPackage(context.packageName) - .putExtra(EXTRA_UUID, id.toString()) + fun getResumeIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_RESUME) + + fun getSkipIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP) + + fun getSkipAllIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP_ALL) fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( context, @@ -62,13 +62,27 @@ class PausingReceiver( false, ) - fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) = + fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( context, 0, - getResumeIntent(context, id, skipError), + getResumeIntent(context, id), 0, false, ) + + fun createSkipPendingIntent(context: Context, id: UUID) = + PendingIntentCompat.getBroadcast( + context, + 0, + getSkipIntent(context, id), + 0, + false, + ) + + private fun createIntent(context: Context, id: UUID, action: String) = Intent(action) + .setData(Uri.parse("$SCHEME://$id")) + .setPackage(context.packageName) + .putExtra(EXTRA_UUID, id.toString()) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 519735223..726e2eab8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -129,7 +129,7 @@ class ExploreFragment : R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource) R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context) R.id.button_more -> SuggestionsActivity.newIntent(v.context) - R.id.button_downloads -> DownloadsActivity.newIntent(v.context) + R.id.button_downloads -> Intent(v.context, DownloadsActivity::class.java) R.id.button_random -> { viewModel.openRandom() return @@ -257,6 +257,7 @@ class ExploreFragment : val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { Intent.ACTION_DELETE } else { + @Suppress("DEPRECATION") Intent.ACTION_UNINSTALL_PACKAGE } context?.startActivity(Intent(action, uri)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt index 4a9d287c7..c8cce1966 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.RoomWarnings import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @@ -51,6 +52,7 @@ abstract class FavouriteCategoriesDao { @Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0") protected abstract suspend fun getMaxSortKey(): Int? + @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // for the new_chapters column @Query("SELECT favourite_categories.*, (SELECT SUM(chapters_new) FROM tracks WHERE tracks.manga_id IN (SELECT manga_id FROM favourites WHERE favourites.category_id = favourite_categories.category_id)) AS new_chapters FROM favourite_categories WHERE track = 1 AND show_in_lib = 1 AND deleted_at = 0 AND new_chapters > 0 ORDER BY new_chapters DESC LIMIT :limit") abstract suspend fun getMostUpdatedCategories(limit: Int): List diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 498619825..a68c5e9ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -114,10 +114,6 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0") abstract fun observeCategories(mangaId: Long): Flow> - @Deprecated("") - @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC") - abstract suspend fun findCategoriesIds(mangaIds: Collection): List - @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0 ORDER BY favourites.created_at ASC") abstract suspend fun findCategoriesIds(mangaId: Long): List diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index b8d216fe1..fa28d9288 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -125,11 +125,6 @@ class FavouritesRepository @Inject constructor( return db.getFavouritesDao().findCategoriesCount(mangaId) != 0 } - @Deprecated("") - suspend fun getCategoriesIds(mangaIds: Collection): Set { - return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet() - } - suspend fun getCategoriesIds(mangaId: Long): Set { return db.getFavouritesDao().findCategoriesIds(mangaId).toSet() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index bafffd7fa..dacba62ba 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -16,7 +16,6 @@ import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.AlphanumComparator -import org.koitharu.kotatsu.core.util.MultiMutex import org.koitharu.kotatsu.core.util.ext.children import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.filterWith @@ -24,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaUtil +import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga @@ -47,10 +47,10 @@ class LocalMangaRepository @Inject constructor( private val storageManager: LocalStorageManager, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, private val settings: AppSettings, + private val lock: MangaLock, ) : MangaRepository { override val source = LocalMangaSource - private val locks = MultiMutex() private val localMappingCache = LocalMangaMappingCache() override val isMultipleTagsSupported: Boolean = true @@ -88,7 +88,7 @@ class LocalMangaRepository @Inject constructor( SortOrder.RATING -> list.sortByDescending { it.manga.rating } SortOrder.NEWEST, SortOrder.UPDATED, - -> list.sortByDescending { it.createdAt } + -> list.sortByDescending { it.createdAt } else -> Unit } @@ -120,17 +120,12 @@ class LocalMangaRepository @Inject constructor( return result } - suspend fun deleteChapters(manga: Manga, ids: Set) { - lockManga(manga.id) - try { - val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) { - "Manga is not stored on local storage" - }.manga - LocalMangaUtil(subject).deleteChapters(ids) - localStorageChanges.emit(LocalManga(subject)) - } finally { - unlockManga(manga.id) - } + suspend fun deleteChapters(manga: Manga, ids: Set) = lock.withLock(manga) { + val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) { + "Manga is not stored on local storage" + }.manga + LocalMangaUtil(subject).deleteChapters(ids) + localStorageChanges.emit(LocalManga(subject)) } suspend fun getRemoteManga(localManga: Manga): Manga? { @@ -193,7 +188,7 @@ class LocalMangaRepository @Inject constructor( } suspend fun cleanup(): Boolean { - if (locks.isNotEmpty()) { + if (lock.isNotEmpty()) { return false } val dirs = storageManager.getWriteableDirs() @@ -207,14 +202,6 @@ class LocalMangaRepository @Inject constructor( return true } - suspend fun lockManga(id: Long) { - locks.lock(id) - } - - fun unlockManga(id: Long) { - locks.unlock(id) - } - private suspend fun getRawList(): ArrayList { val files = getAllFiles().toList() // TODO remove toList() return coroutineScope { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/MangaLock.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/MangaLock.kt new file mode 100644 index 000000000..40d102172 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/MangaLock.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.local.domain + +import org.koitharu.kotatsu.core.util.MultiMutex +import org.koitharu.kotatsu.parsers.model.Manga +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MangaLock @Inject constructor() : MultiMutex() diff --git a/app/src/main/res/drawable/ic_retry.xml b/app/src/main/res/drawable/ic_retry.xml new file mode 100644 index 000000000..c8af82bd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_retry.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml index 0c5b27ce4..9bac49bd8 100644 --- a/app/src/main/res/layout/item_download.xml +++ b/app/src/main/res/layout/item_download.xml @@ -148,25 +148,17 @@ style="?materialButtonOutlinedStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:layout_marginEnd="12dp" android:text="@string/pause" android:visibility="gone" - app:layout_constraintEnd_toStartOf="@id/button_resume" - app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters" - tools:visibility="gone" /> + tools:visibility="visible" />