From 41ac50c76a57e54c1f9a26df7e8c35705427e255 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 5 May 2023 11:08:21 +0300 Subject: [PATCH] Manage download states --- app/build.gradle | 3 +- app/src/main/AndroidManifest.xml | 2 +- .../kotatsu/download/domain/DownloadState2.kt | 44 ++++-- .../kotatsu/download/ui/DownloadItemAD.kt | 140 ------------------ .../kotatsu/download/ui/DownloadsAdapter.kt | 46 ------ .../download/ui/list/DownloadItemAD.kt | 113 ++++++++++++++ .../download/ui/list/DownloadItemListener.kt | 14 ++ .../download/ui/list/DownloadItemModel.kt | 25 ++++ .../ui/{ => list}/DownloadsActivity.kt | 36 ++++- .../download/ui/list/DownloadsAdapter.kt | 61 ++++++++ .../download/ui/list/DownloadsViewModel.kt | 108 ++++++++++++++ .../download/ui/service/PausingHandle.kt | 2 +- .../ui/worker/DownloadNotificationFactory.kt | 113 ++++++++------ .../ui/worker/DownloadStartedObserver.kt | 2 +- .../download/ui/worker/DownloadWorker.kt | 94 ++++++++---- .../download/ui/worker/PausingReceiver.kt | 66 +++++++++ .../kotatsu/download/ui/worker/Throttler.kt | 10 +- .../kotatsu/settings/tools/ToolsFragment.kt | 2 +- .../koitharu/kotatsu/utils/ext/StringExt.kt | 11 +- .../utils/progress/TimeLeftEstimator.kt | 13 +- .../drawable-anydpi-v24/ic_stat_paused.xml | 15 ++ .../main/res/drawable-hdpi/ic_stat_paused.png | Bin 0 -> 166 bytes .../main/res/drawable-mdpi/ic_stat_paused.png | Bin 0 -> 131 bytes .../res/drawable-xhdpi/ic_stat_paused.png | Bin 0 -> 203 bytes .../res/drawable-xxhdpi/ic_stat_paused.png | Bin 0 -> 223 bytes app/src/main/res/drawable/ic_action_pause.xml | 11 ++ .../main/res/drawable/ic_action_resume.xml | 11 ++ .../main/res/layout/activity_downloads.xml | 11 -- app/src/main/res/layout/item_download.xml | 21 ++- app/src/main/res/values/strings.xml | 3 + 30 files changed, 669 insertions(+), 308 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt rename app/src/main/java/org/koitharu/kotatsu/download/ui/{ => list}/DownloadsActivity.kt (59%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt create mode 100644 app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_paused.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_paused.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_paused.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_paused.png create mode 100644 app/src/main/res/drawable/ic_action_pause.xml create mode 100644 app/src/main/res/drawable/ic_action_resume.xml diff --git a/app/build.gradle b/app/build.gradle index 37a94ac4b..95ce28b69 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,11 +98,12 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.0' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.preference:preference-ktx:1.2.0' - implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'com.google.android.material:material:1.8.0' //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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f70692fb5..41b72ab7b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -128,7 +128,7 @@ android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity" android:windowSoftInputMode="adjustResize" /> 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 0aa537acc..80269df26 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 @@ -4,45 +4,57 @@ import androidx.work.Data import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.parsers.model.Manga -import java.util.UUID +import java.util.Date data class DownloadState2( - val id: UUID, val manga: Manga, - val state: State, + val isIndeterminate: Boolean, + val isPaused: Boolean = false, val error: Throwable? = null, val totalChapters: Int = 0, val currentChapter: Int = 0, val totalPages: Int = 0, val currentPage: Int = 0, - val timeLeft: Long = -1L, + val eta: Long = -1L, val localManga: LocalManga? = null, + val timestamp: Long = System.currentTimeMillis(), ) { - val isTerminal: Boolean - get() = state == State.FAILED || state == State.CANCELLED || state == State.DONE - val max: Int = totalChapters * totalPages val progress: Int = totalPages * currentChapter + currentPage + 1 val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE + val isFinalState: Boolean + get() = localManga != null || (error != null && !isPaused) + fun toWorkData() = Data.Builder() - .putString(DATA_UUID, id.toString()) .putLong(DATA_MANGA_ID, manga.id) - .putString(DATA_STATE, state.name) + .putInt(DATA_MAX, max) + .putInt(DATA_PROGRESS, progress) + .putLong(DATA_ETA, eta) + .putLong(DATA_TIMESTAMP, timestamp) + .putString(DATA_ERROR, error?.toString()) .build() - enum class State { - - PREPARING, PROGRESS, PAUSED, FAILED, CANCELLED, DONE - } - companion object { - private const val DATA_UUID = "uuid" private const val DATA_MANGA_ID = "manga_id" - private const val DATA_STATE = "state" + private const val DATA_MAX = "max" + private const val DATA_PROGRESS = "progress" + private const val DATA_ETA = "eta" + private const val DATA_TIMESTAMP = "timestamp" + private const val DATA_ERROR = "error" + + fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L) + + fun getMax(data: Data) = data.getInt(DATA_MAX, 0) + + fun getProgress(data: Data) = data.getInt(DATA_PROGRESS, 0) + + fun getEta(data: Data) = data.getLong(DATA_ETA, -1L) + + fun getTimestamp(data: Data) = Date(data.getLong(DATA_TIMESTAMP, 0L)) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt deleted file mode 100644 index 2afba0b27..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt +++ /dev/null @@ -1,140 +0,0 @@ -package org.koitharu.kotatsu.download.ui - -import android.view.View -import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.databinding.ItemDownloadBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.parsers.util.format -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.onFirst -import org.koitharu.kotatsu.utils.ext.source - -fun downloadItemAD( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }, -) { - var job: Job? = null - val percentPattern = context.resources.getString(R.string.percent_string_pattern) - - val clickListener = View.OnClickListener { v -> - when (v.id) { - R.id.button_cancel -> item.cancel() - R.id.button_resume -> item.resume() - else -> context.startActivity( - DetailsActivity.newIntent(context, item.progressValue.manga), - ) - } - } - binding.buttonCancel.setOnClickListener(clickListener) - binding.buttonResume.setOnClickListener(clickListener) - itemView.setOnClickListener(clickListener) - - bind { - job?.cancel() - job = item.progressAsFlow().onFirst { state -> - binding.imageViewCover.newImageRequest(lifecycleOwner, state.manga.coverUrl)?.run { - placeholder(state.cover) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - source(state.manga.source) - allowRgb565(true) - enqueueWith(coil) - } - }.onEach { state -> - binding.textViewTitle.text = state.manga.title - when (state) { - is DownloadState.Cancelled -> { - binding.textViewStatus.setText(R.string.cancelling_) - binding.progressBar.isIndeterminate = true - binding.progressBar.isVisible = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - } - - is DownloadState.Done -> { - binding.textViewStatus.setText(R.string.download_complete) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - } - - is DownloadState.Error -> { - binding.textViewStatus.setText(R.string.error_occurred) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.textViewPercent.isVisible = false - binding.textViewDetails.text = state.error.getDisplayMessage(context.resources) - binding.textViewDetails.isVisible = true - binding.buttonCancel.isVisible = state.canRetry - binding.buttonResume.isVisible = state.canRetry - } - - is DownloadState.PostProcessing -> { - binding.textViewStatus.setText(R.string.processing_) - binding.progressBar.isIndeterminate = true - binding.progressBar.isVisible = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - } - - is DownloadState.Preparing -> { - binding.textViewStatus.setText(R.string.preparing_) - binding.progressBar.isIndeterminate = true - binding.progressBar.isVisible = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = true - binding.buttonResume.isVisible = false - } - - is DownloadState.Progress -> { - binding.textViewStatus.setText(R.string.manga_downloading_) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = true - binding.progressBar.max = state.max - binding.progressBar.setProgressCompat(state.progress, true) - binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1)) - binding.textViewPercent.isVisible = true - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = true - binding.buttonResume.isVisible = false - } - - is DownloadState.Queued -> { - binding.textViewStatus.setText(R.string.queued) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = true - binding.buttonResume.isVisible = false - } - } - }.launchIn(lifecycleOwner.lifecycleScope) - } - - onViewRecycled { - job?.cancel() - job = null - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt deleted file mode 100644 index 5962220c3..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.download.ui - -import androidx.lifecycle.LifecycleOwner -import androidx.recyclerview.widget.DiffUtil -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.utils.progress.PausingProgressJob - -typealias DownloadItem = PausingProgressJob - -class DownloadsAdapter( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, -) : AsyncListDifferDelegationAdapter(DiffCallback()) { - - init { - delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil)) - setHasStableIds(true) - } - - override fun getItemId(position: Int): Long { - return items[position].progressValue.startId.toLong() - } - - private class DiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldItem: DownloadItem, - newItem: DownloadItem, - ): Boolean { - return oldItem.progressValue.startId == newItem.progressValue.startId - } - - override fun areContentsTheSame( - oldItem: DownloadItem, - newItem: DownloadItem, - ): Boolean { - return oldItem.progressValue == newItem.progressValue && oldItem.isPaused == newItem.isPaused - } - - override fun getChangePayload(oldItem: DownloadItem, newItem: DownloadItem): Any { - return Unit - } - } -} 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 new file mode 100644 index 000000000..a372ebee7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -0,0 +1,113 @@ +package org.koitharu.kotatsu.download.ui.list + +import android.view.View +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.work.WorkInfo +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemDownloadBinding +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 + +fun downloadItemAD( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + listener: DownloadItemListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }, +) { + + 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) + } + } + binding.buttonCancel.setOnClickListener(clickListener) + binding.buttonPause.setOnClickListener(clickListener) + binding.buttonResume.setOnClickListener(clickListener) + itemView.setOnClickListener(clickListener) + + bind { payloads -> + binding.textViewTitle.text = item.manga.title + binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply { + placeholder(R.drawable.ic_placeholder) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_error_placeholder) + allowRgb565(true) + source(item.manga.source) + enqueueWith(coil) + } + when (item.workState) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED -> { + binding.textViewStatus.setText(R.string.queued) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = true + binding.buttonResume.isVisible = false + } + + WorkInfo.State.RUNNING -> { + binding.textViewStatus.setText(R.string.manga_downloading_) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = true + binding.progressBar.max = item.max + 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.buttonCancel.isVisible = true + binding.buttonResume.isVisible = false + } + + WorkInfo.State.SUCCEEDED -> { + binding.textViewStatus.setText(R.string.download_complete) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = false + } + + WorkInfo.State.FAILED -> { + binding.textViewStatus.setText(R.string.error_occurred) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.text = item.error?.getDisplayMessage(context.resources) + binding.textViewDetails.isVisible = true + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = true + } + + WorkInfo.State.CANCELLED -> { + binding.textViewStatus.setText(R.string.canceled) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = false + } + } + } + + onViewRecycled { + binding.imageViewCover.disposeImageRequest() + } +} 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 new file mode 100644 index 000000000..290af949a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.download.ui.list + +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener + +interface DownloadItemListener : OnListItemClickListener { + + fun onCancelClick(item: DownloadItemModel) + + 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 new file mode 100644 index 000000000..6f90fd0f9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.download.ui.list + +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( + val id: UUID, + val workState: WorkInfo.State, + val manga: Manga, + val error: Throwable?, + val max: Int, + val progress: Int, + val eta: Long, + val createdAt: Date, +) : ListModel { + + val percent: Float + get() = if (max > 0) progress / max.toFloat() else 0f + + val hasEta: Boolean + get() = eta > 0L +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt similarity index 59% rename from app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index e95a20f27..8ae920f04 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -1,8 +1,10 @@ -package org.koitharu.kotatsu.download.ui +package org.koitharu.kotatsu.download.ui.list import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.View +import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.updatePadding import coil.ImageLoader @@ -11,23 +13,31 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.ui.worker.PausingReceiver import javax.inject.Inject @AndroidEntryPoint -class DownloadsActivity : BaseActivity() { +class DownloadsActivity : BaseActivity(), DownloadItemListener { @Inject lateinit var coil: ImageLoader + private val viewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - val adapter = DownloadsAdapter(this, coil) + 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 + } } override fun onWindowInsetsChanged(insets: Insets) { @@ -42,6 +52,26 @@ class DownloadsActivity : BaseActivity() { ) } + override fun onItemClick(item: DownloadItemModel, view: View) { + startActivity(DetailsActivity.newIntent(view.context, item.manga)) + } + + override fun onCancelClick(item: DownloadItemModel) { + viewModel.cancel(item.id) + } + + override fun onPauseClick(item: DownloadItemModel) { + sendBroadcast(PausingReceiver.getPauseIntent(item.id)) + } + + override fun onResumeClick(item: DownloadItemModel) { + sendBroadcast(PausingReceiver.getResumeIntent(item.id)) + } + + override fun onRetryClick(item: DownloadItemModel) { + // TODO + } + companion object { fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) 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 new file mode 100644 index 000000000..e133d90a5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt @@ -0,0 +1,61 @@ +package org.koitharu.kotatsu.download.ui.list + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD +import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD +import org.koitharu.kotatsu.list.ui.adapter.relatedDateItemAD +import org.koitharu.kotatsu.list.ui.model.ListModel +import kotlin.jvm.internal.Intrinsics + +class DownloadsAdapter( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + listener: DownloadItemListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil, listener)) + .addDelegate(loadingStateAD()) + .addDelegate(emptyStateListAD(coil, lifecycleOwner, null)) + .addDelegate(relatedDateItemAD()) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when { + + oldItem is DownloadItemModel && newItem is DownloadItemModel -> { + oldItem.id == newItem.id + } + + oldItem is DateTimeAgo && newItem is DateTimeAgo -> { + oldItem == newItem + } + + else -> oldItem.javaClass == newItem.javaClass + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + + override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? { + return when (newItem) { + is DownloadItemModel -> { + oldItem as DownloadItemModel + if (oldItem.workState == newItem.workState) { + Unit + } else { + null + } + } + + else -> super.getChangePayload(oldItem, newItem) + } + } + } +} 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 new file mode 100644 index 000000000..d904458f0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -0,0 +1,108 @@ +package org.koitharu.kotatsu.download.ui.list + +import androidx.collection.LongSparseArray +import androidx.collection.getOrElse +import androidx.collection.set +import androidx.lifecycle.viewModelScope +import androidx.work.Data +import androidx.work.WorkInfo +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.mapLatest +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.download.domain.DownloadState2 +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +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.utils.asFlowLiveData +import org.koitharu.kotatsu.utils.ext.daysDiff +import java.util.Date +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltViewModel +class DownloadsViewModel @Inject constructor( + private val workScheduler: DownloadWorker.Scheduler, + private val mangaDataRepository: MangaDataRepository, +) : BaseViewModel() { + + private val mangaCache = LongSparseArray() + + val items = workScheduler.observeWorks() + .mapLatest { list -> + list.mapList() + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + + private suspend fun List.mapList(): List { + 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) + 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, + ), + ) + } + return destination + } + + private suspend fun WorkInfo.toUiModel(): DownloadItemModel? { + 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 + } + return DownloadItemModel( + id = id, + workState = state, + manga = manga, + error = null, + max = DownloadState2.getMax(workData), + progress = DownloadState2.getProgress(workData), + eta = DownloadState2.getEta(workData), + createdAt = DownloadState2.getTimestamp(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() + val diffDays = -date.daysDiff(System.currentTimeMillis()) + return when { + diffMinutes < 3 -> DateTimeAgo.JustNow + diffDays < 1 -> DateTimeAgo.Today + diffDays == 1 -> DateTimeAgo.Yesterday + diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays) + else -> DateTimeAgo.Absolute(date) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt index 499f88f34..791201668 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt @@ -27,4 +27,4 @@ class PausingHandle { fun resume() { paused.value = false } -} \ No newline at end of file +} 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 b610d2427..933c31b4d 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 @@ -17,14 +17,16 @@ import androidx.work.WorkManager import coil.ImageLoader import coil.request.ImageRequest import coil.size.Scale -import dagger.Reusable +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.R import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.domain.DownloadState2 -import org.koitharu.kotatsu.download.ui.DownloadsActivity +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.format @@ -34,16 +36,15 @@ 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 -import javax.inject.Inject import com.google.android.material.R as materialR private const val CHANNEL_ID = "download" private const val GROUP_ID = "downloads" -@Reusable -class DownloadNotificationFactory @Inject constructor( +class DownloadNotificationFactory @AssistedInject constructor( @ApplicationContext private val context: Context, private val coil: ImageLoader, + @Assisted private val uuid: UUID, ) { private val covers = HashMap() @@ -64,6 +65,30 @@ class DownloadNotificationFactory @Inject constructor( false, ) + private val actionCancel by lazy { + NotificationCompat.Action( + materialR.drawable.material_ic_clear_black_24dp, + context.getString(android.R.string.cancel), + WorkManager.getInstance(context).createCancelPendingIntent(uuid), + ) + } + + private val actionPause by lazy { + NotificationCompat.Action( + R.drawable.ic_action_pause, + context.getString(R.string.pause), + PausingReceiver.createPausePendingIntent(context, uuid), + ) + } + + private val actionResume by lazy { + NotificationCompat.Action( + R.drawable.ic_action_resume, + context.getString(R.string.resume), + PausingReceiver.createResumePendingIntent(context, uuid), + ) + } + init { createChannel() builder.setOnlyAlertOnce(true) @@ -73,6 +98,7 @@ class DownloadNotificationFactory @Inject constructor( builder.setSilent(true) builder.setGroup(GROUP_ID) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + builder.priority = NotificationCompat.PRIORITY_DEFAULT } suspend fun create(state: DownloadState2?): Notification = mutex.withLock { @@ -93,21 +119,12 @@ class DownloadNotificationFactory @Inject constructor( NotificationCompat.VISIBILITY_PUBLIC }, ) - when (state?.state) { - null -> Unit - DownloadState2.State.CANCELLED -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.cancelling_)) - builder.setContentIntent(null) - builder.setStyle(null) - builder.setOngoing(true) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - DownloadState2.State.DONE -> { + when { + state == null -> Unit + state.localManga != null -> { // downloaded, final state builder.setProgress(0, 0, false) builder.setContentText(context.getString(R.string.download_complete)) - builder.setContentIntent(createMangaIntent(context, state.localManga?.manga)) + builder.setContentIntent(createMangaIntent(context, state.localManga.manga)) builder.setAutoCancel(true) builder.setSmallIcon(android.R.drawable.stat_sys_download_done) builder.setCategory(null) @@ -115,12 +132,26 @@ class DownloadNotificationFactory @Inject constructor( builder.setOngoing(false) builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) - builder.priority = NotificationCompat.PRIORITY_DEFAULT } - DownloadState2.State.FAILED -> { - val message = state.error?.getDisplayMessage(context.resources) - ?: context.getString(R.string.error_occurred) + state.isPaused -> { // paused (with error or manually) + 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.setCategory(NotificationCompat.CATEGORY_PROGRESS) + builder.setStyle(null) + builder.setOngoing(true) + builder.setSmallIcon(R.drawable.ic_stat_paused) + builder.addAction(actionCancel) + builder.addAction(actionResume) + } + + 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)) @@ -131,23 +162,17 @@ class DownloadNotificationFactory @Inject constructor( builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) - builder.priority = NotificationCompat.PRIORITY_DEFAULT } - DownloadState2.State.PREPARING -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.preparing_)) - builder.setStyle(null) - builder.setOngoing(true) - builder.addAction(createCancelAction(state.id)) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - DownloadState2.State.PROGRESS -> { + else -> { builder.setProgress(state.max, state.progress, false) val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) - if (state.timeLeft > 0L) { - val eta = DateUtils.getRelativeTimeSpanString(state.timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) + if (state.eta > 0L) { + val eta = DateUtils.getRelativeTimeSpanString( + state.eta, + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS, + ) builder.setContentText(eta) builder.setSubText(percent) } else { @@ -156,11 +181,9 @@ class DownloadNotificationFactory @Inject constructor( builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) - builder.addAction(createCancelAction(state.id)) - builder.priority = NotificationCompat.PRIORITY_DEFAULT + builder.addAction(actionCancel) + builder.addAction(actionPause) } - - DownloadState2.State.PAUSED -> TODO() } return builder.build() } @@ -177,12 +200,6 @@ class DownloadNotificationFactory @Inject constructor( false, ) - private fun createCancelAction(uuid: UUID) = NotificationCompat.Action( - materialR.drawable.material_ic_clear_black_24dp, - context.getString(android.R.string.cancel), - WorkManager.getInstance(context).createCancelPendingIntent(uuid), - ) - private suspend fun getCover(manga: Manga) = covers[manga] ?: run { runCatchingCancellable { coil.execute( @@ -217,4 +234,10 @@ class DownloadNotificationFactory @Inject constructor( } } } + + @AssistedFactory + interface Factory { + + fun create(uuid: UUID): DownloadNotificationFactory + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt index 4918d6e9f..69453b711 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt @@ -4,7 +4,7 @@ import android.view.View import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.download.ui.DownloadsActivity +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.utils.ext.findActivity 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 140d1e54b..0014cfef4 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 @@ -3,7 +3,9 @@ package org.koitharu.kotatsu.download.ui.worker import android.app.NotificationManager import android.content.Context import android.webkit.MimeTypeMap +import androidx.core.content.ContextCompat import androidx.hilt.work.HiltWorker +import androidx.lifecycle.asFlow import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.Data @@ -12,6 +14,7 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await @@ -22,6 +25,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -50,6 +54,8 @@ import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit import javax.inject.Inject @HiltWorker @@ -63,32 +69,38 @@ class DownloadWorker @AssistedInject constructor( private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, - private val notificationFactory: DownloadNotificationFactory, + notificationFactoryFactory: DownloadNotificationFactory.Factory, ) : CoroutineWorker(appContext, params) { + private val notificationFactory = notificationFactoryFactory.create(params.id) private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @Volatile private lateinit var currentState: DownloadState2 + private val pausingHandle = PausingHandle() private val timeLeftEstimator = TimeLeftEstimator() private val notificationThrottler = Throttler(400) + private val pausingReceiver = PausingReceiver(params.id, pausingHandle) override suspend fun doWork(): Result { setForeground(getForegroundInfo()) val mangaId = inputData.getLong(MANGA_ID, 0L) val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } - currentState = DownloadState2(id, manga, DownloadState2.State.PREPARING) - val pausingHandle = PausingHandle() - downloadMangaImpl(chaptersIds, pausingHandle) - val outputData = currentState.toWorkData() - return when (currentState.state) { - DownloadState2.State.CANCELLED, - DownloadState2.State.DONE -> Result.success(outputData) - - DownloadState2.State.FAILED -> Result.failure(outputData) - else -> Result.retry() + currentState = DownloadState2(manga, isIndeterminate = true) + return try { + downloadMangaImpl(chaptersIds) + Result.success(currentState.toWorkData()) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + e.printStackTraceDebug() + Result.retry() + } catch (e: Exception) { + e.printStackTraceDebug() + currentState = currentState.copy(error = e) + Result.failure(currentState.toWorkData()) } } @@ -97,13 +109,16 @@ class DownloadWorker @AssistedInject constructor( notificationFactory.create(null), ) - private suspend fun downloadMangaImpl( - chaptersIds: LongArray?, - pausingHandle: PausingHandle, - ) { + private suspend fun downloadMangaImpl(chaptersIds: LongArray?) { var manga = currentState.manga val chaptersIdsSet = chaptersIds?.toMutableSet() withMangaLock(manga) { + ContextCompat.registerReceiver( + applicationContext, + pausingReceiver, + PausingReceiver.createIntentFilter(id), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) val destination = localMangaRepository.getOutputDir(manga) checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } val tempFileName = "${manga.id}_$id.tmp" @@ -149,12 +164,11 @@ class DownloadWorker @AssistedInject constructor( } publishState( currentState.copy( - state = DownloadState2.State.PROGRESS, totalChapters = chapters.size, currentChapter = chapterIndex, totalPages = pages.size, currentPage = pageIndex, - timeLeft = timeLeftEstimator.getEstimatedTimeLeft(), + eta = timeLeftEstimator.getEta(), ), ) @@ -168,20 +182,20 @@ class DownloadWorker @AssistedInject constructor( }.onFailure(Throwable::printStackTraceDebug) } } - publishState(currentState.copy(state = DownloadState2.State.PROGRESS)) + publishState(currentState.copy(isIndeterminate = true)) output.mergeWithExisting() output.finish() val localManga = LocalMangaInput.of(output.rootFile).getManga() localStorageChanges.emit(localManga) - publishState(currentState.copy(state = DownloadState2.State.DONE, localManga = localManga)) - } catch (e: CancellationException) { - publishState(currentState.copy(state = DownloadState2.State.CANCELLED)) + publishState(currentState.copy(localManga = localManga)) + } catch (e: Exception) { + if (e !is CancellationException) { + publishState(currentState.copy(error = e)) + } throw e - } catch (e: Throwable) { - e.printStackTraceDebug() - publishState(currentState.copy(state = DownloadState2.State.FAILED, error = e)) } finally { withContext(NonCancellable) { + applicationContext.unregisterReceiver(pausingReceiver) output?.closeQuietly() output?.cleanup() File(destination, tempFileName).deleteAwait() @@ -194,17 +208,22 @@ class DownloadWorker @AssistedInject constructor( pausingHandle: PausingHandle, block: suspend () -> R, ): R { + if (pausingHandle.isPaused) { + publishState(currentState.copy(isPaused = true)) + pausingHandle.awaitResumed() + publishState(currentState.copy(isPaused = false)) + } var countDown = MAX_FAILSAFE_ATTEMPTS failsafe@ while (true) { try { return block() } catch (e: IOException) { if (countDown <= 0) { - publishState(currentState.copy(state = DownloadState2.State.PAUSED, error = e)) + publishState(currentState.copy(isPaused = true, error = e)) countDown = MAX_FAILSAFE_ATTEMPTS pausingHandle.pause() pausingHandle.awaitResumed() - publishState(currentState.copy(state = DownloadState2.State.PROGRESS, error = null)) + publishState(currentState.copy(isPaused = false, error = null)) } else { countDown-- delay(DOWNLOAD_ERROR_DELAY) @@ -222,6 +241,7 @@ class DownloadWorker @AssistedInject constructor( val request = Request.Builder() .url(url) .tag(MangaSource::class.java, source) + .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .get() .build() @@ -236,15 +256,15 @@ class DownloadWorker @AssistedInject constructor( private suspend fun publishState(state: DownloadState2) { currentState = state - if (state.state == DownloadState2.State.PROGRESS && state.max > 0) { + if (!state.isPaused && state.max > 0) { timeLeftEstimator.tick(state.progress, state.max) } else { timeLeftEstimator.emptyTick() notificationThrottler.reset() } val notification = notificationFactory.create(state) - if (state.isTerminal) { - notificationManager.notify(state.id.toString(), id.hashCode(), notification) + if (state.isFinalState) { + notificationManager.notify(id.toString(), id.hashCode(), notification) } else if (notificationThrottler.throttle()) { notificationManager.notify(id.hashCode(), notification) } @@ -264,6 +284,9 @@ class DownloadWorker @AssistedInject constructor( private val dataRepository: MangaDataRepository, ) { + private val workManager: WorkManager + inline get() = WorkManager.getInstance(context) + suspend fun schedule(manga: Manga, chaptersIds: Collection?) { dataRepository.storeManga(manga) val data = Data.Builder() @@ -284,6 +307,14 @@ class DownloadWorker @AssistedInject constructor( scheduleImpl(data).await() } + fun observeWorks(): Flow> = workManager + .getWorkInfosByTagLiveData(TAG) + .asFlow() + + suspend fun cancel(id: UUID) { + workManager.cancelWorkById(id).await() + } + private fun scheduleImpl(data: Collection): Operation { val constraints = Constraints.Builder() .setRequiresStorageNotLow(true) @@ -293,11 +324,12 @@ class DownloadWorker @AssistedInject constructor( OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG) + .keepResultsForAtLeast(3, TimeUnit.DAYS) .setInputData(inputData) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) .build() } - return WorkManager.getInstance(context).enqueue(requests) + return workManager.enqueue(requests) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt new file mode 100644 index 000000000..5f01711f5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt @@ -0,0 +1,66 @@ +package org.koitharu.kotatsu.download.ui.worker + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.PatternMatcher +import androidx.core.app.PendingIntentCompat +import org.koitharu.kotatsu.download.ui.service.PausingHandle +import org.koitharu.kotatsu.utils.ext.toUUIDOrNull +import java.util.UUID + +class PausingReceiver( + private val id: UUID, + private val pausingHandle: PausingHandle, +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent?) { + val uuid = intent?.getStringExtra(EXTRA_UUID)?.toUUIDOrNull() + assert(uuid == id) + when (intent?.action) { + ACTION_RESUME -> pausingHandle.resume() + ACTION_PAUSE -> pausingHandle.pause() + } + } + + companion object { + + private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" + private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" + private const val EXTRA_UUID = "uuid" + private const val SCHEME = "workuid" + + fun createIntentFilter(id: UUID) = IntentFilter().apply { + addAction(ACTION_PAUSE) + addAction(ACTION_RESUME) + addDataScheme(SCHEME) + addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) + } + + fun getPauseIntent(id: UUID) = Intent(ACTION_PAUSE) + .setData(Uri.parse("$SCHEME://$id")) + .putExtra(EXTRA_UUID, id.toString()) + + fun getResumeIntent(id: UUID) = Intent(ACTION_RESUME) + .setData(Uri.parse("$SCHEME://$id")) + .putExtra(EXTRA_UUID, id.toString()) + + fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( + context, + 0, + getPauseIntent(id), + 0, + false, + ) + + fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( + context, + 0, + getResumeIntent(id), + 0, + false, + ) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt index 1e25674b1..37f8fd6fd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt @@ -9,9 +9,13 @@ class Throttler( private var lastTick = 0L fun throttle(): Boolean { - val prevValue = lastTick - lastTick = SystemClock.elapsedRealtime() - return lastTick > prevValue + timeoutMs + val now = SystemClock.elapsedRealtime() + return if (lastTick + timeoutMs <= now) { + lastTick = now + true + } else { + false + } } fun reset() { diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt index 798fa7c33..cad841abc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt @@ -16,7 +16,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.databinding.FragmentToolsBinding -import org.koitharu.kotatsu.download.ui.DownloadsActivity +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.about.AppUpdateDialog diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index badf5ae7c..a5054b5e0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.utils.ext +import java.util.UUID + inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { return if (this.isNullOrEmpty()) defaultValue() else this } @@ -11,4 +13,11 @@ fun String.longHashCode(): Long { h = 31 * h + this[i].code } return h -} \ No newline at end of file +} + +fun String.toUUIDOrNull(): UUID? = try { + UUID.fromString(this) +} catch (e: IllegalArgumentException) { + e.printStackTraceDebug() + null +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt index f998a5119..b454535f7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt @@ -42,9 +42,14 @@ class TimeLeftEstimator { return if (eta < tooLargeTime) eta else NO_TIME } + fun getEta(): Long { + val etl = getEstimatedTimeLeft() + return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl + } + private class Tick( - val value: Int, - val total: Int, - val time: Long, + @JvmField val value: Int, + @JvmField val total: Int, + @JvmField val time: Long, ) -} \ No newline at end of file +} 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 new file mode 100644 index 000000000..0e71a1771 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_paused.png b/app/src/main/res/drawable-hdpi/ic_stat_paused.png new file mode 100644 index 0000000000000000000000000000000000000000..dade9e39a808ae49fa5964755434087df2e9b800 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBay?xfLn`9lo-^cXFc4w67?P!2 z^*^~%C|}@mny19Z4YSzy|K<+vvRkn=uOK@w=yxRB?srATp*QEwzEZbNX6YTDrv(QT zk^~GpBwTou*aVw99FHKl6Yn?s?NByf`S(q8Z28WM(){O@&e@&pSN8;3%;4$j=d#Wz Gp$PyMB|2;X literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_stat_paused.png b/app/src/main/res/drawable-mdpi/ic_stat_paused.png new file mode 100644 index 0000000000000000000000000000000000000000..a40b746f13f996db5cdac51f64570114733b59a9 GIT binary patch literal 131 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjUY;(FAr*0N&mQDtP!M2t{GEUL zkY7ti!bPSjXUw@X-)e_mecf>Pke}7=+`cvHW!%;$8#hjtkwBw%%G+czq<-7XS6=uw Q1!yXRr>mdKI;Vst05&BpB>(^b literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_paused.png b/app/src/main/res/drawable-xhdpi/ic_stat_paused.png new file mode 100644 index 0000000000000000000000000000000000000000..0668229665a330cd9c7966f5ad2ca788121b3137 GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png b/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png new file mode 100644 index 0000000000000000000000000000000000000000..6ceff5260ca0a11aea9432fbb9f72043af25cafe GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_action_pause.xml b/app/src/main/res/drawable/ic_action_pause.xml new file mode 100644 index 000000000..8e5ee878f --- /dev/null +++ b/app/src/main/res/drawable/ic_action_pause.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_action_resume.xml b/app/src/main/res/drawable/ic_action_resume.xml new file mode 100644 index 000000000..876e2efff --- /dev/null +++ b/app/src/main/res/drawable/ic_action_resume.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml index 64c63cffe..4c1397a85 100644 --- a/app/src/main/res/layout/activity_downloads.xml +++ b/app/src/main/res/layout/activity_downloads.xml @@ -41,15 +41,4 @@ app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" tools:listitem="@layout/item_download" /> - - diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml index 7369b59a4..39ea0c7ed 100644 --- a/app/src/main/res/layout/item_download.xml +++ b/app/src/main/res/layout/item_download.xml @@ -92,14 +92,29 @@ app:layout_constraintTop_toBottomOf="@id/textView_status" tools:text="@tools:sample/lorem[3]" /> +