Downloading improvements
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
class MultiMutex<T : Any> : Set<T> {
|
||||
open class MultiMutex<T : Any> : Set<T> {
|
||||
|
||||
private val delegates = ArrayMap<T, Mutex>()
|
||||
|
||||
@@ -20,19 +20,26 @@ class MultiMutex<T : Any> : Set<T> {
|
||||
elements.all { x -> delegates.containsKey(x) }
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean {
|
||||
return delegates.isEmpty()
|
||||
override fun isEmpty(): Boolean = delegates.isEmpty()
|
||||
|
||||
override fun iterator(): Iterator<T> = synchronized(delegates) {
|
||||
delegates.keys.toList()
|
||||
}.iterator()
|
||||
|
||||
fun isLocked(element: T): Boolean = synchronized(delegates) {
|
||||
delegates[element]?.isLocked == true
|
||||
}
|
||||
|
||||
override fun iterator(): Iterator<T> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
|
||||
@@ -87,6 +90,20 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
|
||||
}
|
||||
}
|
||||
|
||||
fun tickerFlow(interval: Long, timeUnit: TimeUnit): Flow<Long> = flow {
|
||||
while (true) {
|
||||
emit(SystemClock.elapsedRealtime())
|
||||
delay(timeUnit.toMillis(interval))
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.withTicker(interval: Long, timeUnit: TimeUnit) = channelFlow<T> {
|
||||
onCompletion { cause ->
|
||||
close(cause)
|
||||
}.combine(tickerFlow(interval, timeUnit)) { x, _ -> x }
|
||||
.collectLatest { send(it) }
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
flow: Flow<T1>,
|
||||
|
||||
@@ -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<Class<*>>(
|
||||
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.<init>")
|
||||
|
||||
@@ -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<Tick>(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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,11 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<ActivityDownloadsBinding>(),
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var scheduler: DownloadWorker.Scheduler
|
||||
|
||||
private val viewModel by viewModels<DownloadsViewModel>()
|
||||
private lateinit var selectionController: ListSelectionController
|
||||
|
||||
@@ -102,11 +103,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
}
|
||||
|
||||
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<ActivityDownloadsBinding>(),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 <T> 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"
|
||||
|
||||
@@ -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<PausingHandle> {
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<FavouriteCategoryEntity>
|
||||
|
||||
|
||||
@@ -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<List<FavouriteCategoryEntity>>
|
||||
|
||||
@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<Long>): List<Long>
|
||||
|
||||
@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<Long>
|
||||
|
||||
|
||||
@@ -125,11 +125,6 @@ class FavouritesRepository @Inject constructor(
|
||||
return db.getFavouritesDao().findCategoriesCount(mangaId) != 0
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
|
||||
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
|
||||
}
|
||||
|
||||
suspend fun getCategoriesIds(mangaId: Long): Set<Long> {
|
||||
return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
|
||||
}
|
||||
|
||||
@@ -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<LocalManga?>,
|
||||
private val settings: AppSettings,
|
||||
private val lock: MangaLock,
|
||||
) : MangaRepository {
|
||||
|
||||
override val source = LocalMangaSource
|
||||
private val locks = MultiMutex<Long>()
|
||||
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<Long>) {
|
||||
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<Long>) = 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<LocalManga> {
|
||||
val files = getAllFiles().toList() // TODO remove toList()
|
||||
return coroutineScope {
|
||||
|
||||
@@ -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<Manga>()
|
||||
17
app/src/main/res/drawable/ic_retry.xml
Normal file
17
app/src/main/res/drawable/ic_retry.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,5V2L8,6l4,4V7c3.31,0 6,2.69 6,6c0,2.97 -2.17,5.43 -5,5.91v2.02c3.95,-0.49 7,-3.85 7,-7.93C20,8.58 16.42,5 12,5z" />
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,13c0,-1.65 0.67,-3.15 1.76,-4.24L6.34,7.34C4.9,8.79 4,10.79 4,13c0,4.08 3.05,7.44 7,7.93v-2.02C8.17,18.43 6,15.97 6,13z" />
|
||||
|
||||
</vector>
|
||||
@@ -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" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_resume"
|
||||
style="?materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:text="@string/resume"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_cancel"
|
||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<Button
|
||||
@@ -174,12 +166,17 @@
|
||||
style="?materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:text="@string/skip"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_resume"
|
||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_skip_all"
|
||||
style="?materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/skip_all"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<Button
|
||||
@@ -187,14 +184,26 @@
|
||||
style="?materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:text="@android:string/cancel"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.constraintlayout.helper.widget.Flow
|
||||
android:id="@+id/flow_buttons"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
app:constraint_referenced_ids="button_pause,button_resume,button_skip,button_skip_all,button_cancel"
|
||||
app:flow_horizontalAlign="end"
|
||||
app:flow_horizontalBias="1"
|
||||
app:flow_horizontalGap="8dp"
|
||||
app:flow_horizontalStyle="packed"
|
||||
app:flow_wrapMode="chain"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/list_selector"
|
||||
android:clipChildren="false">
|
||||
android:clipChildren="false"
|
||||
android:minHeight="72dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/imageView_cover"
|
||||
@@ -14,10 +15,7 @@
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="16dp"
|
||||
|
||||
android:layout_marginBottom="16dp"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
||||
@@ -33,7 +31,6 @@
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
app:layout_constraintBottom_toTopOf="@+id/textView_summary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
||||
@@ -44,14 +41,14 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingBottom="16dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
||||
tools:text="@tools:sample/lorem/random" />
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<string name="chapter_d_of_d">Chapter %1$d of %2$d</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="try_again">Try again</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="clear_history">Clear history</string>
|
||||
<string name="nothing_found">Nothing found</string>
|
||||
<string name="history_is_empty">No history yet</string>
|
||||
@@ -459,6 +460,7 @@
|
||||
<string name="in_progress">In progress</string>
|
||||
<string name="disable_nsfw">Disable NSFW</string>
|
||||
<string name="too_many_requests_message">Too many requests. Try again later</string>
|
||||
<string name="too_many_requests_message_retry">Too many requests. Try again after %s</string>
|
||||
<string name="related_manga_summary">Show a list of related manga. In some cases it may be inaccurate or missing</string>
|
||||
<string name="advanced">Advanced</string>
|
||||
<string name="manga_list">Manga list</string>
|
||||
@@ -624,8 +626,12 @@
|
||||
<string name="hours_short">%d h</string>
|
||||
<!-- Short minutes format pattern -->
|
||||
<string name="minutes_short">%d m</string>
|
||||
<!-- Short seconds format pattern -->
|
||||
<string name="seconds_short">%d s</string>
|
||||
<!-- Short hours and minutes format pattern -->
|
||||
<string name="hours_minutes_short">%1$d h %2$d m</string>
|
||||
<!-- Short minutes and seconds format pattern -->
|
||||
<string name="minutes_seconds_short">%1$d m %2$d s</string>
|
||||
<string name="fix">Fix</string>
|
||||
<string name="missing_storage_permission">There is no permission to access manga on external storage</string>
|
||||
<string name="last_used">Last used</string>
|
||||
@@ -677,4 +683,6 @@
|
||||
<string name="show_quick_filters">Show quick filters</string>
|
||||
<string name="show_quick_filters_summary">Provides the ability to filter manga lists by certain parameters</string>
|
||||
<string name="sfw">SFW</string>
|
||||
<string name="skip_all">Skip all</string>
|
||||
<string name="stuck">Stuck</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user