Improve downloads list
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +1,83 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.list
|
package org.koitharu.kotatsu.download.ui.list
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class DownloadItemModel(
|
class DownloadItemModel(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val workState: WorkInfo.State,
|
val workState: WorkInfo.State,
|
||||||
|
val isIndeterminate: Boolean,
|
||||||
|
val isPaused: Boolean,
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val error: Throwable?,
|
val error: String?,
|
||||||
val max: Int,
|
val max: Int,
|
||||||
|
val totalChapters: Int,
|
||||||
val progress: Int,
|
val progress: Int,
|
||||||
val eta: Long,
|
val eta: Long,
|
||||||
val createdAt: Date,
|
val timestamp: Date,
|
||||||
) : ListModel {
|
) : ListModel, Comparable<DownloadItemModel> {
|
||||||
|
|
||||||
val percent: Float
|
val percent: Float
|
||||||
get() = if (max > 0) progress / max.toFloat() else 0f
|
get() = if (max > 0) progress / max.toFloat() else 0f
|
||||||
|
|
||||||
val hasEta: Boolean
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 138 B |
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 144 B |
|
Before Width: | Height: | Size: 203 B After Width: | Height: | Size: 218 B |
|
Before Width: | Height: | Size: 223 B After Width: | Height: | Size: 534 B |
@ -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>
|
||||||
@ -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>
|
||||||