Improve downloads list

This commit is contained in:
Koitharu
2023-05-06 13:08:57 +03:00
parent 41ac50c76a
commit 632b42ea86
26 changed files with 701 additions and 650 deletions

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
@@ -119,6 +120,7 @@ class DetailsActivity :
binding.buttonDropdown.isVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(binding.containerDetails))
addMenuProvider(
DetailsMenuProvider(

View File

@@ -1,277 +0,0 @@
package org.koitharu.kotatsu.download.domain
import android.app.Service
import android.content.Context
import android.webkit.MimeTypeMap
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ServiceScoped
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File
import java.util.UUID
import javax.inject.Inject
private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 150L
@ServiceScoped
class DownloadManager @Inject constructor(
service: Service,
@ApplicationContext private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) {
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width,
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val semaphore = Semaphore(settings.downloadsParallelism)
private val coroutineScope = (service as LifecycleService).lifecycleScope
fun downloadManga(
manga: Manga,
chaptersIds: LongArray?,
startId: UUID,
): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(uuid = startId, manga = manga),
)
val pausingHandle = PausingHandle()
val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) {
try {
downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
} catch (e: CancellationException) { // handle cancellation if not handled already
val state = stateFlow.value
if (state !is DownloadState.Cancelled) {
stateFlow.value = DownloadState.Cancelled(startId, state.manga)
}
throw e
}
}
return PausingProgressJob(job, stateFlow, pausingHandle)
}
private suspend fun downloadMangaImpl(
manga: Manga,
chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
startId: UUID,
) {
@Suppress("NAME_SHADOWING")
var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
outState.value = DownloadState.Queued(startId, manga)
withMangaLock(manga) {
semaphore.withPermit {
outState.value = DownloadState.Preparing(startId, manga)
val destination = localMangaRepository.getOutputDir(manga)
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp"
var output: LocalMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance")
}
val repo = mangaRepositoryFactory.create(manga.source)
outState.value = DownloadState.Preparing(startId, manga)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
},
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = runFailsafe(outState, pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache.get(url)
?: downloadFile(url, destination, tempFileName, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
outState.value = DownloadState.Progress(
uuid = startId,
manga = data,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
timeLeft = 0L,
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
if (output.flushChapter(chapter)) {
runCatchingCancellable {
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug)
}
}
outState.value = DownloadState.PostProcessing(startId, data)
output.mergeWithExisting()
output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga()
localStorageChanges.emit(localManga)
outState.value = DownloadState.Done(startId, data, localManga.manga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga)
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, e, false)
} finally {
withContext(NonCancellable) {
output?.closeQuietly()
output?.cleanup()
File(destination, tempFileName).deleteAwait()
}
}
}
}
}
private suspend fun <R> runFailsafe(
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
val state = outState.value
outState.value = DownloadState.Error(state.uuid, state.manga, e, true)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
outState.value = state
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
}
private suspend fun downloadFile(
url: String,
destination: File,
tempFileName: String,
source: MangaSource,
): File {
val request = Request.Builder()
.url(url)
.tag(MangaSource::class.java, source)
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get()
.build()
val call = okHttp.newCall(request)
val file = File(destination, tempFileName)
val response = call.clone().await()
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyToSuspending(out)
}
return file
}
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
val prevValue = outState.value
outState.value = DownloadState.Error(
uuid = prevValue.uuid,
manga = prevValue.manga,
error = throwable,
canRetry = false,
)
}
private suspend fun loadCover(manga: Manga) = runCatchingCancellable {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.allowHardware(false)
.tag(manga.source)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(),
).drawable
}.getOrNull()
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
}

View File

