Improve downloads list

pull/359/head
Koitharu 3 years ago
parent 41ac50c76a
commit 632b42ea86
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -103,7 +103,6 @@ dependencies {
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'com.google.guava:guava:31.1-android'
implementation 'androidx.room:room-runtime:2.5.1' implementation 'androidx.room:room-runtime:2.5.1'
implementation 'androidx.room:room-ktx:2.5.1' implementation 'androidx.room:room-ktx:2.5.1'

@ -35,6 +35,7 @@ import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo 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.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@ -119,6 +120,7 @@ class DetailsActivity :
binding.buttonDropdown.isVisible = it.size > 1 binding.buttonDropdown.isVisible = it.size > 1
} }
viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(binding.containerDetails))
addMenuProvider( addMenuProvider(
DetailsMenuProvider( 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 manga: Manga,
val isIndeterminate: Boolean, val isIndeterminate: Boolean,
val isPaused: Boolean = false, val isPaused: Boolean = false,
val error: Throwable? = null, val error: String? = null,
val totalChapters: Int = 0, val totalChapters: Int = 0,
val currentChapter: Int = 0, val currentChapter: Int = 0,
val totalPages: Int = 0, val totalPages: Int = 0,
@ -29,13 +29,19 @@ data class DownloadState2(
val isFinalState: Boolean val isFinalState: Boolean
get() = localManga != null || (error != null && !isPaused) get() = localManga != null || (error != null && !isPaused)
val isParticularProgress: Boolean
get() = localManga == null && error == null && !isPaused && max > 0 && !isIndeterminate
fun toWorkData() = Data.Builder() fun toWorkData() = Data.Builder()
.putLong(DATA_MANGA_ID, manga.id) .putLong(DATA_MANGA_ID, manga.id)
.putInt(DATA_MAX, max) .putInt(DATA_MAX, max)
.putInt(DATA_PROGRESS, progress) .putInt(DATA_PROGRESS, progress)
.putLong(DATA_ETA, eta) .putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp) .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() .build()
companion object { companion object {
@ -43,18 +49,29 @@ data class DownloadState2(
private const val DATA_MANGA_ID = "manga_id" private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max" private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_ETA = "eta" private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp" private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error" private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate"
private const val DATA_PAUSED = "paused"
fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L) 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 isPaused(data: Data): Boolean = data.getBoolean(DATA_PAUSED, false)
fun getMax(data: Data): Int = data.getInt(DATA_MAX, 0)
fun getError(data: Data): String? = data.getString(DATA_ERROR)
fun getProgress(data: Data): Int = data.getInt(DATA_PROGRESS, 0)
fun getProgress(data: Data) = data.getInt(DATA_PROGRESS, 0) fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
fun getEta(data: Data) = data.getLong(DATA_ETA, -1L) fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getTimestamp(data: Data) = 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.parsers.util.format
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith 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.newImageRequest
import org.koitharu.kotatsu.utils.ext.source import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun downloadItemAD( fun downloadItemAD(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@ -26,18 +26,25 @@ fun downloadItemAD(
val percentPattern = context.resources.getString(R.string.percent_string_pattern) val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val clickListener = View.OnClickListener { v -> val clickListener = object : View.OnClickListener, View.OnLongClickListener {
when (v.id) { override fun onClick(v: View) {
R.id.button_cancel -> listener.onCancelClick(item) when (v.id) {
R.id.button_resume -> listener.onResumeClick(item) R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_pause -> listener.onPauseClick(item) R.id.button_resume -> listener.onResumeClick(item)
else -> listener.onItemClick(item, v) 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.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener) binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener) binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener) itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.manga.title binding.textViewTitle.text = item.manga.title
@ -55,54 +62,74 @@ fun downloadItemAD(
binding.textViewStatus.setText(R.string.queued) binding.textViewStatus.setText(R.string.queued)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
} }
WorkInfo.State.RUNNING -> { WorkInfo.State.RUNNING -> {
binding.textViewStatus.setText(R.string.manga_downloading_) binding.textViewStatus.setText(
binding.progressBar.isIndeterminate = false if (item.isPaused) R.string.paused else R.string.manga_downloading_,
)
binding.progressBar.isIndeterminate = item.isIndeterminate
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.progressBar.max = item.max binding.progressBar.max = item.max
binding.progressBar.isEnabled = !item.isPaused
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
binding.textViewPercent.isVisible = true binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false binding.textViewDetails.textAndVisible = item.getEtaString()
binding.buttonCancel.isVisible = true binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = item.isPaused
binding.buttonPause.isVisible = item.canPause
} }
WorkInfo.State.SUCCEEDED -> { WorkInfo.State.SUCCEEDED -> {
binding.textViewStatus.setText(R.string.download_complete) binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false 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.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
} }
WorkInfo.State.FAILED -> { WorkInfo.State.FAILED -> {
binding.textViewStatus.setText(R.string.error_occurred) binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.text = item.error?.getDisplayMessage(context.resources) binding.textViewDetails.textAndVisible = item.error
binding.textViewDetails.isVisible = true
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = true binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
} }
WorkInfo.State.CANCELLED -> { WorkInfo.State.CANCELLED -> {
binding.textViewStatus.setText(R.string.canceled) binding.textViewStatus.setText(R.string.canceled)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
} }
} }
} }

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

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

@ -3,14 +3,20 @@ package org.koitharu.kotatsu.download.ui.list
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.Px
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity 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.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@ -18,32 +24,52 @@ import org.koitharu.kotatsu.download.ui.worker.PausingReceiver
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(), DownloadItemListener { class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
DownloadItemListener,
ListSelectionController.Callback2 {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
private val viewModel by viewModels<DownloadsViewModel>() private val viewModel by viewModels<DownloadsViewModel>()
private lateinit var selectionController: ListSelectionController
@Px
private var listSpacing = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
listSpacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(this, coil, this) val downloadsAdapter = DownloadsAdapter(this, coil, this)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) val decoration = SpacingItemDecoration(listSpacing)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) selectionController = ListSelectionController(
binding.recyclerView.setHasFixedSize(true) activity = this,
binding.recyclerView.adapter = adapter 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) { viewModel.items.observe(this) {
adapter.items = it 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) { override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
left = insets.left, left = insets.left + listSpacing,
right = insets.right, right = insets.right + listSpacing,
bottom = insets.bottom, bottom = insets.bottom,
) )
binding.toolbar.updatePadding( binding.toolbar.updatePadding(
@ -53,9 +79,16 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(), DownloadItem
} }
override fun onItemClick(item: DownloadItemModel, view: View) { override fun onItemClick(item: DownloadItemModel, view: View) {
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
startActivity(DetailsActivity.newIntent(view.context, item.manga)) 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) { override fun onCancelClick(item: DownloadItemModel) {
viewModel.cancel(item.id) viewModel.cancel(item.id)
} }
@ -68,8 +101,67 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(), DownloadItem
sendBroadcast(PausingReceiver.getResumeIntent(item.id)) sendBroadcast(PausingReceiver.getResumeIntent(item.id))
} }
override fun onRetryClick(item: DownloadItemModel) { override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
// TODO 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 { companion object {

@ -18,7 +18,7 @@ class DownloadsAdapter(
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil, listener)) delegatesManager.addDelegate(ITEM_TYPE_DOWNLOAD, downloadItemAD(lifecycleOwner, coil, listener))
.addDelegate(loadingStateAD()) .addDelegate(loadingStateAD())
.addDelegate(emptyStateListAD(coil, lifecycleOwner, null)) .addDelegate(emptyStateListAD(coil, lifecycleOwner, null))
.addDelegate(relatedDateItemAD()) .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 androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest 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.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.BaseViewModel 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.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga 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.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import java.util.Date import java.util.Date
@ -33,33 +40,133 @@ class DownloadsViewModel @Inject constructor(
) : BaseViewModel() { ) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>() 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() val items = works.map {
.mapLatest { list -> it.toUiList()
list.mapList() }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
}.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()) val destination = ArrayList<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null var prevDate: DateTimeAgo? = null
for (item in this) { for (item in this) {
val model = item.toUiModel() ?: continue val date = timeAgo(item.timestamp)
val date = timeAgo(model.createdAt)
if (prevDate != date) { if (prevDate != date) {
destination += date destination += date
} }
prevDate = date prevDate = date
destination += model destination += item
}
if (destination.isEmpty()) {
destination.add(
EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.text_downloads_holder,
textSecondary = 0,
actionStringRes = 0,
),
)
} }
return destination return destination
} }
@ -68,31 +175,22 @@ class DownloadsViewModel @Inject constructor(
val workData = if (progress != Data.EMPTY) progress else outputData val workData = if (progress != Data.EMPTY) progress else outputData
val mangaId = DownloadState2.getMangaId(workData) val mangaId = DownloadState2.getMangaId(workData)
if (mangaId == 0L) return null if (mangaId == 0L) return null
val manga = mangaCache.getOrElse(mangaId) { val manga = getManga(mangaId) ?: return null
mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null
}
return DownloadItemModel( return DownloadItemModel(
id = id, id = id,
workState = state, workState = state,
manga = manga, manga = manga,
error = null, error = DownloadState2.getError(workData),
isIndeterminate = DownloadState2.isIndeterminate(workData),
isPaused = DownloadState2.isPaused(workData),
max = DownloadState2.getMax(workData), max = DownloadState2.getMax(workData),
progress = DownloadState2.getProgress(workData), progress = DownloadState2.getProgress(workData),
eta = DownloadState2.getEta(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 { private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L) val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt() val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
@ -105,4 +203,24 @@ class DownloadsViewModel @Inject constructor(
else -> DateTimeAgo.Absolute(date) 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.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity 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.getDrawableOrThrow
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.util.UUID import java.util.UUID
@ -138,10 +137,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setProgress(state.max, state.progress, false) builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
builder.setContentText(percent) builder.setContentText(percent)
builder.setContentText( builder.setContentText(state.error)
state.error?.getDisplayMessage(context.resources)
?: context.getString(R.string.paused),
)
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true) builder.setOngoing(true)
@ -151,17 +147,16 @@ class DownloadNotificationFactory @AssistedInject constructor(
} }
state.error != null -> { // error, final state state.error != null -> { // error, final state
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error)) builder.setSubText(context.getString(R.string.error))
builder.setContentText(message) builder.setContentText(state.error)
builder.setAutoCancel(true) builder.setAutoCancel(true)
builder.setOngoing(false) builder.setOngoing(false)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true) builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis()) builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error))
} }
else -> { else -> {

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 534 B

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

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

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

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

Loading…
Cancel
Save