Downloading improvements

This commit is contained in:
Koitharu
2024-08-24 10:10:31 +03:00
parent 6e8a1cd6af
commit 85da41be9a
29 changed files with 486 additions and 249 deletions

View File

@@ -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'
} }

View File

@@ -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()
} }

View File

@@ -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)
}
}

View File

@@ -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>,

View File

@@ -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>")

View File

@@ -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)
}
}

View File

@@ -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,
)
}

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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)
}
} }

View File

@@ -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,

View File

@@ -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

View File

@@ -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()
} }

View File

@@ -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"

View File

@@ -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> {

View File

@@ -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())
} }
} }

View File

@@ -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))

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()
} }

View File

@@ -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 {

View File

@@ -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>()

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>