@@ -1,233 +0,0 @@
package org.koitharu.kotatsu.download.domain
import android.graphics.drawable.Drawable
import androidx.work.Data
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.UUID
sealed interface DownloadState {
val uuid: UUID
val manga: Manga
@Deprecated("")
val cover: Drawable? get() = null
@Deprecated("")
val startId: Int get() = uuid.hashCode()
fun toWorkData(): Data = Data.Builder()
.putString(DATA_UUID, uuid.toString())
.putLong(DATA_MANGA_ID, manga.id)
.build()
override fun equals(other: Any?): Boolean
override fun hashCode(): Int
val isTerminal: Boolean
get() = this is Done || this is Cancelled || (this is Error && !canRetry)
class Queued(
override val uuid: UUID,
override val manga: Manga,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Queued
if (uuid != other.uuid) return false
if (manga != other.manga) return false
return true
}
override fun hashCode(): Int {
var result = uuid.hashCode()
result = 31 * result + manga.hashCode()
return result
}
}
class Preparing(
override val uuid: UUID,
override val manga: Manga,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Preparing
if (uuid != other.uuid) return false
if (manga != other.manga) return false
return true
}
override fun hashCode(): Int {
var result = uuid.hashCode()
result = 31 * result + manga.hashCode()
return result
}
}
class Progress(
override val uuid: UUID,
override val manga: Manga,
val totalChapters: Int,
val currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
val timeLeft: Long,
) : DownloadState {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = progress.toFloat() / max
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Progress
if (uuid != other.uuid) return false
if (manga != other.manga) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
return true
}
override fun hashCode(): Int {
var result = uuid.hashCode()
result = 31 * result + manga.hashCode()
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
return result
}
}
class Done(
override val uuid: UUID,
override val manga: Manga,
val localManga: Manga,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Done
if (uuid != other.uuid) return false
if (manga != other.manga) return false
if (localManga != other.localManga) return false
return true
}
override fun hashCode(): Int {
var result = uuid.hashCode()
result = 31 * result + manga.hashCode()
result = 31 * result + localManga.hashCode()
return result
}
}
class Error(
override val uuid: UUID,
override val manga: Manga,
val error: Throwable,
val canRetry: Boolean,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Error
if (uuid != other.uuid) return false
if (manga != other.manga) return false
if (error != other.error) return false
if (canRetry != other.canRetry) return false
return true
}
override fun hashCode(): Int {
var result = uuid.hashCode()
result = 31 * result + manga.hashCode()
result = 31 * result + error.hashCode()
result = 31 * result + canRetry.hashCode()
return result
}
}
class Cancelled(
override val uuid: UUID,
override val manga: Manga,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Cancelled
if (uuid != other.uuid) return false
if (manga != other.manga) return false
return true
}
override fun hashCode(): Int {
var result = uuid.hashCode()
result = 31 * result + manga.hashCode()
return result
}
}
class PostProcessing(
override val uuid: UUID,
override val manga: Manga,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostProcessing
if (uuid != other.uuid) return false
if (manga != other.manga) return false
return true
}
override fun hashCode(): Int {
var result = uuid.hashCode()
result = 31 * result + manga.hashCode()
return result
}
}
companion object {
private const val DATA_UUID = "id"
private const val DATA_MANGA_ID = "manga_id"
}
}

View File

