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'
minSdk = 21
targetSdk = 35
versionCode = 662
versionName = '7.5-a2'
versionCode = 663
versionName = '7.5-a3'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -56,6 +56,7 @@ android {
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi',
@@ -82,7 +83,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:f91ff0b9d0') {
implementation('com.github.KotatsuApp:kotatsu-parsers:939b6b1e46') {
exclude group: 'org.json', module: 'json'
}

View File

@@ -5,7 +5,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class MultiMutex<T : Any> : Set<T> {
open class MultiMutex<T : Any> : Set<T> {
private val delegates = ArrayMap<T, Mutex>()
@@ -20,19 +20,26 @@ class MultiMutex<T : Any> : Set<T> {
elements.all { x -> delegates.containsKey(x) }
}
override fun isEmpty(): Boolean {
return delegates.isEmpty()
override fun isEmpty(): Boolean = delegates.isEmpty()
override fun iterator(): Iterator<T> = synchronized(delegates) {
delegates.keys.toList()
}.iterator()
fun isLocked(element: T): Boolean = synchronized(delegates) {
delegates[element]?.isLocked == true
}
override fun iterator(): Iterator<T> {
return delegates.keys.iterator()
fun tryLock(element: T): Boolean {
val mutex = synchronized(delegates) {
delegates.getOrPut(element, ::Mutex)
}
return mutex.tryLock()
}
suspend fun lock(element: T) {
val mutex = synchronized(delegates) {
delegates.getOrPut(element) {
Mutex()
}
delegates.getOrPut(element, ::Mutex)
}
mutex.lock()
}

View File

@@ -1,11 +1,14 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.res.Resources
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
// TODO: Use Java 9's LocalDate.ofInstant().
@@ -33,3 +36,17 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
}
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
fun Resources.formatDurationShort(millis: Long): String? {
val hours = TimeUnit.MILLISECONDS.toHours(millis).toInt()
val minutes = (TimeUnit.MILLISECONDS.toMinutes(millis) % 60).toInt()
val seconds = (TimeUnit.MILLISECONDS.toSeconds(millis) % 60).toInt()
return when {
hours == 0 && minutes == 0 && seconds == 0 -> null
hours != 0 && minutes != 0 -> getString(R.string.hours_minutes_short, hours, minutes)
hours != 0 -> getString(R.string.hours_short, hours)
minutes != 0 && seconds != 0 -> getString(R.string.minutes_seconds_short, minutes, seconds)
minutes != 0 -> getString(R.string.minutes_short, minutes)
else -> getString(R.string.seconds_short, seconds)
}
}

View File

@@ -4,6 +4,8 @@ import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
@@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import org.koitharu.kotatsu.R
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
@@ -87,6 +90,20 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
}
}
fun tickerFlow(interval: Long, timeUnit: TimeUnit): Flow<Long> = flow {
while (true) {
emit(SystemClock.elapsedRealtime())
delay(timeUnit.toMillis(interval))
}
}
fun <T> Flow<T>.withTicker(interval: Long, timeUnit: TimeUnit) = channelFlow<T> {
onCompletion { cause ->
close(cause)
}.combine(tickerFlow(interval, timeUnit)) { x, _ -> x }
.collectLatest { send(it) }
}
@Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ActivityNotFoundException
import android.content.res.Resources
import androidx.annotation.DrawableRes
import androidx.collection.arraySetOf
import coil.network.HttpException
import okio.FileNotFoundException
import okio.IOException
@@ -23,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
@@ -47,7 +47,20 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message)
is TooManyRequestExceptions -> {
val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay)
} else {
null
}
if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else {
resources.getString(R.string.too_many_requests_message)
}
}
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
@@ -107,7 +120,25 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe
}
fun Throwable.isReportable(): Boolean {
return this is Error || this.javaClass in reportableExceptions
if (this is Error) {
return true
}
if (this is CaughtException) {
return cause?.isReportable() == true
}
if (ExceptionResolver.canResolve(this)) {
return false
}
if (this is ParseException
|| this.isNetworkError()
|| this is CloudFlareBlockedException
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
) {
return false
}
return true
}
fun Throwable.isNetworkError(): Boolean {
@@ -119,15 +150,6 @@ fun Throwable.report() {
exception.sendWithAcra()
}
private val reportableExceptions = arraySetOf<Class<*>>(
RuntimeException::class.java,
IllegalStateException::class.java,
IllegalArgumentException::class.java,
ConcurrentModificationException::class.java,
UnsupportedOperationException::class.java,
NoDataReceivedException::class.java,
)
fun Throwable.isWebViewUnavailable(): Boolean {
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")

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 isPaused: Boolean = false,
val isStopped: Boolean = false,
val error: String? = null,
val error: Throwable? = null,
val errorMessage: String? = null,
val totalChapters: Int = 0,
val currentChapter: Int = 0,
val totalPages: Int = 0,
val currentPage: Int = 0,
val eta: Long = -1L,
val isStuck: Boolean = false,
val localManga: LocalManga? = null,
val downloadedChapters: Int = 0,
val timestamp: Long = System.currentTimeMillis(),
@@ -39,8 +41,9 @@ data class DownloadState(
.putInt(DATA_MAX, max)
.putInt(DATA_PROGRESS, progress)
.putLong(DATA_ETA, eta)
.putBoolean(DATA_STUCK, isStuck)
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error)
.putString(DATA_ERROR, errorMessage)
.putInt(DATA_CHAPTERS, downloadedChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
@@ -53,6 +56,7 @@ data class DownloadState(
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter_cnt"
private const val DATA_ETA = "eta"
private const val DATA_STUCK = "stuck"
const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate"
@@ -72,6 +76,8 @@ data class DownloadState(
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
fun isStuck(data: Data): Boolean = data.getBoolean(DATA_STUCK, false)
fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)

View File

@@ -45,8 +45,9 @@ fun downloadItemAD(
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item, skip = false)
R.id.button_skip -> listener.onResumeClick(item, skip = true)
R.id.button_resume -> listener.onResumeClick(item)
R.id.button_skip -> listener.onSkipClick(item)
R.id.button_skip_all -> listener.onSkipAllClick(item)
R.id.button_pause -> listener.onPauseClick(item)
R.id.imageView_expand -> listener.onExpandClick(item)
else -> listener.onItemClick(item, v)
@@ -65,6 +66,7 @@ fun downloadItemAD(
binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
binding.buttonSkip.setOnClickListener(clickListener)
binding.buttonSkipAll.setOnClickListener(clickListener)
binding.imageViewExpand.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
@@ -136,9 +138,14 @@ fun downloadItemAD(
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.textAndVisible = if (item.isPaused) item.error else item.getEtaString()
binding.textViewDetails.textAndVisible = when {
item.isPaused -> item.getErrorMessage(context)
item.isStuck -> context.getString(R.string.stuck)
else -> item.getEtaString()
}
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = item.isPaused
binding.buttonResume.setText(if (item.error == null) R.string.resume else R.string.retry)
binding.buttonSkip.isVisible = item.isPaused && item.error != null
binding.buttonPause.isVisible = item.canPause
}
@@ -171,7 +178,7 @@ fun downloadItemAD(
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.textAndVisible = item.error
binding.textViewDetails.textAndVisible = item.getErrorMessage(context)
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false

View File

@@ -8,7 +8,11 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
fun onPauseClick(item: DownloadItemModel)
fun onResumeClick(item: DownloadItemModel, skip: Boolean)
fun onResumeClick(item: DownloadItemModel)
fun onSkipClick(item: DownloadItemModel)
fun onSkipAllClick(item: DownloadItemModel)
fun onExpandClick(item: DownloadItemModel)
}

View File

@@ -1,15 +1,22 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.graphics.Color
import android.text.format.DateUtils
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.work.WorkInfo
import coil.memory.MemoryCache
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
import java.util.UUID
import com.google.android.material.R as materialR
data class DownloadItemModel(
val id: UUID,
@@ -21,6 +28,7 @@ data class DownloadItemModel(
val max: Int,
val progress: Int,
val eta: Long,
val isStuck: Boolean,
val timestamp: Instant,
val chaptersDownloaded: Int,
val isExpanded: Boolean,
@@ -51,6 +59,18 @@ data class DownloadItemModel(
null
}
fun getErrorMessage(context: Context): CharSequence? = if (error != null) {
buildSpannedString {
bold {
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
append(error)
}
}
}
} else {
null
}
override fun compareTo(other: DownloadItemModel): Int {
return timestamp.compareTo(other.timestamp)
}

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
@@ -22,7 +20,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.PausingReceiver
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import javax.inject.Inject
@@ -34,6 +32,9 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var scheduler: DownloadWorker.Scheduler
private val viewModel by viewModels<DownloadsViewModel>()
private lateinit var selectionController: ListSelectionController
@@ -102,11 +103,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
}
override fun onPauseClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getPauseIntent(this, item.id))
scheduler.pause(item.id)
}
override fun onResumeClick(item: DownloadItemModel, skip: Boolean) {
sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip))
override fun onResumeClick(item: DownloadItemModel) {
scheduler.resume(item.id)
}
override fun onSkipClick(item: DownloadItemModel) {
scheduler.skip(item.id)
}
override fun onSkipAllClick(item: DownloadItemModel) {
scheduler.skipAll(item.id)
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
@@ -171,9 +180,4 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
menu.findItem(R.id.action_remove)?.isVisible = canRemove
return super.onPrepareActionMode(controller, mode, menu)
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
}

View File

@@ -143,7 +143,7 @@ class DownloadsViewModel @Inject constructor(
var isResumed = false
for (work in snapshot) {
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
workScheduler.resume(work.id, skipError = false)
workScheduler.resume(work.id)
isResumed = true
}
}
@@ -156,7 +156,7 @@ class DownloadsViewModel @Inject constructor(
val snapshot = works.value ?: return
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.resume(work.id, skipError = false)
workScheduler.resume(work.id)
}
}
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
@@ -268,6 +268,7 @@ class DownloadsViewModel @Inject constructor(
max = DownloadState.getMax(workData),
progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData),
isStuck = DownloadState.isStuck(workData),
timestamp = DownloadState.getTimestamp(workData),
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded,

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.download.ui.worker
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.text.format.DateUtils
import androidx.core.app.NotificationChannelCompat
@@ -21,8 +22,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.isReportable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
@@ -57,7 +60,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
private val queueIntent = PendingIntentCompat.getActivity(
context,
0,
DownloadsActivity.newIntent(context),
Intent(context, DownloadsActivity::class.java),
0,
false,
)
@@ -82,7 +85,15 @@ class DownloadNotificationFactory @AssistedInject constructor(
NotificationCompat.Action(
R.drawable.ic_action_resume,
context.getString(R.string.resume),
PausingReceiver.createResumePendingIntent(context, uuid, skipError = false),
PausingReceiver.createResumePendingIntent(context, uuid),
)
}
private val actionRetry by lazy {
NotificationCompat.Action(
R.drawable.ic_retry,
context.getString(R.string.retry),
actionResume.actionIntent,
)
}
@@ -90,7 +101,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
NotificationCompat.Action(
R.drawable.ic_action_skip,
context.getString(R.string.skip),
PausingReceiver.createResumePendingIntent(context, uuid, skipError = true),
PausingReceiver.createSkipPendingIntent(context, uuid),
)
}
@@ -160,8 +171,14 @@ class DownloadNotificationFactory @AssistedInject constructor(
} else {
null
}
if (state.error != null) {
builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error))
if (state.errorMessage != null) {
builder.setContentText(
context.getString(
R.string.download_summary_pattern,
percent,
state.errorMessage,
),
)
} else {
builder.setContentText(percent)
}
@@ -170,9 +187,11 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setOngoing(true)
builder.setSmallIcon(R.drawable.ic_stat_paused)
builder.addAction(actionCancel)
builder.addAction(actionResume)
if (state.error != null) {
if (state.errorMessage != null) {
builder.addAction(actionRetry)
builder.addAction(actionSkip)
} else {
builder.addAction(actionResume)
}
}
@@ -180,18 +199,27 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(state.error)
builder.setContentText(state.errorMessage)
builder.setAutoCancel(true)
builder.setOngoing(false)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error))
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.errorMessage))
if (state.error.isReportable()) {
builder.addAction(
NotificationCompat.Action(
0,
context.getString(R.string.report),
ErrorReporterReceiver.getPendingIntent(context, state.error),
),
)
}
}
else -> {
builder.setProgress(state.max, state.progress, false)
builder.setContentText(getProgressString(state.percent, state.eta))
builder.setContentText(getProgressString(state.percent, state.eta, state.isStuck))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
@@ -202,20 +230,20 @@ class DownloadNotificationFactory @AssistedInject constructor(
return builder.build()
}
private fun getProgressString(percent: Float, eta: Long): CharSequence? {
private fun getProgressString(percent: Float, eta: Long, isStuck: Boolean): CharSequence? {
val percentString = if (percent >= 0f) {
context.getString(R.string.percent_string_pattern, (percent * 100).format())
} else {
null
}
val etaString = if (eta > 0L) {
DateUtils.getRelativeTimeSpanString(
val etaString = when {
eta <= 0L -> null
isStuck -> context.getString(R.string.stuck)
else -> DateUtils.getRelativeTimeSpanString(
eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
} else {
null
}
return when {
percentString == null && etaString == null -> null

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.worker
import android.content.Intent
import android.view.View
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
@@ -18,7 +19,7 @@ class DownloadStartedObserver(
snackbar.anchorView = it.bottomNav
}
snackbar.setAction(R.string.details) {
it.context.startActivity(DownloadsActivity.newIntent(it.context))
it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
}
snackbar.show()
}

View File

@@ -30,6 +30,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@@ -61,8 +62,10 @@ import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.withTicker
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator
import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
import org.koitharu.kotatsu.download.domain.DownloadProgress
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges
@@ -70,6 +73,7 @@ import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.MangaLock
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.parsers.model.Manga
@@ -91,6 +95,7 @@ class DownloadWorker @AssistedInject constructor(
@MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val mangaLock: MangaLock,
private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
@@ -108,7 +113,7 @@ class DownloadWorker @AssistedInject constructor(
private val currentState: DownloadState
get() = checkNotNull(lastPublishedState)
private val timeLeftEstimator = TimeLeftEstimator()
private val etaEstimator = RealtimeEtaEstimator()
private val notificationThrottler = Throttler(400)
override suspend fun doWork(): Result {
@@ -130,17 +135,16 @@ class DownloadWorker @AssistedInject constructor(
notificationManager.notify(id.hashCode(), notification)
}
Result.failure(
currentState.copy(eta = -1L).toWorkData(),
currentState.copy(eta = -1L, isStuck = false).toWorkData(),
)
} catch (e: IOException) {
e.printStackTraceDebug()
Result.retry()
} catch (e: Exception) {
e.printStackTraceDebug()
Result.failure(
currentState.copy(
error = e.getDisplayMessage(applicationContext.resources),
error = e,
errorMessage = e.getDisplayMessage(applicationContext.resources),
eta = -1L,
isStuck = false,
).toWorkData(),
)
} finally {
@@ -169,7 +173,7 @@ class DownloadWorker @AssistedInject constructor(
var manga = subject
val chaptersToSkip = excludedIds.toMutableSet()
val pausingReceiver = PausingReceiver(id, PausingHandle.current())
withMangaLock(manga) {
mangaLock.withLock(manga) {
ContextCompat.registerReceiver(
applicationContext,
pausingReceiver,
@@ -229,15 +233,23 @@ class DownloadWorker @AssistedInject constructor(
}
}
}
}.collect {
}.map {
DownloadProgress(
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageCounter.getAndIncrement(),
)
}.withTicker(2L, TimeUnit.SECONDS).collect { progress ->
publishState(
currentState.copy(
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageCounter.incrementAndGet(),
totalChapters = progress.totalChapters,
currentChapter = progress.currentChapter,
totalPages = progress.totalPages,
currentPage = progress.currentPage,
isIndeterminate = false,
eta = timeLeftEstimator.getEta(),
eta = etaEstimator.getEta(),
isStuck = etaEstimator.isStuck(),
),
)
}
@@ -248,15 +260,20 @@ class DownloadWorker @AssistedInject constructor(
}
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
}
publishState(currentState.copy(isIndeterminate = true, eta = -1L))
publishState(currentState.copy(isIndeterminate = true, eta = -1L, isStuck = false))
output.mergeWithExisting()
output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga()
localStorageChanges.emit(localManga)
publishState(currentState.copy(localManga = localManga, eta = -1L))
publishState(currentState.copy(localManga = localManga, eta = -1L, isStuck = false))
} catch (e: Exception) {
if (e !is CancellationException) {
publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources)))
publishState(
currentState.copy(
error = e,
errorMessage = e.getDisplayMessage(applicationContext.resources),
),
)
}
throw e
} finally {
@@ -281,12 +298,19 @@ class DownloadWorker @AssistedInject constructor(
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
val retryDelay = if (e is TooManyRequestExceptions) {
e.getRetryDelay()
} else {
DOWNLOAD_ERROR_DELAY
}
if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) {
publishState(
currentState.copy(
isPaused = true,
error = e.getDisplayMessage(applicationContext.resources),
error = e,
errorMessage = e.getDisplayMessage(applicationContext.resources),
eta = -1L,
isStuck = false,
),
)
countDown = MAX_FAILSAFE_ATTEMPTS
@@ -298,15 +322,10 @@ class DownloadWorker @AssistedInject constructor(
return null
}
} finally {
publishState(currentState.copy(isPaused = false, error = null))
publishState(currentState.copy(isPaused = false, error = null, errorMessage = null))
}
} else {
countDown--
val retryDelay = if (e is TooManyRequestExceptions) {
e.retryAfter + DOWNLOAD_ERROR_DELAY
} else {
DOWNLOAD_ERROR_DELAY
}
delay(retryDelay)
}
}
@@ -316,7 +335,7 @@ class DownloadWorker @AssistedInject constructor(
private suspend fun checkIsPaused() {
val pausingHandle = PausingHandle.current()
if (pausingHandle.isPaused) {
publishState(currentState.copy(isPaused = true, eta = -1L))
publishState(currentState.copy(isPaused = true, eta = -1L, isStuck = false))
try {
pausingHandle.awaitResumed()
} finally {
@@ -354,9 +373,9 @@ class DownloadWorker @AssistedInject constructor(
val previousState = currentState
lastPublishedState = state
if (previousState.isParticularProgress && state.isParticularProgress) {
timeLeftEstimator.tick(state.progress, state.max)
etaEstimator.onProgressChanged(state.progress, state.max)
} else {
timeLeftEstimator.emptyTick()
etaEstimator.reset()
notificationThrottler.reset()
}
val notification = notificationFactory.create(state)
@@ -399,13 +418,6 @@ class DownloadWorker @AssistedInject constructor(
return result
}
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
@Reusable
class Scheduler @Inject constructor(
@ApplicationContext private val context: Context,
@@ -458,15 +470,21 @@ class DownloadWorker @AssistedInject constructor(
workManager.cancelAllWorkByTag(TAG).await()
}
fun pause(id: UUID) {
val intent = PausingReceiver.getPauseIntent(context, id)
context.sendBroadcast(intent)
}
fun pause(id: UUID) = context.sendBroadcast(
PausingReceiver.getPauseIntent(context, id),
)
fun resume(id: UUID, skipError: Boolean) {
val intent = PausingReceiver.getResumeIntent(context, id, skipError)
context.sendBroadcast(intent)
}
fun resume(id: UUID) = context.sendBroadcast(
PausingReceiver.getResumeIntent(context, id),
)
fun skip(id: UUID) = context.sendBroadcast(
PausingReceiver.getSkipIntent(context, id),
)
fun skipAll(id: UUID) = context.sendBroadcast(
PausingReceiver.getSkipAllIntent(context, id),
)
suspend fun delete(id: UUID) {
workManager.deleteWork(id)
@@ -526,7 +544,8 @@ class DownloadWorker @AssistedInject constructor(
const val MAX_FAILSAFE_ATTEMPTS = 2
const val MAX_PAGES_PARALLELISM = 4
const val DOWNLOAD_ERROR_DELAY = 500L
const val DOWNLOAD_ERROR_DELAY = 2_000L
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
const val SLOWDOWN_DELAY = 200L
const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters"

View File

@@ -10,7 +10,10 @@ import kotlin.coroutines.CoroutineContext
class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
private val paused = MutableStateFlow(false)
private val isSkipError = MutableStateFlow(false)
private val skipError = MutableStateFlow(false)
@Volatile
private var skipAllErrors = false
@get:AnyThread
val isPaused: Boolean
@@ -27,18 +30,30 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
}
@AnyThread
fun resume(skipError: Boolean) {
isSkipError.value = skipError
fun resume() {
skipError.value = false
paused.value = false
}
@AnyThread
fun skip() {
skipError.value = true
paused.value = false
}
@AnyThread
fun skipAll() {
skipAllErrors = true
skip()
}
suspend fun yield() {
if (paused.value) {
paused.first { !it }
}
}
fun skipCurrentError(): Boolean = isSkipError.compareAndSet(expect = true, update = false)
fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = skipAllErrors)
companion object : CoroutineContext.Key<PausingHandle> {

View File

@@ -21,8 +21,9 @@ class PausingReceiver(
return
}
when (intent.action) {
ACTION_RESUME -> pausingHandle.resume(skipError = false)
ACTION_SKIP -> pausingHandle.resume(skipError = true)
ACTION_RESUME -> pausingHandle.resume()
ACTION_SKIP -> pausingHandle.skip()
ACTION_SKIP_ALL -> pausingHandle.skipAll()
ACTION_PAUSE -> pausingHandle.pause()
}
}
@@ -32,6 +33,7 @@ class PausingReceiver(
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP"
private const val ACTION_SKIP_ALL = "org.koitharu.kotatsu.download.SKIP_ALL"
private const val EXTRA_UUID = "uuid"
private const val SCHEME = "workuid"
@@ -39,20 +41,18 @@ class PausingReceiver(
addAction(ACTION_PAUSE)
addAction(ACTION_RESUME)
addAction(ACTION_SKIP)
addAction(ACTION_SKIP_ALL)
addDataScheme(SCHEME)
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
addDataPath(id.toString(), PatternMatcher.PATTERN_LITERAL)
}
fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE)
.setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString())
fun getPauseIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_PAUSE)
fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(
if (skipError) ACTION_SKIP else ACTION_RESUME,
).setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString())
fun getResumeIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_RESUME)
fun getSkipIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP)
fun getSkipAllIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP_ALL)
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
context,
@@ -62,13 +62,27 @@ class PausingReceiver(
false,
)
fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) =
fun createResumePendingIntent(context: Context, id: UUID) =
PendingIntentCompat.getBroadcast(
context,
0,
getResumeIntent(context, id, skipError),
getResumeIntent(context, id),
0,
false,
)
fun createSkipPendingIntent(context: Context, id: UUID) =
PendingIntentCompat.getBroadcast(
context,
0,
getSkipIntent(context, id),
0,
false,
)
private fun createIntent(context: Context, id: UUID, action: String) = Intent(action)
.setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString())
}
}

View File

@@ -129,7 +129,7 @@ class ExploreFragment :
R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
R.id.button_more -> SuggestionsActivity.newIntent(v.context)
R.id.button_downloads -> DownloadsActivity.newIntent(v.context)
R.id.button_downloads -> Intent(v.context, DownloadsActivity::class.java)
R.id.button_random -> {
viewModel.openRandom()
return
@@ -257,6 +257,7 @@ class ExploreFragment :
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Intent.ACTION_DELETE
} else {
@Suppress("DEPRECATION")
Intent.ACTION_UNINSTALL_PACKAGE
}
context?.startActivity(Intent(action, uri))

View File

@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RoomWarnings
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@@ -51,6 +52,7 @@ abstract class FavouriteCategoriesDao {
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
protected abstract suspend fun getMaxSortKey(): Int?
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // for the new_chapters column
@Query("SELECT favourite_categories.*, (SELECT SUM(chapters_new) FROM tracks WHERE tracks.manga_id IN (SELECT manga_id FROM favourites WHERE favourites.category_id = favourite_categories.category_id)) AS new_chapters FROM favourite_categories WHERE track = 1 AND show_in_lib = 1 AND deleted_at = 0 AND new_chapters > 0 ORDER BY new_chapters DESC LIMIT :limit")
abstract suspend fun getMostUpdatedCategories(limit: Int): List<FavouriteCategoryEntity>

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")
abstract fun observeCategories(mangaId: Long): Flow<List<FavouriteCategoryEntity>>
@Deprecated("")
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaId: Long): List<Long>

View File

@@ -125,11 +125,6 @@ class FavouritesRepository @Inject constructor(
return db.getFavouritesDao().findCategoriesCount(mangaId) != 0
}
@Deprecated("")
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
}
suspend fun getCategoriesIds(mangaId: Long): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
}

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.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.filterWith
@@ -24,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.local.domain.MangaLock
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
@@ -47,10 +47,10 @@ class LocalMangaRepository @Inject constructor(
private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val settings: AppSettings,
private val lock: MangaLock,
) : MangaRepository {
override val source = LocalMangaSource
private val locks = MultiMutex<Long>()
private val localMappingCache = LocalMangaMappingCache()
override val isMultipleTagsSupported: Boolean = true
@@ -88,7 +88,7 @@ class LocalMangaRepository @Inject constructor(
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
SortOrder.NEWEST,
SortOrder.UPDATED,
-> list.sortByDescending { it.createdAt }
-> list.sortByDescending { it.createdAt }
else -> Unit
}
@@ -120,17 +120,12 @@ class LocalMangaRepository @Inject constructor(
return result
}
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
lockManga(manga.id)
try {
val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
"Manga is not stored on local storage"
}.manga
LocalMangaUtil(subject).deleteChapters(ids)
localStorageChanges.emit(LocalManga(subject))
} finally {
unlockManga(manga.id)
}
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) = lock.withLock(manga) {
val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
"Manga is not stored on local storage"
}.manga
LocalMangaUtil(subject).deleteChapters(ids)
localStorageChanges.emit(LocalManga(subject))
}
suspend fun getRemoteManga(localManga: Manga): Manga? {
@@ -193,7 +188,7 @@ class LocalMangaRepository @Inject constructor(
}
suspend fun cleanup(): Boolean {
if (locks.isNotEmpty()) {
if (lock.isNotEmpty()) {
return false
}
val dirs = storageManager.getWriteableDirs()
@@ -207,14 +202,6 @@ class LocalMangaRepository @Inject constructor(
return true
}
suspend fun lockManga(id: Long) {
locks.lock(id)
}
fun unlockManga(id: Long) {
locks.unlock(id)
}
private suspend fun getRawList(): ArrayList<LocalManga> {
val files = getAllFiles().toList() // TODO remove toList()
return coroutineScope {

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"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:text="@string/pause"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_resume"
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
tools:visibility="gone" />
tools:visibility="visible" />
<Button
android:id="@+id/button_resume"
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:text="@string/resume"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
tools:visibility="visible" />
<Button
@@ -174,12 +166,17 @@
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:text="@string/skip"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_resume"
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
tools:visibility="visible" />
<Button
android:id="@+id/button_skip_all"
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/skip_all"
android:visibility="gone"
tools:visibility="visible" />
<Button
@@ -187,14 +184,26 @@
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:text="@android:string/cancel"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
tools:visibility="visible" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/flow_buttons"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
app:constraint_referenced_ids="button_pause,button_resume,button_skip,button_skip_all,button_cancel"
app:flow_horizontalAlign="end"
app:flow_horizontalBias="1"
app:flow_horizontalGap="8dp"
app:flow_horizontalStyle="packed"
app:flow_wrapMode="chain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -4,9 +4,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_height="wrap_content"
android:background="@drawable/list_selector"
android:clipChildren="false">
android:clipChildren="false"
android:minHeight="72dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
@@ -14,10 +15,7 @@
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
@@ -33,7 +31,6 @@
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceTitleSmall"
app:layout_constraintBottom_toTopOf="@+id/textView_summary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
@@ -44,14 +41,14 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingBottom="16dp"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toBottomOf="@+id/textView_title"
tools:text="@tools:sample/lorem/random" />
tools:text="@tools:sample/lorem[2]" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -19,6 +19,7 @@
<string name="chapter_d_of_d">Chapter %1$d of %2$d</string>
<string name="close">Close</string>
<string name="try_again">Try again</string>
<string name="retry">Retry</string>
<string name="clear_history">Clear history</string>
<string name="nothing_found">Nothing found</string>
<string name="history_is_empty">No history yet</string>
@@ -459,6 +460,7 @@
<string name="in_progress">In progress</string>
<string name="disable_nsfw">Disable NSFW</string>
<string name="too_many_requests_message">Too many requests. Try again later</string>
<string name="too_many_requests_message_retry">Too many requests. Try again after %s</string>
<string name="related_manga_summary">Show a list of related manga. In some cases it may be inaccurate or missing</string>
<string name="advanced">Advanced</string>
<string name="manga_list">Manga list</string>
@@ -624,8 +626,12 @@
<string name="hours_short">%d h</string>
<!-- Short minutes format pattern -->
<string name="minutes_short">%d m</string>
<!-- Short seconds format pattern -->
<string name="seconds_short">%d s</string>
<!-- Short hours and minutes format pattern -->
<string name="hours_minutes_short">%1$d h %2$d m</string>
<!-- Short minutes and seconds format pattern -->
<string name="minutes_seconds_short">%1$d m %2$d s</string>
<string name="fix">Fix</string>
<string name="missing_storage_permission">There is no permission to access manga on external storage</string>
<string name="last_used">Last used</string>
@@ -677,4 +683,6 @@
<string name="show_quick_filters">Show quick filters</string>
<string name="show_quick_filters_summary">Provides the ability to filter manga lists by certain parameters</string>
<string name="sfw">SFW</string>
<string name="skip_all">Skip all</string>
<string name="stuck">Stuck</string>
</resources>