Downloading improvements
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 662
|
versionCode = 663
|
||||||
versionName = '7.5-a2'
|
versionName = '7.5-a3'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -56,6 +56,7 @@ android {
|
|||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||||
@@ -82,7 +83,7 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:f91ff0b9d0') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:939b6b1e46') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlin.contracts.InvocationKind
|
import kotlin.contracts.InvocationKind
|
||||||
import kotlin.contracts.contract
|
import kotlin.contracts.contract
|
||||||
|
|
||||||
class MultiMutex<T : Any> : Set<T> {
|
open class MultiMutex<T : Any> : Set<T> {
|
||||||
|
|
||||||
private val delegates = ArrayMap<T, Mutex>()
|
private val delegates = ArrayMap<T, Mutex>()
|
||||||
|
|
||||||
@@ -20,19 +20,26 @@ class MultiMutex<T : Any> : Set<T> {
|
|||||||
elements.all { x -> delegates.containsKey(x) }
|
elements.all { x -> delegates.containsKey(x) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isEmpty(): Boolean {
|
override fun isEmpty(): Boolean = delegates.isEmpty()
|
||||||
return 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> {
|
fun tryLock(element: T): Boolean {
|
||||||
return delegates.keys.iterator()
|
val mutex = synchronized(delegates) {
|
||||||
|
delegates.getOrPut(element, ::Mutex)
|
||||||
|
}
|
||||||
|
return mutex.tryLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun lock(element: T) {
|
suspend fun lock(element: T) {
|
||||||
val mutex = synchronized(delegates) {
|
val mutex = synchronized(delegates) {
|
||||||
delegates.getOrPut(element) {
|
delegates.getOrPut(element, ::Mutex)
|
||||||
Mutex()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
mutex.lock()
|
mutex.lock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
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 org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
|
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
|
||||||
// TODO: Use Java 9's LocalDate.ofInstant().
|
// 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 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.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
@@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.flow.transform
|
import kotlinx.coroutines.flow.transform
|
||||||
import kotlinx.coroutines.flow.transformLatest
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun <T1, T2, T3, T4, T5, T6, R> combine(
|
fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||||
flow: Flow<T1>,
|
flow: Flow<T1>,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.util.ext
|
|||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.collection.arraySetOf
|
|
||||||
import coil.network.HttpException
|
import coil.network.HttpException
|
||||||
import okio.FileNotFoundException
|
import okio.FileNotFoundException
|
||||||
import okio.IOException
|
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.UnsupportedFileException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
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_LOCALE_GENRES_NOT_SUPPORTED
|
||||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_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
|
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,
|
is UnsupportedOperationException,
|
||||||
-> resources.getString(R.string.operation_not_supported)
|
-> 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 UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
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 {
|
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 {
|
fun Throwable.isNetworkError(): Boolean {
|
||||||
@@ -119,15 +150,6 @@ fun Throwable.report() {
|
|||||||
exception.sendWithAcra()
|
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 {
|
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||||
val trace = stackTraceToString()
|
val trace = stackTraceToString()
|
||||||
return trace.contains("android.webkit.WebView.<init>")
|
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 isIndeterminate: Boolean,
|
||||||
val isPaused: Boolean = false,
|
val isPaused: Boolean = false,
|
||||||
val isStopped: Boolean = false,
|
val isStopped: Boolean = false,
|
||||||
val error: String? = null,
|
val error: Throwable? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
val totalChapters: Int = 0,
|
val totalChapters: Int = 0,
|
||||||
val currentChapter: Int = 0,
|
val currentChapter: Int = 0,
|
||||||
val totalPages: Int = 0,
|
val totalPages: Int = 0,
|
||||||
val currentPage: Int = 0,
|
val currentPage: Int = 0,
|
||||||
val eta: Long = -1L,
|
val eta: Long = -1L,
|
||||||
|
val isStuck: Boolean = false,
|
||||||
val localManga: LocalManga? = null,
|
val localManga: LocalManga? = null,
|
||||||
val downloadedChapters: Int = 0,
|
val downloadedChapters: Int = 0,
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
@@ -39,8 +41,9 @@ data class DownloadState(
|
|||||||
.putInt(DATA_MAX, max)
|
.putInt(DATA_MAX, max)
|
||||||
.putInt(DATA_PROGRESS, progress)
|
.putInt(DATA_PROGRESS, progress)
|
||||||
.putLong(DATA_ETA, eta)
|
.putLong(DATA_ETA, eta)
|
||||||
|
.putBoolean(DATA_STUCK, isStuck)
|
||||||
.putLong(DATA_TIMESTAMP, timestamp)
|
.putLong(DATA_TIMESTAMP, timestamp)
|
||||||
.putString(DATA_ERROR, error)
|
.putString(DATA_ERROR, errorMessage)
|
||||||
.putInt(DATA_CHAPTERS, downloadedChapters)
|
.putInt(DATA_CHAPTERS, downloadedChapters)
|
||||||
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
|
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
|
||||||
.putBoolean(DATA_PAUSED, isPaused)
|
.putBoolean(DATA_PAUSED, isPaused)
|
||||||
@@ -53,6 +56,7 @@ data class DownloadState(
|
|||||||
private const val DATA_PROGRESS = "progress"
|
private const val DATA_PROGRESS = "progress"
|
||||||
private const val DATA_CHAPTERS = "chapter_cnt"
|
private const val DATA_CHAPTERS = "chapter_cnt"
|
||||||
private const val DATA_ETA = "eta"
|
private const val DATA_ETA = "eta"
|
||||||
|
private const val DATA_STUCK = "stuck"
|
||||||
const val DATA_TIMESTAMP = "timestamp"
|
const val DATA_TIMESTAMP = "timestamp"
|
||||||
private const val DATA_ERROR = "error"
|
private const val DATA_ERROR = "error"
|
||||||
private const val DATA_INDETERMINATE = "indeterminate"
|
private const val DATA_INDETERMINATE = "indeterminate"
|
||||||
@@ -72,6 +76,8 @@ data class DownloadState(
|
|||||||
|
|
||||||
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
|
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 getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L))
|
||||||
|
|
||||||
fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
|
fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ fun downloadItemAD(
|
|||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
when (v.id) {
|
when (v.id) {
|
||||||
R.id.button_cancel -> listener.onCancelClick(item)
|
R.id.button_cancel -> listener.onCancelClick(item)
|
||||||
R.id.button_resume -> listener.onResumeClick(item, skip = false)
|
R.id.button_resume -> listener.onResumeClick(item)
|
||||||
R.id.button_skip -> listener.onResumeClick(item, skip = true)
|
R.id.button_skip -> listener.onSkipClick(item)
|
||||||
|
R.id.button_skip_all -> listener.onSkipAllClick(item)
|
||||||
R.id.button_pause -> listener.onPauseClick(item)
|
R.id.button_pause -> listener.onPauseClick(item)
|
||||||
R.id.imageView_expand -> listener.onExpandClick(item)
|
R.id.imageView_expand -> listener.onExpandClick(item)
|
||||||
else -> listener.onItemClick(item, v)
|
else -> listener.onItemClick(item, v)
|
||||||
@@ -65,6 +66,7 @@ fun downloadItemAD(
|
|||||||
binding.buttonPause.setOnClickListener(clickListener)
|
binding.buttonPause.setOnClickListener(clickListener)
|
||||||
binding.buttonResume.setOnClickListener(clickListener)
|
binding.buttonResume.setOnClickListener(clickListener)
|
||||||
binding.buttonSkip.setOnClickListener(clickListener)
|
binding.buttonSkip.setOnClickListener(clickListener)
|
||||||
|
binding.buttonSkipAll.setOnClickListener(clickListener)
|
||||||
binding.imageViewExpand.setOnClickListener(clickListener)
|
binding.imageViewExpand.setOnClickListener(clickListener)
|
||||||
itemView.setOnClickListener(clickListener)
|
itemView.setOnClickListener(clickListener)
|
||||||
itemView.setOnLongClickListener(clickListener)
|
itemView.setOnLongClickListener(clickListener)
|
||||||
@@ -136,9 +138,14 @@ fun downloadItemAD(
|
|||||||
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
|
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
|
||||||
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
|
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
|
||||||
binding.textViewPercent.isVisible = true
|
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.buttonCancel.isVisible = true
|
||||||
binding.buttonResume.isVisible = item.isPaused
|
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.buttonSkip.isVisible = item.isPaused && item.error != null
|
||||||
binding.buttonPause.isVisible = item.canPause
|
binding.buttonPause.isVisible = item.canPause
|
||||||
}
|
}
|
||||||
@@ -171,7 +178,7 @@ fun downloadItemAD(
|
|||||||
binding.progressBar.isVisible = false
|
binding.progressBar.isVisible = false
|
||||||
binding.progressBar.isEnabled = true
|
binding.progressBar.isEnabled = true
|
||||||
binding.textViewPercent.isVisible = false
|
binding.textViewPercent.isVisible = false
|
||||||
binding.textViewDetails.textAndVisible = item.error
|
binding.textViewDetails.textAndVisible = item.getErrorMessage(context)
|
||||||
binding.buttonCancel.isVisible = false
|
binding.buttonCancel.isVisible = false
|
||||||
binding.buttonResume.isVisible = false
|
binding.buttonResume.isVisible = false
|
||||||
binding.buttonSkip.isVisible = false
|
binding.buttonSkip.isVisible = false
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
|
|||||||
|
|
||||||
fun onPauseClick(item: 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)
|
fun onExpandClick(item: DownloadItemModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.list
|
package org.koitharu.kotatsu.download.ui.list
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
import android.text.format.DateUtils
|
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 androidx.work.WorkInfo
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.download.ui.list.chapters.DownloadChapter
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
data class DownloadItemModel(
|
data class DownloadItemModel(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
@@ -21,6 +28,7 @@ data class DownloadItemModel(
|
|||||||
val max: Int,
|
val max: Int,
|
||||||
val progress: Int,
|
val progress: Int,
|
||||||
val eta: Long,
|
val eta: Long,
|
||||||
|
val isStuck: Boolean,
|
||||||
val timestamp: Instant,
|
val timestamp: Instant,
|
||||||
val chaptersDownloaded: Int,
|
val chaptersDownloaded: Int,
|
||||||
val isExpanded: Boolean,
|
val isExpanded: Boolean,
|
||||||
@@ -51,6 +59,18 @@ data class DownloadItemModel(
|
|||||||
null
|
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 {
|
override fun compareTo(other: DownloadItemModel): Int {
|
||||||
return timestamp.compareTo(other.timestamp)
|
return timestamp.compareTo(other.timestamp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.list
|
package org.koitharu.kotatsu.download.ui.list
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
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.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
|
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
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 org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -34,6 +32,9 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var coil: ImageLoader
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var scheduler: DownloadWorker.Scheduler
|
||||||
|
|
||||||
private val viewModel by viewModels<DownloadsViewModel>()
|
private val viewModel by viewModels<DownloadsViewModel>()
|
||||||
private lateinit var selectionController: ListSelectionController
|
private lateinit var selectionController: ListSelectionController
|
||||||
|
|
||||||
@@ -102,11 +103,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPauseClick(item: DownloadItemModel) {
|
override fun onPauseClick(item: DownloadItemModel) {
|
||||||
sendBroadcast(PausingReceiver.getPauseIntent(this, item.id))
|
scheduler.pause(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResumeClick(item: DownloadItemModel, skip: Boolean) {
|
override fun onResumeClick(item: DownloadItemModel) {
|
||||||
sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip))
|
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) {
|
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||||
@@ -171,9 +180,4 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
|||||||
menu.findItem(R.id.action_remove)?.isVisible = canRemove
|
menu.findItem(R.id.action_remove)?.isVisible = canRemove
|
||||||
return super.onPrepareActionMode(controller, mode, menu)
|
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
|
var isResumed = false
|
||||||
for (work in snapshot) {
|
for (work in snapshot) {
|
||||||
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
|
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
|
||||||
workScheduler.resume(work.id, skipError = false)
|
workScheduler.resume(work.id)
|
||||||
isResumed = true
|
isResumed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
val snapshot = works.value ?: return
|
val snapshot = works.value ?: return
|
||||||
for (work in snapshot) {
|
for (work in snapshot) {
|
||||||
if (work.id.mostSignificantBits in ids) {
|
if (work.id.mostSignificantBits in ids) {
|
||||||
workScheduler.resume(work.id, skipError = false)
|
workScheduler.resume(work.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
|
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
|
||||||
@@ -268,6 +268,7 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
max = DownloadState.getMax(workData),
|
max = DownloadState.getMax(workData),
|
||||||
progress = DownloadState.getProgress(workData),
|
progress = DownloadState.getProgress(workData),
|
||||||
eta = DownloadState.getEta(workData),
|
eta = DownloadState.getEta(workData),
|
||||||
|
isStuck = DownloadState.isStuck(workData),
|
||||||
timestamp = DownloadState.getTimestamp(workData),
|
timestamp = DownloadState.getTimestamp(workData),
|
||||||
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
|
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.download.ui.worker
|
|||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.core.app.NotificationChannelCompat
|
import androidx.core.app.NotificationChannelCompat
|
||||||
@@ -21,8 +22,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
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.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||||
@@ -57,7 +60,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
|||||||
private val queueIntent = PendingIntentCompat.getActivity(
|
private val queueIntent = PendingIntentCompat.getActivity(
|
||||||
context,
|
context,
|
||||||
0,
|
0,
|
||||||
DownloadsActivity.newIntent(context),
|
Intent(context, DownloadsActivity::class.java),
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -82,7 +85,15 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
|||||||
NotificationCompat.Action(
|
NotificationCompat.Action(
|
||||||
R.drawable.ic_action_resume,
|
R.drawable.ic_action_resume,
|
||||||
context.getString(R.string.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(
|
NotificationCompat.Action(
|
||||||
R.drawable.ic_action_skip,
|
R.drawable.ic_action_skip,
|
||||||
context.getString(R.string.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 {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (state.error != null) {
|
if (state.errorMessage != null) {
|
||||||
builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error))
|
builder.setContentText(
|
||||||
|
context.getString(
|
||||||
|
R.string.download_summary_pattern,
|
||||||
|
percent,
|
||||||
|
state.errorMessage,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
builder.setContentText(percent)
|
builder.setContentText(percent)
|
||||||
}
|
}
|
||||||
@@ -170,9 +187,11 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
|||||||
builder.setOngoing(true)
|
builder.setOngoing(true)
|
||||||
builder.setSmallIcon(R.drawable.ic_stat_paused)
|
builder.setSmallIcon(R.drawable.ic_stat_paused)
|
||||||
builder.addAction(actionCancel)
|
builder.addAction(actionCancel)
|
||||||
builder.addAction(actionResume)
|
if (state.errorMessage != null) {
|
||||||
if (state.error != null) {
|
builder.addAction(actionRetry)
|
||||||
builder.addAction(actionSkip)
|
builder.addAction(actionSkip)
|
||||||
|
} else {
|
||||||
|
builder.addAction(actionResume)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,18 +199,27 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
|||||||
builder.setProgress(0, 0, false)
|
builder.setProgress(0, 0, false)
|
||||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
builder.setSubText(context.getString(R.string.error))
|
builder.setSubText(context.getString(R.string.error))
|
||||||
builder.setContentText(state.error)
|
builder.setContentText(state.errorMessage)
|
||||||
builder.setAutoCancel(true)
|
builder.setAutoCancel(true)
|
||||||
builder.setOngoing(false)
|
builder.setOngoing(false)
|
||||||
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||||
builder.setShowWhen(true)
|
builder.setShowWhen(true)
|
||||||
builder.setWhen(System.currentTimeMillis())
|
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 -> {
|
else -> {
|
||||||
builder.setProgress(state.max, state.progress, false)
|
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.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
builder.setStyle(null)
|
builder.setStyle(null)
|
||||||
builder.setOngoing(true)
|
builder.setOngoing(true)
|
||||||
@@ -202,20 +230,20 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
|||||||
return builder.build()
|
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) {
|
val percentString = if (percent >= 0f) {
|
||||||
context.getString(R.string.percent_string_pattern, (percent * 100).format())
|
context.getString(R.string.percent_string_pattern, (percent * 100).format())
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val etaString = if (eta > 0L) {
|
val etaString = when {
|
||||||
DateUtils.getRelativeTimeSpanString(
|
eta <= 0L -> null
|
||||||
|
isStuck -> context.getString(R.string.stuck)
|
||||||
|
else -> DateUtils.getRelativeTimeSpanString(
|
||||||
eta,
|
eta,
|
||||||
System.currentTimeMillis(),
|
System.currentTimeMillis(),
|
||||||
DateUtils.SECOND_IN_MILLIS,
|
DateUtils.SECOND_IN_MILLIS,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
return when {
|
return when {
|
||||||
percentString == null && etaString == null -> null
|
percentString == null && etaString == null -> null
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.worker
|
package org.koitharu.kotatsu.download.ui.worker
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
@@ -18,7 +19,7 @@ class DownloadStartedObserver(
|
|||||||
snackbar.anchorView = it.bottomNav
|
snackbar.anchorView = it.bottomNav
|
||||||
}
|
}
|
||||||
snackbar.setAction(R.string.details) {
|
snackbar.setAction(R.string.details) {
|
||||||
it.context.startActivity(DownloadsActivity.newIntent(it.context))
|
it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
|
||||||
}
|
}
|
||||||
snackbar.show()
|
snackbar.show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
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.getWorkSpec
|
||||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
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.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.download.domain.DownloadState
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
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.TempFileFilter
|
||||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
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.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -91,6 +95,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
@MangaHttpClient private val okHttp: OkHttpClient,
|
@MangaHttpClient private val okHttp: OkHttpClient,
|
||||||
private val cache: PagesCache,
|
private val cache: PagesCache,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
|
private val mangaLock: MangaLock,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
@@ -108,7 +113,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
private val currentState: DownloadState
|
private val currentState: DownloadState
|
||||||
get() = checkNotNull(lastPublishedState)
|
get() = checkNotNull(lastPublishedState)
|
||||||
|
|
||||||
private val timeLeftEstimator = TimeLeftEstimator()
|
private val etaEstimator = RealtimeEtaEstimator()
|
||||||
private val notificationThrottler = Throttler(400)
|
private val notificationThrottler = Throttler(400)
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
@@ -130,17 +135,16 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
notificationManager.notify(id.hashCode(), notification)
|
notificationManager.notify(id.hashCode(), notification)
|
||||||
}
|
}
|
||||||
Result.failure(
|
Result.failure(
|
||||||
currentState.copy(eta = -1L).toWorkData(),
|
currentState.copy(eta = -1L, isStuck = false).toWorkData(),
|
||||||
)
|
)
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
Result.retry()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
Result.failure(
|
Result.failure(
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
error = e.getDisplayMessage(applicationContext.resources),
|
error = e,
|
||||||
|
errorMessage = e.getDisplayMessage(applicationContext.resources),
|
||||||
eta = -1L,
|
eta = -1L,
|
||||||
|
isStuck = false,
|
||||||
).toWorkData(),
|
).toWorkData(),
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -169,7 +173,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
var manga = subject
|
var manga = subject
|
||||||
val chaptersToSkip = excludedIds.toMutableSet()
|
val chaptersToSkip = excludedIds.toMutableSet()
|
||||||
val pausingReceiver = PausingReceiver(id, PausingHandle.current())
|
val pausingReceiver = PausingReceiver(id, PausingHandle.current())
|
||||||
withMangaLock(manga) {
|
mangaLock.withLock(manga) {
|
||||||
ContextCompat.registerReceiver(
|
ContextCompat.registerReceiver(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
pausingReceiver,
|
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(
|
publishState(
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
totalChapters = chapters.size,
|
totalChapters = progress.totalChapters,
|
||||||
currentChapter = chapterIndex,
|
currentChapter = progress.currentChapter,
|
||||||
totalPages = pages.size,
|
totalPages = progress.totalPages,
|
||||||
currentPage = pageCounter.incrementAndGet(),
|
currentPage = progress.currentPage,
|
||||||
isIndeterminate = false,
|
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(downloadedChapters = currentState.downloadedChapters + 1))
|
||||||
}
|
}
|
||||||
publishState(currentState.copy(isIndeterminate = true, eta = -1L))
|
publishState(currentState.copy(isIndeterminate = true, eta = -1L, isStuck = false))
|
||||||
output.mergeWithExisting()
|
output.mergeWithExisting()
|
||||||
output.finish()
|
output.finish()
|
||||||
val localManga = LocalMangaInput.of(output.rootFile).getManga()
|
val localManga = LocalMangaInput.of(output.rootFile).getManga()
|
||||||
localStorageChanges.emit(localManga)
|
localStorageChanges.emit(localManga)
|
||||||
publishState(currentState.copy(localManga = localManga, eta = -1L))
|
publishState(currentState.copy(localManga = localManga, eta = -1L, isStuck = false))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e !is CancellationException) {
|
if (e !is CancellationException) {
|
||||||
publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources)))
|
publishState(
|
||||||
|
currentState.copy(
|
||||||
|
error = e,
|
||||||
|
errorMessage = e.getDisplayMessage(applicationContext.resources),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
@@ -281,12 +298,19 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
try {
|
try {
|
||||||
return block()
|
return block()
|
||||||
} catch (e: IOException) {
|
} 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(
|
publishState(
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
isPaused = true,
|
isPaused = true,
|
||||||
error = e.getDisplayMessage(applicationContext.resources),
|
error = e,
|
||||||
|
errorMessage = e.getDisplayMessage(applicationContext.resources),
|
||||||
eta = -1L,
|
eta = -1L,
|
||||||
|
isStuck = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
countDown = MAX_FAILSAFE_ATTEMPTS
|
countDown = MAX_FAILSAFE_ATTEMPTS
|
||||||
@@ -298,15 +322,10 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
publishState(currentState.copy(isPaused = false, error = null))
|
publishState(currentState.copy(isPaused = false, error = null, errorMessage = null))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
countDown--
|
countDown--
|
||||||
val retryDelay = if (e is TooManyRequestExceptions) {
|
|
||||||
e.retryAfter + DOWNLOAD_ERROR_DELAY
|
|
||||||
} else {
|
|
||||||
DOWNLOAD_ERROR_DELAY
|
|
||||||
}
|
|
||||||
delay(retryDelay)
|
delay(retryDelay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,7 +335,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
private suspend fun checkIsPaused() {
|
private suspend fun checkIsPaused() {
|
||||||
val pausingHandle = PausingHandle.current()
|
val pausingHandle = PausingHandle.current()
|
||||||
if (pausingHandle.isPaused) {
|
if (pausingHandle.isPaused) {
|
||||||
publishState(currentState.copy(isPaused = true, eta = -1L))
|
publishState(currentState.copy(isPaused = true, eta = -1L, isStuck = false))
|
||||||
try {
|
try {
|
||||||
pausingHandle.awaitResumed()
|
pausingHandle.awaitResumed()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -354,9 +373,9 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
val previousState = currentState
|
val previousState = currentState
|
||||||
lastPublishedState = state
|
lastPublishedState = state
|
||||||
if (previousState.isParticularProgress && state.isParticularProgress) {
|
if (previousState.isParticularProgress && state.isParticularProgress) {
|
||||||
timeLeftEstimator.tick(state.progress, state.max)
|
etaEstimator.onProgressChanged(state.progress, state.max)
|
||||||
} else {
|
} else {
|
||||||
timeLeftEstimator.emptyTick()
|
etaEstimator.reset()
|
||||||
notificationThrottler.reset()
|
notificationThrottler.reset()
|
||||||
}
|
}
|
||||||
val notification = notificationFactory.create(state)
|
val notification = notificationFactory.create(state)
|
||||||
@@ -399,13 +418,6 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
|
|
||||||
localMangaRepository.lockManga(manga.id)
|
|
||||||
block()
|
|
||||||
} finally {
|
|
||||||
localMangaRepository.unlockManga(manga.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
class Scheduler @Inject constructor(
|
class Scheduler @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
@@ -458,15 +470,21 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
workManager.cancelAllWorkByTag(TAG).await()
|
workManager.cancelAllWorkByTag(TAG).await()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause(id: UUID) {
|
fun pause(id: UUID) = context.sendBroadcast(
|
||||||
val intent = PausingReceiver.getPauseIntent(context, id)
|
PausingReceiver.getPauseIntent(context, id),
|
||||||
context.sendBroadcast(intent)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
fun resume(id: UUID, skipError: Boolean) {
|
fun resume(id: UUID) = context.sendBroadcast(
|
||||||
val intent = PausingReceiver.getResumeIntent(context, id, skipError)
|
PausingReceiver.getResumeIntent(context, id),
|
||||||
context.sendBroadcast(intent)
|
)
|
||||||
}
|
|
||||||
|
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) {
|
suspend fun delete(id: UUID) {
|
||||||
workManager.deleteWork(id)
|
workManager.deleteWork(id)
|
||||||
@@ -526,7 +544,8 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
const val MAX_FAILSAFE_ATTEMPTS = 2
|
const val MAX_FAILSAFE_ATTEMPTS = 2
|
||||||
const val MAX_PAGES_PARALLELISM = 4
|
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 SLOWDOWN_DELAY = 200L
|
||||||
const val MANGA_ID = "manga_id"
|
const val MANGA_ID = "manga_id"
|
||||||
const val CHAPTERS_IDS = "chapters"
|
const val CHAPTERS_IDS = "chapters"
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import kotlin.coroutines.CoroutineContext
|
|||||||
class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
|
class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
|
||||||
|
|
||||||
private val paused = MutableStateFlow(false)
|
private val paused = MutableStateFlow(false)
|
||||||
private val isSkipError = MutableStateFlow(false)
|
private val skipError = MutableStateFlow(false)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var skipAllErrors = false
|
||||||
|
|
||||||
@get:AnyThread
|
@get:AnyThread
|
||||||
val isPaused: Boolean
|
val isPaused: Boolean
|
||||||
@@ -27,18 +30,30 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@AnyThread
|
@AnyThread
|
||||||
fun resume(skipError: Boolean) {
|
fun resume() {
|
||||||
isSkipError.value = skipError
|
skipError.value = false
|
||||||
paused.value = false
|
paused.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun skip() {
|
||||||
|
skipError.value = true
|
||||||
|
paused.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun skipAll() {
|
||||||
|
skipAllErrors = true
|
||||||
|
skip()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun yield() {
|
suspend fun yield() {
|
||||||
if (paused.value) {
|
if (paused.value) {
|
||||||
paused.first { !it }
|
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> {
|
companion object : CoroutineContext.Key<PausingHandle> {
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ class PausingReceiver(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_RESUME -> pausingHandle.resume(skipError = false)
|
ACTION_RESUME -> pausingHandle.resume()
|
||||||
ACTION_SKIP -> pausingHandle.resume(skipError = true)
|
ACTION_SKIP -> pausingHandle.skip()
|
||||||
|
ACTION_SKIP_ALL -> pausingHandle.skipAll()
|
||||||
ACTION_PAUSE -> pausingHandle.pause()
|
ACTION_PAUSE -> pausingHandle.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ class PausingReceiver(
|
|||||||
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
|
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
|
||||||
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
|
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 = "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 EXTRA_UUID = "uuid"
|
||||||
private const val SCHEME = "workuid"
|
private const val SCHEME = "workuid"
|
||||||
|
|
||||||
@@ -39,20 +41,18 @@ class PausingReceiver(
|
|||||||
addAction(ACTION_PAUSE)
|
addAction(ACTION_PAUSE)
|
||||||
addAction(ACTION_RESUME)
|
addAction(ACTION_RESUME)
|
||||||
addAction(ACTION_SKIP)
|
addAction(ACTION_SKIP)
|
||||||
|
addAction(ACTION_SKIP_ALL)
|
||||||
addDataScheme(SCHEME)
|
addDataScheme(SCHEME)
|
||||||
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
|
addDataPath(id.toString(), PatternMatcher.PATTERN_LITERAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE)
|
fun getPauseIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_PAUSE)
|
||||||
.setData(Uri.parse("$SCHEME://$id"))
|
|
||||||
.setPackage(context.packageName)
|
|
||||||
.putExtra(EXTRA_UUID, id.toString())
|
|
||||||
|
|
||||||
fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(
|
fun getResumeIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_RESUME)
|
||||||
if (skipError) ACTION_SKIP else ACTION_RESUME,
|
|
||||||
).setData(Uri.parse("$SCHEME://$id"))
|
fun getSkipIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP)
|
||||||
.setPackage(context.packageName)
|
|
||||||
.putExtra(EXTRA_UUID, id.toString())
|
fun getSkipAllIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP_ALL)
|
||||||
|
|
||||||
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
|
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
|
||||||
context,
|
context,
|
||||||
@@ -62,13 +62,27 @@ class PausingReceiver(
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) =
|
fun createResumePendingIntent(context: Context, id: UUID) =
|
||||||
PendingIntentCompat.getBroadcast(
|
PendingIntentCompat.getBroadcast(
|
||||||
context,
|
context,
|
||||||
0,
|
0,
|
||||||
getResumeIntent(context, id, skipError),
|
getResumeIntent(context, id),
|
||||||
0,
|
0,
|
||||||
false,
|
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_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
|
||||||
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
|
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
|
||||||
R.id.button_more -> SuggestionsActivity.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 -> {
|
R.id.button_random -> {
|
||||||
viewModel.openRandom()
|
viewModel.openRandom()
|
||||||
return
|
return
|
||||||
@@ -257,6 +257,7 @@ class ExploreFragment :
|
|||||||
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
Intent.ACTION_DELETE
|
Intent.ACTION_DELETE
|
||||||
} else {
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
Intent.ACTION_UNINSTALL_PACKAGE
|
Intent.ACTION_UNINSTALL_PACKAGE
|
||||||
}
|
}
|
||||||
context?.startActivity(Intent(action, uri))
|
context?.startActivity(Intent(action, uri))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.RoomWarnings
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ abstract class FavouriteCategoriesDao {
|
|||||||
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
|
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
|
||||||
protected abstract suspend fun getMaxSortKey(): Int?
|
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")
|
@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>
|
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")
|
@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>>
|
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")
|
@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>
|
abstract suspend fun findCategoriesIds(mangaId: Long): List<Long>
|
||||||
|
|
||||||
|
|||||||
@@ -125,11 +125,6 @@ class FavouritesRepository @Inject constructor(
|
|||||||
return db.getFavouritesDao().findCategoriesCount(mangaId) != 0
|
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> {
|
suspend fun getCategoriesIds(mangaId: Long): Set<Long> {
|
||||||
return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
|
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.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
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.children
|
||||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||||
import org.koitharu.kotatsu.core.util.ext.filterWith
|
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.input.LocalMangaInput
|
||||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||||
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
|
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.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -47,10 +47,10 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
private val storageManager: LocalStorageManager,
|
private val storageManager: LocalStorageManager,
|
||||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
|
private val lock: MangaLock,
|
||||||
) : MangaRepository {
|
) : MangaRepository {
|
||||||
|
|
||||||
override val source = LocalMangaSource
|
override val source = LocalMangaSource
|
||||||
private val locks = MultiMutex<Long>()
|
|
||||||
private val localMappingCache = LocalMangaMappingCache()
|
private val localMappingCache = LocalMangaMappingCache()
|
||||||
|
|
||||||
override val isMultipleTagsSupported: Boolean = true
|
override val isMultipleTagsSupported: Boolean = true
|
||||||
@@ -88,7 +88,7 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
|
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
|
||||||
SortOrder.NEWEST,
|
SortOrder.NEWEST,
|
||||||
SortOrder.UPDATED,
|
SortOrder.UPDATED,
|
||||||
-> list.sortByDescending { it.createdAt }
|
-> list.sortByDescending { it.createdAt }
|
||||||
|
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
@@ -120,17 +120,12 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
|
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) = lock.withLock(manga) {
|
||||||
lockManga(manga.id)
|
val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
|
||||||
try {
|
"Manga is not stored on local storage"
|
||||||
val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
|
}.manga
|
||||||
"Manga is not stored on local storage"
|
LocalMangaUtil(subject).deleteChapters(ids)
|
||||||
}.manga
|
localStorageChanges.emit(LocalManga(subject))
|
||||||
LocalMangaUtil(subject).deleteChapters(ids)
|
|
||||||
localStorageChanges.emit(LocalManga(subject))
|
|
||||||
} finally {
|
|
||||||
unlockManga(manga.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getRemoteManga(localManga: Manga): Manga? {
|
suspend fun getRemoteManga(localManga: Manga): Manga? {
|
||||||
@@ -193,7 +188,7 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cleanup(): Boolean {
|
suspend fun cleanup(): Boolean {
|
||||||
if (locks.isNotEmpty()) {
|
if (lock.isNotEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val dirs = storageManager.getWriteableDirs()
|
val dirs = storageManager.getWriteableDirs()
|
||||||
@@ -207,14 +202,6 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun lockManga(id: Long) {
|
|
||||||
locks.lock(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unlockManga(id: Long) {
|
|
||||||
locks.unlock(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getRawList(): ArrayList<LocalManga> {
|
private suspend fun getRawList(): ArrayList<LocalManga> {
|
||||||
val files = getAllFiles().toList() // TODO remove toList()
|
val files = getAllFiles().toList() // TODO remove toList()
|
||||||
return coroutineScope {
|
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"
|
style="?materialButtonOutlinedStyle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:text="@string/pause"
|
android:text="@string/pause"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintEnd_toStartOf="@id/button_resume"
|
tools:visibility="visible" />
|
||||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
|
|
||||||
tools:visibility="gone" />
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/button_resume"
|
android:id="@+id/button_resume"
|
||||||
style="?materialButtonOutlinedStyle"
|
style="?materialButtonOutlinedStyle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:text="@string/resume"
|
android:text="@string/resume"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintEnd_toStartOf="@id/button_cancel"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
|
|
||||||
tools:visibility="visible" />
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -174,12 +166,17 @@
|
|||||||
style="?materialButtonOutlinedStyle"
|
style="?materialButtonOutlinedStyle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:text="@string/skip"
|
android:text="@string/skip"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintEnd_toStartOf="@id/button_resume"
|
tools:visibility="visible" />
|
||||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
|
|
||||||
|
<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" />
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -187,14 +184,26 @@
|
|||||||
style="?materialButtonOutlinedStyle"
|
style="?materialButtonOutlinedStyle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:text="@android:string/cancel"
|
android:text="@android:string/cancel"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
|
|
||||||
tools:visibility="visible" />
|
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>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="72dp"
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/list_selector"
|
android:background="@drawable/list_selector"
|
||||||
android:clipChildren="false">
|
android:clipChildren="false"
|
||||||
|
android:minHeight="72dp">
|
||||||
|
|
||||||
<com.google.android.material.imageview.ShapeableImageView
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
android:id="@+id/imageView_cover"
|
android:id="@+id/imageView_cover"
|
||||||
@@ -14,10 +15,7 @@
|
|||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
|
|
||||||
android:layout_marginBottom="16dp"
|
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
||||||
@@ -33,7 +31,6 @@
|
|||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/textView_summary"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
||||||
@@ -44,14 +41,14 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:paddingBottom="16dp"
|
||||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
||||||
tools:text="@tools:sample/lorem/random" />
|
tools:text="@tools:sample/lorem[2]" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<string name="chapter_d_of_d">Chapter %1$d of %2$d</string>
|
<string name="chapter_d_of_d">Chapter %1$d of %2$d</string>
|
||||||
<string name="close">Close</string>
|
<string name="close">Close</string>
|
||||||
<string name="try_again">Try again</string>
|
<string name="try_again">Try again</string>
|
||||||
|
<string name="retry">Retry</string>
|
||||||
<string name="clear_history">Clear history</string>
|
<string name="clear_history">Clear history</string>
|
||||||
<string name="nothing_found">Nothing found</string>
|
<string name="nothing_found">Nothing found</string>
|
||||||
<string name="history_is_empty">No history yet</string>
|
<string name="history_is_empty">No history yet</string>
|
||||||
@@ -459,6 +460,7 @@
|
|||||||
<string name="in_progress">In progress</string>
|
<string name="in_progress">In progress</string>
|
||||||
<string name="disable_nsfw">Disable NSFW</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">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="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="advanced">Advanced</string>
|
||||||
<string name="manga_list">Manga list</string>
|
<string name="manga_list">Manga list</string>
|
||||||
@@ -624,8 +626,12 @@
|
|||||||
<string name="hours_short">%d h</string>
|
<string name="hours_short">%d h</string>
|
||||||
<!-- Short minutes format pattern -->
|
<!-- Short minutes format pattern -->
|
||||||
<string name="minutes_short">%d m</string>
|
<string name="minutes_short">%d m</string>
|
||||||
|
<!-- Short seconds format pattern -->
|
||||||
|
<string name="seconds_short">%d s</string>
|
||||||
<!-- Short hours and minutes format pattern -->
|
<!-- Short hours and minutes format pattern -->
|
||||||
<string name="hours_minutes_short">%1$d h %2$d m</string>
|
<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="fix">Fix</string>
|
||||||
<string name="missing_storage_permission">There is no permission to access manga on external storage</string>
|
<string name="missing_storage_permission">There is no permission to access manga on external storage</string>
|
||||||
<string name="last_used">Last used</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">Show quick filters</string>
|
||||||
<string name="show_quick_filters_summary">Provides the ability to filter manga lists by certain parameters</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="sfw">SFW</string>
|
||||||
|
<string name="skip_all">Skip all</string>
|
||||||
|
<string name="stuck">Stuck</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user