@@ -10,7 +10,7 @@ data class DownloadState2(
val manga: Manga,
val isIndeterminate: Boolean,
val isPaused: Boolean = false,
val error: Throwable? = null,
val error: String? = null,
val totalChapters: Int = 0,
val currentChapter: Int = 0,
val totalPages: Int = 0,
@@ -29,13 +29,19 @@ data class DownloadState2(
val isFinalState: Boolean
get() = localManga != null || (error != null && !isPaused)
val isParticularProgress: Boolean
get() = localManga == null && error == null && !isPaused && max > 0 && !isIndeterminate
fun toWorkData() = Data.Builder()
.putLong(DATA_MANGA_ID, manga.id)
.putInt(DATA_MAX, max)
.putInt(DATA_PROGRESS, progress)
.putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error?.toString())
.putString(DATA_ERROR, error)
.putInt(DATA_CHAPTERS, totalChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
.build()
companion object {
@@ -43,18 +49,29 @@ data class DownloadState2(
private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate"
private const val DATA_PAUSED = "paused"
fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L)
fun getMax(data: Data) = data.getInt(DATA_MAX, 0)
fun isIndeterminate(data: Data): Boolean = data.getBoolean(DATA_INDETERMINATE, false)
fun getProgress(data: Data) = data.getInt(DATA_PROGRESS, 0)
fun isPaused(data: Data): Boolean = data.getBoolean(DATA_PAUSED, false)
fun getEta(data: Data) = data.getLong(DATA_ETA, -1L)
fun getMax(data: Data): Int = data.getInt(DATA_MAX, 0)
fun getTimestamp(data: Data) = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getError(data: Data): String? = data.getString(DATA_ERROR)
fun getProgress(data: Data): Int = data.getInt(DATA_PROGRESS, 0)
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getTotalChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
}
}

View File

@@ -12,9 +12,9 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun downloadItemAD(
lifecycleOwner: LifecycleOwner,
@@ -26,18 +26,25 @@ fun downloadItemAD(
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item)
R.id.button_pause -> listener.onPauseClick(item)
else -> listener.onItemClick(item, v)
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item)
R.id.button_pause -> listener.onPauseClick(item)
else -> listener.onItemClick(item, v)
}
}
override fun onLongClick(v: View): Boolean {
return listener.onItemLongClick(item, v)
}
}
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
bind { payloads ->
binding.textViewTitle.text = item.manga.title
@@ -55,54 +62,74 @@ fun downloadItemAD(
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.RUNNING -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.isIndeterminate = false
binding.textViewStatus.setText(
if (item.isPaused) R.string.paused else R.string.manga_downloading_,
)
binding.progressBar.isIndeterminate = item.isIndeterminate
binding.progressBar.isVisible = true
binding.progressBar.max = item.max
binding.progressBar.isEnabled = !item.isPaused
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
binding.textViewDetails.textAndVisible = item.getEtaString()
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
binding.buttonResume.isVisible = item.isPaused
binding.buttonPause.isVisible = item.canPause
}
WorkInfo.State.SUCCEEDED -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
if (item.totalChapters > 0) {
binding.textViewDetails.text = context.resources.getQuantityString(
R.plurals.chapters,
item.totalChapters,
item.totalChapters,
)
binding.textViewDetails.isVisible = true
} else {
binding.textViewDetails.isVisible = false
}
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.FAILED -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = item.error?.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
binding.textViewDetails.textAndVisible = item.error
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = true
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.CANCELLED -> {
binding.textViewStatus.setText(R.string.canceled)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
}
}

View File

@@ -9,6 +9,4 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
fun onPauseClick(item: DownloadItemModel)
fun onResumeClick(item: DownloadItemModel)
fun onRetryClick(item: DownloadItemModel)
}

View File

