Improve downloads list
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,4 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
|
||||
fun onPauseClick(item: DownloadItemModel)
|
||||
|
||||
fun onResumeClick(item: DownloadItemModel)
|
||||
|
||||
fun onRetryClick(item: DownloadItemModel)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
|
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 |
@@ -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>
|
||||
|
||||
36
app/src/main/res/menu/mode_downloads.xml
Normal 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>
|
||||
30
app/src/main/res/menu/opt_downloads.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||