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 000000000..dade9e39a
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_paused.png differ
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 000000000..a40b746f1
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_paused.png differ
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 000000000..066822966
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_paused.png differ
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 000000000..6ceff5260
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png differ
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]" />
+
+