@@ -1,25 +1,83 @@
package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils
import androidx.work.WorkInfo
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
import java.util.UUID
data class DownloadItemModel(
class DownloadItemModel(
val id: UUID,
val workState: WorkInfo.State,
val isIndeterminate: Boolean,
val isPaused: Boolean,
val manga: Manga,
val error: Throwable?,
val error: String?,
val max: Int,
val totalChapters: Int,
val progress: Int,
val eta: Long,
val createdAt: Date,
) : ListModel {
val timestamp: Date,
) : ListModel, Comparable<DownloadItemModel> {
val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f
val hasEta: Boolean
get() = eta > 0L
get() = workState == WorkInfo.State.RUNNING && !isPaused && eta > 0L
val canPause: Boolean
get() = workState == WorkInfo.State.RUNNING && !isPaused && error == null
val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(
eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
} else {
null
}
override fun compareTo(other: DownloadItemModel): Int {
return timestamp.compareTo(other.timestamp)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadItemModel
if (id != other.id) return false
if (workState != other.workState) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (manga != other.manga) return false
if (error != other.error) return false
if (max != other.max) return false
if (totalChapters != other.totalChapters) return false
if (progress != other.progress) return false
if (eta != other.eta) return false
return timestamp == other.timestamp
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + workState.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + manga.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + max
result = 31 * result + totalChapters
result = 31 * result + progress
result = 31 * result + eta.hashCode()
result = 31 * result + timestamp.hashCode()
return result
}
}

View File

@@ -3,14 +3,20 @@ 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
import android.view.View
import androidx.activity.viewModels
import androidx.annotation.Px
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -18,32 +24,52 @@ import org.koitharu.kotatsu.download.ui.worker.PausingReceiver
import javax.inject.Inject
@AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(), DownloadItemListener {
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
DownloadItemListener,
ListSelectionController.Callback2 {
@Inject
lateinit var coil: ImageLoader
private val viewModel by viewModels<DownloadsViewModel>()
private lateinit var selectionController: ListSelectionController
@Px
private var listSpacing = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
listSpacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(this, coil, this)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
viewModel.items.observe(this) {
adapter.items = it
val downloadsAdapter = DownloadsAdapter(this, coil, this)
val decoration = SpacingItemDecoration(listSpacing)
selectionController = ListSelectionController(
activity = this,
decoration = DownloadsSelectionDecoration(this),
registryOwner = this,
callback = this,
)
with(binding.recyclerView) {
setHasFixedSize(true)
addItemDecoration(decoration)
adapter = downloadsAdapter
selectionController.attachToRecyclerView(this)
}
addMenuProvider(DownloadsMenuProvider(this, viewModel))
viewModel.items.observe(this) {
downloadsAdapter.items = it
}
val menuObserver = Observer<Any> { _ -> invalidateOptionsMenu() }
viewModel.hasActiveWorks.observe(this, menuObserver)
viewModel.hasPausedWorks.observe(this, menuObserver)
viewModel.hasCancellableWorks.observe(this, menuObserver)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
left = insets.left + listSpacing,
right = insets.right + listSpacing,
bottom = insets.bottom,
)
binding.toolbar.updatePadding(
@@ -53,9 +79,16 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(), DownloadItem
}
override fun onItemClick(item: DownloadItemModel, view: View) {
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
startActivity(DetailsActivity.newIntent(view.context, item.manga))
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
return selectionController.onItemLongClick(item.id.mostSignificantBits)
}
override fun onCancelClick(item: DownloadItemModel) {
viewModel.cancel(item.id)
}
@@ -68,8 +101,67 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(), DownloadItem
sendBroadcast(PausingReceiver.getResumeIntent(item.id))
}
override fun onRetryClick(item: DownloadItemModel) {
// TODO
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
binding.recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_downloads, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_resume -> {
viewModel.resume(controller.snapshot())
mode.finish()
true
}
R.id.action_pause -> {
viewModel.pause(controller.snapshot())
mode.finish()
true
}
R.id.action_cancel -> {
viewModel.cancel(controller.snapshot())
mode.finish()
true
}
R.id.action_remove -> {
viewModel.remove(controller.snapshot())
mode.finish()
true
}
R.id.action_select_all -> {
controller.addAll(viewModel.allIds())
true
}
else -> false
}
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val snapshot = viewModel.snapshot(controller.peekCheckedIds())
var canPause = true
var canResume = true
var canCancel = true
var canRemove = true
for (item in snapshot) {
canPause = canPause and item.canPause
canResume = canResume and item.canResume
canCancel = canCancel and !item.workState.isFinished
canRemove = canRemove and item.workState.isFinished
}
menu.findItem(R.id.action_pause)?.isVisible = canPause
menu.findItem(R.id.action_resume)?.isVisible = canResume
menu.findItem(R.id.action_cancel)?.isVisible = canCancel
menu.findItem(R.id.action_remove)?.isVisible = canRemove
return super.onPrepareActionMode(controller, mode, menu)
}
companion object {

View File

@@ -18,7 +18,7 @@ class DownloadsAdapter(
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil, listener))
delegatesManager.addDelegate(ITEM_TYPE_DOWNLOAD, downloadItemAD(lifecycleOwner, coil, listener))
.addDelegate(loadingStateAD())
.addDelegate(emptyStateListAD(coil, lifecycleOwner, null))
.addDelegate(relatedDateItemAD())
@@ -58,4 +58,8 @@ class DownloadsAdapter(
}
}
}
companion object {
const val ITEM_TYPE_DOWNLOAD = 0
}
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
class DownloadsMenuProvider(
private val context: Context,
private val viewModel: DownloadsViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_downloads, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_pause -> viewModel.pauseAll()
R.id.action_resume -> viewModel.resumeAll()
R.id.action_cancel_all -> viewModel.cancelAll()
R.id.action_remove_completed -> viewModel.removeCompleted()
else -> return false
}
return true
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_pause)?.isVisible = viewModel.hasActiveWorks.value == true
menu.findItem(R.id.action_resume)?.isVisible = viewModel.hasPausedWorks.value == true
menu.findItem(R.id.action_cancel_all)?.isVisible = viewModel.hasCancellableWorks.value == true
}
}

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset)
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74,
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(DownloadItemModel::class.java) ?: return NO_ID
return item.id.mostSignificantBits
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
val isCard = child is CardView
val radius = (child as? CardView)?.radius ?: defaultRadius
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
if (isCard) {
checkIcon?.run {
setBounds(
(bounds.right - iconSize - iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.right - iconOffset).toInt(),
(bounds.top + iconOffset + iconSize).toInt(),
)
draw(canvas)
}
}
}
}

