From 632b42ea86f1a66457fcf3c30bfd379ae3ec17db Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 6 May 2023 13:08:57 +0300 Subject: [PATCH] Improve downloads list --- app/build.gradle | 1 - .../kotatsu/details/ui/DetailsActivity.kt | 2 + .../download/domain/DownloadManager.kt | 277 ------------------ .../kotatsu/download/domain/DownloadState.kt | 233 --------------- .../kotatsu/download/domain/DownloadState2.kt | 29 +- .../download/ui/list/DownloadItemAD.kt | 57 +++- .../download/ui/list/DownloadItemListener.kt | 2 - .../download/ui/list/DownloadItemModel.kt | 68 ++++- .../download/ui/list/DownloadsActivity.kt | 118 +++++++- .../download/ui/list/DownloadsAdapter.kt | 6 +- .../download/ui/list/DownloadsMenuProvider.kt | 36 +++ .../ui/list/DownloadsSelectionDecoration.kt | 75 +++++ .../download/ui/list/DownloadsViewModel.kt | 184 +++++++++--- .../ui/worker/DownloadNotificationFactory.kt | 11 +- .../download/ui/worker/DownloadWorker.kt | 70 ++++- .../kotatsu/utils/WorkManagerHelper.kt | 28 ++ .../drawable-anydpi-v24/ic_stat_paused.xml | 8 +- .../main/res/drawable-hdpi/ic_stat_paused.png | Bin 166 -> 138 bytes .../main/res/drawable-mdpi/ic_stat_paused.png | Bin 131 -> 144 bytes .../res/drawable-xhdpi/ic_stat_paused.png | Bin 203 -> 218 bytes .../res/drawable-xxhdpi/ic_stat_paused.png | Bin 223 -> 534 bytes app/src/main/res/layout/item_download.xml | 74 ++--- app/src/main/res/menu/mode_downloads.xml | 36 +++ app/src/main/res/menu/opt_downloads.xml | 30 ++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/values/styles.xml | 4 + 26 files changed, 701 insertions(+), 650 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt create mode 100644 app/src/main/res/menu/mode_downloads.xml create mode 100644 app/src/main/res/menu/opt_downloads.xml diff --git a/app/build.gradle b/app/build.gradle index 95ce28b69..b963878c5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -103,7 +103,6 @@ dependencies { //noinspection LifecycleAnnotationProcessorWithJava8 kapt 'androidx.lifecycle:lifecycle-compiler:2.6.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-ktx:2.5.1' diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index e3b8a4318..4752e52f4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -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( diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt deleted file mode 100644 index cca3912a2..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ /dev/null @@ -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, -) { - - 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 { - val stateFlow = MutableStateFlow( - 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, - 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 runFailsafe( - outState: MutableStateFlow, - 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) = - 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 withMangaLock(manga: Manga, block: () -> T) = try { - localMangaRepository.lockManga(manga.id) - block() - } finally { - localMangaRepository.unlockManga(manga.id) - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt deleted file mode 100644 index 0d5e1fa37..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt +++ /dev/null @@ -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" - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt index 80269df26..967fafa9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt @@ -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) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt index a372ebee7..7517cb0d1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -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 } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt index 290af949a..c4dd45699 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt @@ -9,6 +9,4 @@ interface DownloadItemListener : OnListItemClickListener { fun onPauseClick(item: DownloadItemModel) fun onResumeClick(item: DownloadItemModel) - - fun onRetryClick(item: DownloadItemModel) } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt index 6f90fd0f9..f8d95a66c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt @@ -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 { 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 + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index 8ae920f04..1fe368b37 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -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(), DownloadItemListener { +class DownloadsActivity : BaseActivity(), + DownloadItemListener, + ListSelectionController.Callback2 { @Inject lateinit var coil: ImageLoader private val viewModel by viewModels() + 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 { _ -> 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(), 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(), 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 { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt index e133d90a5..2778efdc4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt @@ -18,7 +18,7 @@ class DownloadsAdapter( ) : AsyncListDifferDelegationAdapter(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 + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt new file mode 100644 index 000000000..a864cf3ac --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt @@ -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 + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt new file mode 100644 index 000000000..0ca62de1b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index d904458f0..2ebef3182 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -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() + 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.mapList(): List { + 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) { + 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) { + 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) { + val snapshot = works.value + for (work in snapshot) { + if (work.id.mostSignificantBits in ids) { + workScheduler.resume(work.id) + } + } + } + + fun remove(ids: Set) { + 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): Collection { + return works.value.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids } + } + + fun allIds(): Set = works.value.mapToSet { + it.id.mostSignificantBits + } + + private suspend fun List.toDownloadsList(): List { + if (isEmpty()) { + return emptyList() + } + val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() } + list.sortByDescending { it.timestamp } + return list + } + + private fun List.toUiList(): List { + if (isEmpty()) { + return emptyStateList() + } val destination = ArrayList((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 + } + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index 933c31b4d..2af1a59e9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -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 -> { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 0014cfef4..ee2bb97ff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -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) { @@ -304,7 +319,7 @@ class DownloadWorker @AssistedInject constructor( .putLong(MANGA_ID, it.id) .build() } - scheduleImpl(data).await() + scheduleImpl(data) } fun observeWorks(): Flow> = workManager @@ -315,7 +330,29 @@ class DownloadWorker @AssistedInject constructor( workManager.cancelWorkById(id).await() } - private fun scheduleImpl(data: Collection): 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) { val constraints = Constraints.Builder() .setRequiresStorageNotLow(true) .setRequiredNetworkType(NetworkType.CONNECTED) @@ -324,12 +361,13 @@ class DownloadWorker @AssistedInject constructor( OneTimeWorkRequestBuilder() .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() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt new file mode 100644 index 000000000..b2c60f441 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt @@ -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) + } + } + } +} diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml index 0e71a1771..55d7a60db 100644 --- a/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml @@ -4,10 +4,10 @@ android:viewportWidth="24" android:viewportHeight="24" android:tint="#FFFFFF"> - + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_paused.png b/app/src/main/res/drawable-hdpi/ic_stat_paused.png index dade9e39a808ae49fa5964755434087df2e9b800..e42a3d68c44cfc9fd652b6253a1612e8b4976a5d 100644 GIT binary patch delta 109 zcmZ3+*u^+OB`wg?#WAEJ?(I2CJ_ZGW!wyIO?>`f|`^E)@z=~HcMcJ2>SDWozzFE@7 n@%i#2<`O9Z_=qp@6G}3f&zAZ8;xH@~V*mnAS3j3^P6tvSR@p)QsKp{!MutUOyM~O|axx?`Yf;;hkv)>M7 k^Ob+!G{=_jyeQ3oUg@0O$$oWD1|aZs^>bOr?3B<10DNmVkN^Mx diff --git a/app/src/main/res/drawable-mdpi/ic_stat_paused.png b/app/src/main/res/drawable-mdpi/ic_stat_paused.png index a40b746f13f996db5cdac51f64570114733b59a9..979fc6fc3a681f66236a1d20ebf1db1f73924cce 100644 GIT binary patch delta 115 zcmV-(0F3{G0gwTZBy3knL_t(|UhUN}3cxT71HcT~^R4#(4^k)y46?>7DLU{bq0wXf z4uEBDn%p$GrMU*)V{)&CKALN^Kp7}npbQi(PzH(?C<8?cl!2lJx@(}lL7aS<*V%5& Vj2Nbckm3LU002ovPDHLkV1npEFVg@3 delta 102 zcmbQh*vvRVCEm-^#WAEJ?(NxwoD2#A%#OeFPapDY$w;`!H06vrcjjB|(5tT-?jG{9 t+MV0CM!k&N`eftA$ubgX)J}PuOor5NoB7HM-=;7Cfv2mV%Q~loCIF3EDW(7b diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_paused.png b/app/src/main/res/drawable-xhdpi/ic_stat_paused.png index 0668229665a330cd9c7966f5ad2ca788121b3137..834a83c8d1f4efc7a3fd2261f6709173c61a49a8 100644 GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtg`O^sAr*0NZyItnIEXL@zOVPv zSD5E;%~x6|U_-!6_A-0!;N3b5|8jdap3UF?VeP)$66tR*Z`V}cu6ZJH{SA=&`thR= zZ*!}T#Orc!a40BTT-(ph#Nh!HZem~(y8CN~LPLYgb~`;1r7Bb&+_wM8u;(`Chqm7n UXZ&D|1Ui<%)78&qol`;+07z+7qyPW_ literal 203 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtNuDl_Ar*0N&u`>C?7(ou!2kIh z`-{89n*>(Yz6pJ?EA83p;&adcEZn=LeYfpg-apsm%pBe>wasa`zx0A{m&j3A@$kSIZ<~heJG5mK3=RNY$KdJe=d#Wzp$P!u C7FKcq diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png b/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png index 6ceff5260ca0a11aea9432fbb9f72043af25cafe..883a124a3641e3ee72bb661740d8789f51b077e0 100644 GIT binary patch literal 534 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2V7%+;;uum9_x9{Y-op$6M-1NY zFS#VFsr2L2bB0Tw=Ma4UBIFPgg&ebxsLQ0P`j#TL1t6 literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!%RF5iLn`9lUO33vV8Fm~P$cbt zu4dim$R0bDGa?1-)@%$p@BbV#d~a8pe`c}vv%A-yHM=sgHa0mpE>H*%(BKeZ;i4(` f(7r~72jzN8x44{Wn$-$)Fav|9tDnm{r-UW|PXImR diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml index 39ea0c7ed..2ed5c529c 100644 --- a/app/src/main/res/layout/item_download.xml +++ b/app/src/main/res/layout/item_download.xml @@ -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"> + android:paddingBottom="12dp"> + + @@ -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]" />