Manage download states
This commit is contained in:
@@ -98,11 +98,12 @@ dependencies {
|
|||||||
implementation 'androidx.recyclerview:recyclerview:1.3.0'
|
implementation 'androidx.recyclerview:recyclerview:1.3.0'
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
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 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||||
implementation 'com.google.android.material:material:1.8.0'
|
implementation 'com.google.android.material:material:1.8.0'
|
||||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
||||||
|
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||||
|
implementation 'com.google.guava:guava:31.1-android'
|
||||||
|
|
||||||
implementation 'androidx.room:room-runtime:2.5.1'
|
implementation 'androidx.room:room-runtime:2.5.1'
|
||||||
implementation 'androidx.room:room-ktx:2.5.1'
|
implementation 'androidx.room:room-ktx:2.5.1'
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
|
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
android:name="org.koitharu.kotatsu.download.ui.list.DownloadsActivity"
|
||||||
android:label="@string/downloads"
|
android:label="@string/downloads"
|
||||||
android:launchMode="singleTop" />
|
android:launchMode="singleTop" />
|
||||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
|
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
|
||||||
|
|||||||
@@ -4,45 +4,57 @@ import androidx.work.Data
|
|||||||
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
|
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
|
||||||
import org.koitharu.kotatsu.local.data.LocalManga
|
import org.koitharu.kotatsu.local.data.LocalManga
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import java.util.UUID
|
import java.util.Date
|
||||||
|
|
||||||
data class DownloadState2(
|
data class DownloadState2(
|
||||||
val id: UUID,
|
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val state: State,
|
val isIndeterminate: Boolean,
|
||||||
|
val isPaused: Boolean = false,
|
||||||
val error: Throwable? = null,
|
val error: Throwable? = null,
|
||||||
val totalChapters: Int = 0,
|
val totalChapters: Int = 0,
|
||||||
val currentChapter: Int = 0,
|
val currentChapter: Int = 0,
|
||||||
val totalPages: Int = 0,
|
val totalPages: Int = 0,
|
||||||
val currentPage: Int = 0,
|
val currentPage: Int = 0,
|
||||||
val timeLeft: Long = -1L,
|
val eta: Long = -1L,
|
||||||
val localManga: LocalManga? = null,
|
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 max: Int = totalChapters * totalPages
|
||||||
|
|
||||||
val progress: Int = totalPages * currentChapter + currentPage + 1
|
val progress: Int = totalPages * currentChapter + currentPage + 1
|
||||||
|
|
||||||
val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE
|
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()
|
fun toWorkData() = Data.Builder()
|
||||||
.putString(DATA_UUID, id.toString())
|
|
||||||
.putLong(DATA_MANGA_ID, manga.id)
|
.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()
|
.build()
|
||||||
|
|
||||||
enum class State {
|
|
||||||
|
|
||||||
PREPARING, PROGRESS, PAUSED, FAILED, CANCELLED, DONE
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val DATA_UUID = "uuid"
|
|
||||||
private const val DATA_MANGA_ID = "manga_id"
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<DownloadItem, DownloadItem, ItemDownloadBinding>(
|
|
||||||
{ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<DownloadState>
|
|
||||||
|
|
||||||
class DownloadsAdapter(
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
coil: ImageLoader,
|
|
||||||
) : AsyncListDifferDelegationAdapter<DownloadItem>(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<DownloadItem>() {
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<DownloadItemModel, ListModel, ItemDownloadBinding>(
|
||||||
|
{ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.download.ui.list
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
|
|
||||||
|
interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
|
||||||
|
|
||||||
|
fun onCancelClick(item: DownloadItemModel)
|
||||||
|
|
||||||
|
fun onPauseClick(item: DownloadItemModel)
|
||||||
|
|
||||||
|
fun onResumeClick(item: DownloadItemModel)
|
||||||
|
|
||||||
|
fun onRetryClick(item: DownloadItemModel)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.download.ui
|
package org.koitharu.kotatsu.download.ui.list
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil.ImageLoader
|
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.BaseActivity
|
||||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||||
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
|
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.download.ui.worker.PausingReceiver
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
|
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(), DownloadItemListener {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var coil: ImageLoader
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private val viewModel by viewModels<DownloadsViewModel>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
|
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
val adapter = DownloadsAdapter(this, coil)
|
val adapter = DownloadsAdapter(this, coil, this)
|
||||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
||||||
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
|
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
viewModel.items.observe(this) {
|
||||||
|
adapter.items = it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
@@ -42,6 +52,26 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
companion object {
|
||||||
|
|
||||||
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
|
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
|
||||||
@@ -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<ListModel>(DiffCallback()) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil, listener))
|
||||||
|
.addDelegate(loadingStateAD())
|
||||||
|
.addDelegate(emptyStateListAD(coil, lifecycleOwner, null))
|
||||||
|
.addDelegate(relatedDateItemAD())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Manga>()
|
||||||
|
|
||||||
|
val items = workScheduler.observeWorks()
|
||||||
|
.mapLatest { list ->
|
||||||
|
list.mapList()
|
||||||
|
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||||
|
|
||||||
|
private suspend fun List<WorkInfo>.mapList(): List<ListModel> {
|
||||||
|
val destination = ArrayList<ListModel>((size * 1.4).toInt())
|
||||||
|
var prevDate: DateTimeAgo? = null
|
||||||
|
for (item in this) {
|
||||||
|
val model = item.toUiModel() ?: continue
|
||||||
|
val date = timeAgo(model.createdAt)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,4 @@ class PausingHandle {
|
|||||||
fun resume() {
|
fun resume() {
|
||||||
paused.value = false
|
paused.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,16 @@ import androidx.work.WorkManager
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Scale
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadState2
|
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.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.format
|
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.getDrawableOrThrow
|
||||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
private const val CHANNEL_ID = "download"
|
private const val CHANNEL_ID = "download"
|
||||||
private const val GROUP_ID = "downloads"
|
private const val GROUP_ID = "downloads"
|
||||||
|
|
||||||
@Reusable
|
class DownloadNotificationFactory @AssistedInject constructor(
|
||||||
class DownloadNotificationFactory @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val coil: ImageLoader,
|
private val coil: ImageLoader,
|
||||||
|
@Assisted private val uuid: UUID,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val covers = HashMap<Manga, Drawable>()
|
private val covers = HashMap<Manga, Drawable>()
|
||||||
@@ -64,6 +65,30 @@ class DownloadNotificationFactory @Inject constructor(
|
|||||||
false,
|
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 {
|
init {
|
||||||
createChannel()
|
createChannel()
|
||||||
builder.setOnlyAlertOnce(true)
|
builder.setOnlyAlertOnce(true)
|
||||||
@@ -73,6 +98,7 @@ class DownloadNotificationFactory @Inject constructor(
|
|||||||
builder.setSilent(true)
|
builder.setSilent(true)
|
||||||
builder.setGroup(GROUP_ID)
|
builder.setGroup(GROUP_ID)
|
||||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||||
|
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun create(state: DownloadState2?): Notification = mutex.withLock {
|
suspend fun create(state: DownloadState2?): Notification = mutex.withLock {
|
||||||
@@ -93,21 +119,12 @@ class DownloadNotificationFactory @Inject constructor(
|
|||||||
NotificationCompat.VISIBILITY_PUBLIC
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
when (state?.state) {
|
when {
|
||||||
null -> Unit
|
state == null -> Unit
|
||||||
DownloadState2.State.CANCELLED -> {
|
state.localManga != null -> { // downloaded, final state
|
||||||
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 -> {
|
|
||||||
builder.setProgress(0, 0, false)
|
builder.setProgress(0, 0, false)
|
||||||
builder.setContentText(context.getString(R.string.download_complete))
|
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.setAutoCancel(true)
|
||||||
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
builder.setCategory(null)
|
builder.setCategory(null)
|
||||||
@@ -115,12 +132,26 @@ class DownloadNotificationFactory @Inject constructor(
|
|||||||
builder.setOngoing(false)
|
builder.setOngoing(false)
|
||||||
builder.setShowWhen(true)
|
builder.setShowWhen(true)
|
||||||
builder.setWhen(System.currentTimeMillis())
|
builder.setWhen(System.currentTimeMillis())
|
||||||
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadState2.State.FAILED -> {
|
state.isPaused -> { // paused (with error or manually)
|
||||||
val message = state.error?.getDisplayMessage(context.resources)
|
builder.setProgress(state.max, state.progress, false)
|
||||||
?: context.getString(R.string.error_occurred)
|
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.setProgress(0, 0, false)
|
||||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
builder.setSubText(context.getString(R.string.error))
|
builder.setSubText(context.getString(R.string.error))
|
||||||
@@ -131,23 +162,17 @@ class DownloadNotificationFactory @Inject constructor(
|
|||||||
builder.setShowWhen(true)
|
builder.setShowWhen(true)
|
||||||
builder.setWhen(System.currentTimeMillis())
|
builder.setWhen(System.currentTimeMillis())
|
||||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||||
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadState2.State.PREPARING -> {
|
else -> {
|
||||||
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 -> {
|
|
||||||
builder.setProgress(state.max, state.progress, false)
|
builder.setProgress(state.max, state.progress, false)
|
||||||
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
|
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
|
||||||
if (state.timeLeft > 0L) {
|
if (state.eta > 0L) {
|
||||||
val eta = DateUtils.getRelativeTimeSpanString(state.timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
|
val eta = DateUtils.getRelativeTimeSpanString(
|
||||||
|
state.eta,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
DateUtils.SECOND_IN_MILLIS,
|
||||||
|
)
|
||||||
builder.setContentText(eta)
|
builder.setContentText(eta)
|
||||||
builder.setSubText(percent)
|
builder.setSubText(percent)
|
||||||
} else {
|
} else {
|
||||||
@@ -156,11 +181,9 @@ class DownloadNotificationFactory @Inject constructor(
|
|||||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
builder.setStyle(null)
|
builder.setStyle(null)
|
||||||
builder.setOngoing(true)
|
builder.setOngoing(true)
|
||||||
builder.addAction(createCancelAction(state.id))
|
builder.addAction(actionCancel)
|
||||||
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
builder.addAction(actionPause)
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadState2.State.PAUSED -> TODO()
|
|
||||||
}
|
}
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
@@ -177,12 +200,6 @@ class DownloadNotificationFactory @Inject constructor(
|
|||||||
false,
|
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 {
|
private suspend fun getCover(manga: Manga) = covers[manga] ?: run {
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
coil.execute(
|
coil.execute(
|
||||||
@@ -217,4 +234,10 @@ class DownloadNotificationFactory @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
|
||||||
|
fun create(uuid: UUID): DownloadNotificationFactory
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.view.View
|
|||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import org.koitharu.kotatsu.R
|
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.main.ui.owners.BottomNavOwner
|
||||||
import org.koitharu.kotatsu.utils.ext.findActivity
|
import org.koitharu.kotatsu.utils.ext.findActivity
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.download.ui.worker
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
|
import androidx.lifecycle.asFlow
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
@@ -12,6 +14,7 @@ import androidx.work.NetworkType
|
|||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.Operation
|
import androidx.work.Operation
|
||||||
import androidx.work.OutOfQuotaPolicy
|
import androidx.work.OutOfQuotaPolicy
|
||||||
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import androidx.work.await
|
import androidx.work.await
|
||||||
@@ -22,6 +25,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
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.ext.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
|
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltWorker
|
@HiltWorker
|
||||||
@@ -63,32 +69,38 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||||
private val notificationFactory: DownloadNotificationFactory,
|
notificationFactoryFactory: DownloadNotificationFactory.Factory,
|
||||||
) : CoroutineWorker(appContext, params) {
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
private val notificationFactory = notificationFactoryFactory.create(params.id)
|
||||||
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private lateinit var currentState: DownloadState2
|
private lateinit var currentState: DownloadState2
|
||||||
|
|
||||||
|
private val pausingHandle = PausingHandle()
|
||||||
private val timeLeftEstimator = TimeLeftEstimator()
|
private val timeLeftEstimator = TimeLeftEstimator()
|
||||||
private val notificationThrottler = Throttler(400)
|
private val notificationThrottler = Throttler(400)
|
||||||
|
private val pausingReceiver = PausingReceiver(params.id, pausingHandle)
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
setForeground(getForegroundInfo())
|
setForeground(getForegroundInfo())
|
||||||
val mangaId = inputData.getLong(MANGA_ID, 0L)
|
val mangaId = inputData.getLong(MANGA_ID, 0L)
|
||||||
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
|
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
|
||||||
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
||||||
currentState = DownloadState2(id, manga, DownloadState2.State.PREPARING)
|
currentState = DownloadState2(manga, isIndeterminate = true)
|
||||||
val pausingHandle = PausingHandle()
|
return try {
|
||||||
downloadMangaImpl(chaptersIds, pausingHandle)
|
downloadMangaImpl(chaptersIds)
|
||||||
val outputData = currentState.toWorkData()
|
Result.success(currentState.toWorkData())
|
||||||
return when (currentState.state) {
|
} catch (e: CancellationException) {
|
||||||
DownloadState2.State.CANCELLED,
|
throw e
|
||||||
DownloadState2.State.DONE -> Result.success(outputData)
|
} catch (e: IOException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
DownloadState2.State.FAILED -> Result.failure(outputData)
|
Result.retry()
|
||||||
else -> 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),
|
notificationFactory.create(null),
|
||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun downloadMangaImpl(
|
private suspend fun downloadMangaImpl(chaptersIds: LongArray?) {
|
||||||
chaptersIds: LongArray?,
|
|
||||||
pausingHandle: PausingHandle,
|
|
||||||
) {
|
|
||||||
var manga = currentState.manga
|
var manga = currentState.manga
|
||||||
val chaptersIdsSet = chaptersIds?.toMutableSet()
|
val chaptersIdsSet = chaptersIds?.toMutableSet()
|
||||||
withMangaLock(manga) {
|
withMangaLock(manga) {
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
applicationContext,
|
||||||
|
pausingReceiver,
|
||||||
|
PausingReceiver.createIntentFilter(id),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
val destination = localMangaRepository.getOutputDir(manga)
|
val destination = localMangaRepository.getOutputDir(manga)
|
||||||
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
|
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
|
||||||
val tempFileName = "${manga.id}_$id.tmp"
|
val tempFileName = "${manga.id}_$id.tmp"
|
||||||
@@ -149,12 +164,11 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
publishState(
|
publishState(
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
state = DownloadState2.State.PROGRESS,
|
|
||||||
totalChapters = chapters.size,
|
totalChapters = chapters.size,
|
||||||
currentChapter = chapterIndex,
|
currentChapter = chapterIndex,
|
||||||
totalPages = pages.size,
|
totalPages = pages.size,
|
||||||
currentPage = pageIndex,
|
currentPage = pageIndex,
|
||||||
timeLeft = timeLeftEstimator.getEstimatedTimeLeft(),
|
eta = timeLeftEstimator.getEta(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,20 +182,20 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
}.onFailure(Throwable::printStackTraceDebug)
|
}.onFailure(Throwable::printStackTraceDebug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
publishState(currentState.copy(state = DownloadState2.State.PROGRESS))
|
publishState(currentState.copy(isIndeterminate = true))
|
||||||
output.mergeWithExisting()
|
output.mergeWithExisting()
|
||||||
output.finish()
|
output.finish()
|
||||||
val localManga = LocalMangaInput.of(output.rootFile).getManga()
|
val localManga = LocalMangaInput.of(output.rootFile).getManga()
|
||||||
localStorageChanges.emit(localManga)
|
localStorageChanges.emit(localManga)
|
||||||
publishState(currentState.copy(state = DownloadState2.State.DONE, localManga = localManga))
|
publishState(currentState.copy(localManga = localManga))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: Exception) {
|
||||||
publishState(currentState.copy(state = DownloadState2.State.CANCELLED))
|
if (e !is CancellationException) {
|
||||||
|
publishState(currentState.copy(error = e))
|
||||||
|
}
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Throwable) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
publishState(currentState.copy(state = DownloadState2.State.FAILED, error = e))
|
|
||||||
} finally {
|
} finally {
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
|
applicationContext.unregisterReceiver(pausingReceiver)
|
||||||
output?.closeQuietly()
|
output?.closeQuietly()
|
||||||
output?.cleanup()
|
output?.cleanup()
|
||||||
File(destination, tempFileName).deleteAwait()
|
File(destination, tempFileName).deleteAwait()
|
||||||
@@ -194,17 +208,22 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
pausingHandle: PausingHandle,
|
pausingHandle: PausingHandle,
|
||||||
block: suspend () -> R,
|
block: suspend () -> R,
|
||||||
): R {
|
): R {
|
||||||
|
if (pausingHandle.isPaused) {
|
||||||
|
publishState(currentState.copy(isPaused = true))
|
||||||
|
pausingHandle.awaitResumed()
|
||||||
|
publishState(currentState.copy(isPaused = false))
|
||||||
|
}
|
||||||
var countDown = MAX_FAILSAFE_ATTEMPTS
|
var countDown = MAX_FAILSAFE_ATTEMPTS
|
||||||
failsafe@ while (true) {
|
failsafe@ while (true) {
|
||||||
try {
|
try {
|
||||||
return block()
|
return block()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
if (countDown <= 0) {
|
if (countDown <= 0) {
|
||||||
publishState(currentState.copy(state = DownloadState2.State.PAUSED, error = e))
|
publishState(currentState.copy(isPaused = true, error = e))
|
||||||
countDown = MAX_FAILSAFE_ATTEMPTS
|
countDown = MAX_FAILSAFE_ATTEMPTS
|
||||||
pausingHandle.pause()
|
pausingHandle.pause()
|
||||||
pausingHandle.awaitResumed()
|
pausingHandle.awaitResumed()
|
||||||
publishState(currentState.copy(state = DownloadState2.State.PROGRESS, error = null))
|
publishState(currentState.copy(isPaused = false, error = null))
|
||||||
} else {
|
} else {
|
||||||
countDown--
|
countDown--
|
||||||
delay(DOWNLOAD_ERROR_DELAY)
|
delay(DOWNLOAD_ERROR_DELAY)
|
||||||
@@ -222,6 +241,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.tag(MangaSource::class.java, source)
|
.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)
|
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
@@ -236,15 +256,15 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
private suspend fun publishState(state: DownloadState2) {
|
private suspend fun publishState(state: DownloadState2) {
|
||||||
currentState = state
|
currentState = state
|
||||||
if (state.state == DownloadState2.State.PROGRESS && state.max > 0) {
|
if (!state.isPaused && state.max > 0) {
|
||||||
timeLeftEstimator.tick(state.progress, state.max)
|
timeLeftEstimator.tick(state.progress, state.max)
|
||||||
} else {
|
} else {
|
||||||
timeLeftEstimator.emptyTick()
|
timeLeftEstimator.emptyTick()
|
||||||
notificationThrottler.reset()
|
notificationThrottler.reset()
|
||||||
}
|
}
|
||||||
val notification = notificationFactory.create(state)
|
val notification = notificationFactory.create(state)
|
||||||
if (state.isTerminal) {
|
if (state.isFinalState) {
|
||||||
notificationManager.notify(state.id.toString(), id.hashCode(), notification)
|
notificationManager.notify(id.toString(), id.hashCode(), notification)
|
||||||
} else if (notificationThrottler.throttle()) {
|
} else if (notificationThrottler.throttle()) {
|
||||||
notificationManager.notify(id.hashCode(), notification)
|
notificationManager.notify(id.hashCode(), notification)
|
||||||
}
|
}
|
||||||
@@ -264,6 +284,9 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
private val dataRepository: MangaDataRepository,
|
private val dataRepository: MangaDataRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val workManager: WorkManager
|
||||||
|
inline get() = WorkManager.getInstance(context)
|
||||||
|
|
||||||
suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?) {
|
suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?) {
|
||||||
dataRepository.storeManga(manga)
|
dataRepository.storeManga(manga)
|
||||||
val data = Data.Builder()
|
val data = Data.Builder()
|
||||||
@@ -284,6 +307,14 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
scheduleImpl(data).await()
|
scheduleImpl(data).await()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun observeWorks(): Flow<List<WorkInfo>> = workManager
|
||||||
|
.getWorkInfosByTagLiveData(TAG)
|
||||||
|
.asFlow()
|
||||||
|
|
||||||
|
suspend fun cancel(id: UUID) {
|
||||||
|
workManager.cancelWorkById(id).await()
|
||||||
|
}
|
||||||
|
|
||||||
private fun scheduleImpl(data: Collection<Data>): Operation {
|
private fun scheduleImpl(data: Collection<Data>): Operation {
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiresStorageNotLow(true)
|
.setRequiresStorageNotLow(true)
|
||||||
@@ -293,11 +324,12 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
OneTimeWorkRequestBuilder<DownloadWorker>()
|
OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.addTag(TAG)
|
.addTag(TAG)
|
||||||
|
.keepResultsForAtLeast(3, TimeUnit.DAYS)
|
||||||
.setInputData(inputData)
|
.setInputData(inputData)
|
||||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
return WorkManager.getInstance(context).enqueue(requests)
|
return workManager.enqueue(requests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,13 @@ class Throttler(
|
|||||||
private var lastTick = 0L
|
private var lastTick = 0L
|
||||||
|
|
||||||
fun throttle(): Boolean {
|
fun throttle(): Boolean {
|
||||||
val prevValue = lastTick
|
val now = SystemClock.elapsedRealtime()
|
||||||
lastTick = SystemClock.elapsedRealtime()
|
return if (lastTick + timeoutMs <= now) {
|
||||||
return lastTick > prevValue + timeoutMs
|
lastTick = now
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||||
import org.koitharu.kotatsu.core.github.AppVersion
|
import org.koitharu.kotatsu.core.github.AppVersion
|
||||||
import org.koitharu.kotatsu.databinding.FragmentToolsBinding
|
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.SettingsActivity
|
||||||
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
|
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.utils.ext
|
package org.koitharu.kotatsu.utils.ext
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
|
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
|
||||||
return if (this.isNullOrEmpty()) defaultValue() else this
|
return if (this.isNullOrEmpty()) defaultValue() else this
|
||||||
}
|
}
|
||||||
@@ -11,4 +13,11 @@ fun String.longHashCode(): Long {
|
|||||||
h = 31 * h + this[i].code
|
h = 31 * h + this[i].code
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.toUUIDOrNull(): UUID? = try {
|
||||||
|
UUID.fromString(this)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,9 +42,14 @@ class TimeLeftEstimator {
|
|||||||
return if (eta < tooLargeTime) eta else NO_TIME
|
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(
|
private class Tick(
|
||||||
val value: Int,
|
@JvmField val value: Int,
|
||||||
val total: Int,
|
@JvmField val total: Int,
|
||||||
val time: Long,
|
@JvmField val time: Long,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml
Normal file
15
app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="1.44427"
|
||||||
|
android:scaleY="1.44427"
|
||||||
|
android:translateX="-5.33124"
|
||||||
|
android:translateY="-5.33124">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_paused.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_stat_paused.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_paused.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_stat_paused.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_paused.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_stat_paused.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_paused.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_paused.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 223 B |
11
app/src/main/res/drawable/ic_action_pause.xml
Normal file
11
app/src/main/res/drawable/ic_action_pause.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M14,19H18V5H14M6,19H10V5H6V19Z" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/ic_action_resume.xml
Normal file
11
app/src/main/res/drawable/ic_action_resume.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
||||||
|
</vector>
|
||||||
@@ -41,15 +41,4 @@
|
|||||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
||||||
tools:listitem="@layout/item_download" />
|
tools:listitem="@layout/item_download" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/textView_holder"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:layout_margin="20dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@string/text_downloads_holder"
|
|
||||||
android:textAppearance="?attr/textAppearanceBody2"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|||||||
@@ -93,13 +93,28 @@
|
|||||||
tools:text="@tools:sample/lorem[3]" />
|
tools:text="@tools:sample/lorem[3]" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/button_resume"
|
android:id="@+id/button_pause"
|
||||||
style="@style/Widget.Material3.Button.TextButton"
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginEnd="4dp"
|
android:layout_marginEnd="4dp"
|
||||||
android:layout_marginBottom="4dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:text="@string/try_again"
|
android:text="@string/pause"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/button_resume"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/textView_details"
|
||||||
|
app:layout_constraintVertical_bias="1"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button_resume"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:text="@string/resume"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toStartOf="@id/button_cancel"
|
app:layout_constraintEnd_toStartOf="@id/button_cancel"
|
||||||
@@ -109,7 +124,7 @@
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/button_cancel"
|
android:id="@+id/button_cancel"
|
||||||
style="@style/Widget.Material3.Button.TextButton"
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginEnd="4dp"
|
android:layout_marginEnd="4dp"
|
||||||
|
|||||||
@@ -435,4 +435,7 @@
|
|||||||
<string name="show_on_shelf">Show on the Shelf</string>
|
<string name="show_on_shelf">Show on the Shelf</string>
|
||||||
<string name="sync_auth_hint">You can sign in into an existing account or create a new one</string>
|
<string name="sync_auth_hint">You can sign in into an existing account or create a new one</string>
|
||||||
<string name="find_similar">Find similar</string>
|
<string name="find_similar">Find similar</string>
|
||||||
|
<string name="pause">Pause</string>
|
||||||
|
<string name="resume">Resume</string>
|
||||||
|
<string name="paused">Paused</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user