View File

@@ -8,7 +8,13 @@ import androidx.work.Data
import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.BaseViewModel
@@ -19,6 +25,7 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
import java.util.Date
@@ -33,33 +40,133 @@ class DownloadsViewModel @Inject constructor(
) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex()
private val works = workScheduler.observeWorks()
.mapLatest { it.toDownloadsList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val items = workScheduler.observeWorks()
.mapLatest { list ->
list.mapList()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val items = works.map {
it.toUiList()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
private suspend fun List<WorkInfo>.mapList(): List<ListModel> {
val hasPausedWorks = works.map {
it.any { x -> x.canResume }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val hasActiveWorks = works.map {
it.any { x -> x.canPause }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val hasCancellableWorks = works.map {
it.any { x -> !x.workState.isFinished }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
fun cancel(id: UUID) {
launchJob(Dispatchers.Default) {
workScheduler.cancel(id)
}
}
fun cancel(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val snapshot = works.value
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.cancel(work.id)
}
}
}
}
fun cancelAll() {
launchJob(Dispatchers.Default) {
workScheduler.cancelAll()
}
}
fun pause(ids: Set<Long>) {
val snapshot = works.value
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.pause(work.id)
}
}
}
fun pauseAll() {
val snapshot = works.value
for (work in snapshot) {
if (work.canPause) {
workScheduler.pause(work.id)
}
}
}
fun resumeAll() {
val snapshot = works.value
for (work in snapshot) {
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
workScheduler.resume(work.id)
}
}
}
fun resume(ids: Set<Long>) {
val snapshot = works.value
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.resume(work.id)
}
}
}
fun remove(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val snapshot = works.value
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.delete(work.id)
}
}
}
}
fun removeCompleted() {
launchJob(Dispatchers.Default) {
workScheduler.removeCompleted()
}
}
fun snapshot(ids: Set<Long>): Collection<DownloadItemModel> {
return works.value.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }
}
fun allIds(): Set<Long> = works.value.mapToSet {
it.id.mostSignificantBits
}
private suspend fun List<WorkInfo>.toDownloadsList(): List<DownloadItemModel> {
if (isEmpty()) {
return emptyList()
}
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() }
list.sortByDescending { it.timestamp }
return list
}
private fun List<DownloadItemModel>.toUiList(): List<ListModel> {
if (isEmpty()) {
return emptyStateList()
}
val destination = ArrayList<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null
for (item in this) {
val model = item.toUiModel() ?: continue
val date = timeAgo(model.createdAt)
val date = timeAgo(item.timestamp)
if (prevDate != date) {
destination += date
}
prevDate = date
destination += model
}
if (destination.isEmpty()) {
destination.add(
EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.text_downloads_holder,
textSecondary = 0,
actionStringRes = 0,
),
)
destination += item
}
return destination
}
@@ -68,31 +175,22 @@ class DownloadsViewModel @Inject constructor(
val workData = if (progress != Data.EMPTY) progress else outputData
val mangaId = DownloadState2.getMangaId(workData)
if (mangaId == 0L) return null
val manga = mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null
}
val manga = getManga(mangaId) ?: return null
return DownloadItemModel(
id = id,
workState = state,
manga = manga,
error = null,
error = DownloadState2.getError(workData),
isIndeterminate = DownloadState2.isIndeterminate(workData),
isPaused = DownloadState2.isPaused(workData),
max = DownloadState2.getMax(workData),
progress = DownloadState2.getProgress(workData),
eta = DownloadState2.getEta(workData),
createdAt = DownloadState2.getTimestamp(workData),
timestamp = DownloadState2.getTimestamp(workData),
totalChapters = DownloadState2.getTotalChapters(workData),
)
}
fun cancel(id: UUID) {
launchJob(Dispatchers.Default) {
workScheduler.cancel(id)
}
}
fun restart(id: UUID) {
// TODO
}
private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
@@ -105,4 +203,24 @@ class DownloadsViewModel @Inject constructor(
else -> DateTimeAgo.Absolute(date)
}
}
private fun emptyStateList() = listOf(
EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.text_downloads_holder,
textSecondary = 0,
actionStringRes = 0,
),
)
private suspend fun getManga(mangaId: Long): Manga? {
mangaCache[mangaId]?.let {
return it
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null
}
}
}
}

