Manage download states

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

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

@ -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
} }
@ -12,3 +14,10 @@ fun String.longHashCode(): Long {
} }
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,
) )
} }

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

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

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

@ -92,14 +92,29 @@
app:layout_constraintTop_toBottomOf="@id/textView_status" app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" /> tools:text="@tools:sample/lorem[3]" />
<Button
android:id="@+id/button_pause"
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/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 <Button
android:id="@+id/button_resume" android:id="@+id/button_resume"
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/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>

Loading…
Cancel
Save