View File

@@ -32,7 +32,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getDrawableOrThrow
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.util.UUID
@@ -138,10 +137,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
builder.setContentText(percent)
builder.setContentText(
state.error?.getDisplayMessage(context.resources)
?: context.getString(R.string.paused),
)
builder.setContentText(state.error)
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
@@ -151,17 +147,16 @@ class DownloadNotificationFactory @AssistedInject constructor(
}
state.error != null -> { // error, final state
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setContentText(state.error)
builder.setAutoCancel(true)
builder.setOngoing(false)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error))
}
else -> {

View File

@@ -6,13 +6,13 @@ import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.asFlow
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
@@ -48,8 +48,11 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.WorkManagerHelper
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
@@ -99,7 +102,7 @@ class DownloadWorker @AssistedInject constructor(
Result.retry()
} catch (e: Exception) {
e.printStackTraceDebug()
currentState = currentState.copy(error = e)
currentState = currentState.copy(error = e.getDisplayMessage(applicationContext.resources), eta = -1L)
Result.failure(currentState.toWorkData())
}
}
@@ -131,9 +134,11 @@ class DownloadWorker @AssistedInject constructor(
val repo = mangaRepositoryFactory.create(manga.source)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
val coverUrl = data.largeCoverUrl.ifNullOrEmpty { data.coverUrl }
if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
}
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
@@ -168,6 +173,7 @@ class DownloadWorker @AssistedInject constructor(
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
isIndeterminate = false,
eta = timeLeftEstimator.getEta(),
),
)
@@ -182,15 +188,15 @@ class DownloadWorker @AssistedInject constructor(
}.onFailure(Throwable::printStackTraceDebug)
}
}
publishState(currentState.copy(isIndeterminate = true))
publishState(currentState.copy(isIndeterminate = true, eta = -1L))
output.mergeWithExisting()
output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga()
localStorageChanges.emit(localManga)
publishState(currentState.copy(localManga = localManga))
publishState(currentState.copy(localManga = localManga, eta = -1L))
} catch (e: Exception) {
if (e !is CancellationException) {
publishState(currentState.copy(error = e))
publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources)))
}
throw e
} finally {
@@ -209,7 +215,7 @@ class DownloadWorker @AssistedInject constructor(
block: suspend () -> R,
): R {
if (pausingHandle.isPaused) {
publishState(currentState.copy(isPaused = true))
publishState(currentState.copy(isPaused = true, eta = -1L))
pausingHandle.awaitResumed()
publishState(currentState.copy(isPaused = false))
}
@@ -219,7 +225,13 @@ class DownloadWorker @AssistedInject constructor(
return block()
} catch (e: IOException) {
if (countDown <= 0) {
publishState(currentState.copy(isPaused = true, error = e))
publishState(
currentState.copy(
isPaused = true,
error = e.getDisplayMessage(applicationContext.resources),
eta = -1L,
),
)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
@@ -255,8 +267,9 @@ class DownloadWorker @AssistedInject constructor(
}
private suspend fun publishState(state: DownloadState2) {
val previousState = currentState
currentState = state
if (!state.isPaused && state.max > 0) {
if (previousState.isParticularProgress && state.isParticularProgress) {
timeLeftEstimator.tick(state.progress, state.max)
} else {
timeLeftEstimator.emptyTick()
@@ -267,6 +280,8 @@ class DownloadWorker @AssistedInject constructor(
notificationManager.notify(id.toString(), id.hashCode(), notification)
} else if (notificationThrottler.throttle()) {
notificationManager.notify(id.hashCode(), notification)
} else {
return
}
setProgress(state.toWorkData())
}
@@ -294,7 +309,7 @@ class DownloadWorker @AssistedInject constructor(
if (!chaptersIds.isNullOrEmpty()) {
data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray())
}
scheduleImpl(listOf(data.build())).await()
scheduleImpl(listOf(data.build()))
}
suspend fun schedule(manga: Collection<Manga>) {
@@ -304,7 +319,7 @@ class DownloadWorker @AssistedInject constructor(
.putLong(MANGA_ID, it.id)
.build()
}
scheduleImpl(data).await()
scheduleImpl(data)
}
fun observeWorks(): Flow<List<WorkInfo>> = workManager
@@ -315,7 +330,29 @@ class DownloadWorker @AssistedInject constructor(
workManager.cancelWorkById(id).await()
}
private fun scheduleImpl(data: Collection<Data>): Operation {
suspend fun cancelAll() {
workManager.cancelAllWorkByTag(TAG).await()
}
fun pause(id: UUID) {
val intent = PausingReceiver.getPauseIntent(id)
context.sendBroadcast(intent)
}
fun resume(id: UUID) {
val intent = PausingReceiver.getResumeIntent(id)
context.sendBroadcast(intent)
}
suspend fun delete(id: UUID) {
WorkManagerHelper(workManager).deleteWork(id)
}
suspend fun removeCompleted() {
workManager.pruneWork().await()
}
private suspend fun scheduleImpl(data: Collection<Data>) {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(NetworkType.CONNECTED)
@@ -324,12 +361,13 @@ class DownloadWorker @AssistedInject constructor(
OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.addTag(TAG)
.keepResultsForAtLeast(3, TimeUnit.DAYS)
.keepResultsForAtLeast(7, TimeUnit.DAYS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.build()
}
return workManager.enqueue(requests)
workManager.enqueue(requests).await()
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.utils
import android.annotation.SuppressLint
import androidx.work.WorkManager
import androidx.work.impl.WorkManagerImpl
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@SuppressLint("RestrictedApi")
class WorkManagerHelper(
workManager: WorkManager,
) {
private val workManagerImpl = workManager as WorkManagerImpl
suspend fun deleteWork(id: UUID) = suspendCoroutine { cont ->
workManagerImpl.workTaskExecutor.executeOnTaskThread {
try {
workManagerImpl.workDatabase.workSpecDao().delete(id.toString())
cont.resume(Unit)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
}

View File

@@ -4,10 +4,10 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.44427"
android:scaleY="1.44427"
android:translateX="-5.33124"
android:translateY="-5.33124">
<group android:scaleX="1.3320464"
android:scaleY="1.3320464"
android:translateX="-3.984556"
android:translateY="-3.984556">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -3,26 +3,25 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="?materialCardViewFilledStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="16dp">
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="@dimen/manga_list_details_item_height"
android:orientation="horizontal">
android:paddingBottom="12dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="92dp"
android:layout_height="0dp"
android:orientation="vertical"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="H,13:18"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Medium"
tools:src="@sample/covers" />
<TextView
@@ -30,7 +29,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:singleLine="true"
@@ -40,16 +39,25 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/titles" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="imageView_cover, textView_status" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:trackColor="?colorPrimaryContainer"
app:trackCornerRadius="12dp"
tools:progress="25" />
<TextView
@@ -64,7 +72,7 @@
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintEnd_toStartOf="@id/textView_percent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:layout_constraintTop_toBottomOf="@id/textView_title"
tools:text="@string/manga_downloading_" />
<TextView
@@ -72,8 +80,9 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBaseline_toBaselineOf="@id/textView_status"
app:layout_constraintBottom_toTopOf="@id/barrier_top"
app:layout_constraintEnd_toEndOf="parent"
tools:text="25%" />
@@ -87,54 +96,47 @@
android:ellipsize="end"
android:maxLines="4"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/textView_percent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" />
<Button
android:id="@+id/button_pause"
style="@style/Widget.Material3.Button.TonalButton"
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:text="@string/pause"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/button_resume"
app:layout_constraintTop_toBottomOf="@id/textView_details"
app:layout_constraintVertical_bias="1"
app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:visibility="visible" />
<Button
android:id="@+id/button_resume"
style="@style/Widget.Material3.Button.TonalButton"
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:text="@string/resume"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/textView_details"
app:layout_constraintVertical_bias="1"
tools:visibility="visible" />
app:layout_constraintTop_toBottomOf="@id/progressBar" />
<Button
android:id="@+id/button_cancel"
style="@style/Widget.Material3.Button.TonalButton"
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:text="@android:string/cancel"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_details"
app:layout_constraintVertical_bias="1"
app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_resume"
android:icon="@drawable/ic_action_resume"
android:title="@string/resume"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_pause"
android:icon="@drawable/ic_action_pause"
android:title="@string/pause"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_cancel"
android:icon="@drawable/abc_ic_clear_material"
android:title="@android:string/cancel"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_remove"
android:icon="@drawable/ic_delete"
android:title="@string/remove"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_select_all"
android:icon="?actionModeSelectAllDrawable"
android:title="@android:string/selectAll"
app:showAsAction="ifRoom|withText" />
</menu>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_pause"
android:icon="@drawable/ic_action_pause"
android:title="@string/pause"
android:visible="false"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_resume"
android:icon="@drawable/ic_action_resume"
android:title="@string/resume"
android:visible="false"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_cancel_all"
android:title="@string/cancel_all"
app:showAsAction="never" />
<item
android:id="@+id/action_remove_completed"
android:title="@string/remove_completed"
app:showAsAction="never" />
</menu>

View File

@@ -438,4 +438,6 @@
<string name="pause">Pause</string>
<string name="resume">Resume</string>
<string name="paused">Paused</string>
<string name="remove_completed">Remove completed</string>
<string name="cancel_all">Cancel all</string>
</resources>

View File

@@ -238,6 +238,10 @@
<item name="cornerSize">16dp</item>
</style>
<style name="ShapeAppearanceOverlay.Kotatsu.Cover.Medium" parent="">
<item name="cornerSize">12dp</item>
</style>
<style name="ShapeAppearanceOverlay.Kotatsu.Cover.Small" parent="">
<item name="cornerSize">6dp</item>
</style>