Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
package org.koitharu.kotatsu.download.domain
import androidx.work.Data
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
data class DownloadState(
val manga: Manga,
val isIndeterminate: Boolean,
val isPaused: Boolean = false,
val isStopped: Boolean = false,
val error: String? = null,
val totalChapters: Int = 0,
val currentChapter: Int = 0,
val totalPages: Int = 0,
val currentPage: Int = 0,
val eta: Long = -1L,
val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0),
val timestamp: Long = System.currentTimeMillis(),
) {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE
val isFinalState: Boolean
get() = localManga != null || (error != null && !isPaused)
val isParticularProgress: Boolean
get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate
fun toWorkData() = Data.Builder()
.putLong(DATA_MANGA_ID, manga.id)
.putInt(DATA_MAX, max)
.putInt(DATA_PROGRESS, progress)
.putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
.build()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadState
if (manga != other.manga) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (isStopped != other.isStopped) return false
if (error != other.error) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
return percent == other.percent
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + isStopped.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
result = 31 * result + percent.hashCode()
return result
}
companion object {
private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate"
private const val DATA_PAUSED = "paused"
fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L)
fun isIndeterminate(data: Data): Boolean = data.getBoolean(DATA_INDETERMINATE, false)
fun isPaused(data: Data): Boolean = data.getBoolean(DATA_PAUSED, false)
fun getMax(data: Data): Int = data.getInt(DATA_MAX, 0)
fun getError(data: Data): String? = data.getString(DATA_ERROR)
fun getProgress(data: Data): Int = data.getInt(DATA_PROGRESS, 0)
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0)
}
}

View File

@@ -0,0 +1,140 @@
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.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format
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 = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item)
R.id.button_pause -> listener.onPauseClick(item)
else -> listener.onItemClick(item, v)
}
}
override fun onLongClick(v: View): Boolean {
return listener.onItemLongClick(item, v)
}
}
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
bind { payloads ->
binding.textViewTitle.text = item.manga.title
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.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.RUNNING -> {
binding.textViewStatus.setText(
if (item.isPaused) R.string.paused else R.string.manga_downloading_,
)
binding.progressBar.isIndeterminate = item.isIndeterminate
binding.progressBar.isVisible = true
binding.progressBar.max = item.max
binding.progressBar.isEnabled = !item.isPaused
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.textAndVisible = item.getEtaString()
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = item.isPaused
binding.buttonPause.isVisible = item.canPause
}
WorkInfo.State.SUCCEEDED -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
if (item.totalChapters > 0) {
binding.textViewDetails.text = context.resources.getQuantityString(
R.plurals.chapters,
item.totalChapters,
item.totalChapters,
)
binding.textViewDetails.isVisible = true
} else {
binding.textViewDetails.isVisible = false
}
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.FAILED -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.textAndVisible = item.error
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.CANCELLED -> {
binding.textViewStatus.setText(R.string.canceled)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
}
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.download.ui.list
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
fun onCancelClick(item: DownloadItemModel)
fun onPauseClick(item: DownloadItemModel)
fun onResumeClick(item: DownloadItemModel)
}

View File

@@ -0,0 +1,83 @@
package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils
import androidx.work.WorkInfo
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
import java.util.UUID
class DownloadItemModel(
val id: UUID,
val workState: WorkInfo.State,
val isIndeterminate: Boolean,
val isPaused: Boolean,
val manga: Manga,
val error: String?,
val max: Int,
val totalChapters: Int,
val progress: Int,
val eta: Long,
val timestamp: Date,
) : ListModel, Comparable<DownloadItemModel> {
val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f
val hasEta: Boolean
get() = workState == WorkInfo.State.RUNNING && !isPaused && eta > 0L
val canPause: Boolean
get() = workState == WorkInfo.State.RUNNING && !isPaused && error == null
val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(
eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
} else {
null
}
override fun compareTo(other: DownloadItemModel): Int {
return timestamp.compareTo(other.timestamp)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadItemModel
if (id != other.id) return false
if (workState != other.workState) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (manga != other.manga) return false
if (error != other.error) return false
if (max != other.max) return false
if (totalChapters != other.totalChapters) return false
if (progress != other.progress) return false
if (eta != other.eta) return false
return timestamp == other.timestamp
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + workState.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + manga.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + max
result = 31 * result + totalChapters
result = 31 * result + progress
result = 31 * result + eta.hashCode()
result = 31 * result + timestamp.hashCode()
return result
}
}

View File

@@ -0,0 +1,173 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.annotation.Px
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.PausingReceiver
import javax.inject.Inject
@AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
DownloadItemListener,
ListSelectionController.Callback2 {
@Inject
lateinit var coil: ImageLoader
private val viewModel by viewModels<DownloadsViewModel>()
private lateinit var selectionController: ListSelectionController
@Px
private var listSpacing = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
listSpacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val downloadsAdapter = DownloadsAdapter(this, coil, this)
val decoration = SpacingItemDecoration(listSpacing)
selectionController = ListSelectionController(
activity = this,
decoration = DownloadsSelectionDecoration(this),
registryOwner = this,
callback = this,
)
with(viewBinding.recyclerView) {
setHasFixedSize(true)
addItemDecoration(decoration)
adapter = downloadsAdapter
selectionController.attachToRecyclerView(this)
}
addMenuProvider(DownloadsMenuProvider(this, viewModel))
viewModel.items.observe(this) {
downloadsAdapter.items = it
}
viewModel.onActionDone.observe(this, ReversibleActionObserver(viewBinding.recyclerView))
val menuObserver = Observer<Any> { _ -> invalidateOptionsMenu() }
viewModel.hasActiveWorks.observe(this, menuObserver)
viewModel.hasPausedWorks.observe(this, menuObserver)
viewModel.hasCancellableWorks.observe(this, menuObserver)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.recyclerView.updatePadding(
left = insets.left + listSpacing,
right = insets.right + listSpacing,
bottom = insets.bottom,
)
viewBinding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
)
}
override fun onItemClick(item: DownloadItemModel, view: View) {
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
startActivity(DetailsActivity.newIntent(view.context, item.manga))
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
return selectionController.onItemLongClick(item.id.mostSignificantBits)
}
override fun onCancelClick(item: DownloadItemModel) {
viewModel.cancel(item.id)
}
override fun onPauseClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getPauseIntent(item.id))
}
override fun onResumeClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getResumeIntent(item.id))
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
viewBinding.recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_downloads, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_resume -> {
viewModel.resume(controller.snapshot())
mode.finish()
true
}
R.id.action_pause -> {
viewModel.pause(controller.snapshot())
mode.finish()
true
}
R.id.action_cancel -> {
viewModel.cancel(controller.snapshot())
mode.finish()
true
}
R.id.action_remove -> {
viewModel.remove(controller.snapshot())
mode.finish()
true
}
R.id.action_select_all -> {
controller.addAll(viewModel.allIds())
true
}
else -> false
}
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val snapshot = viewModel.snapshot(controller.peekCheckedIds())
var canPause = true
var canResume = true
var canCancel = true
var canRemove = true
for (item in snapshot) {
canPause = canPause and item.canPause
canResume = canResume and item.canResume
canCancel = canCancel and !item.workState.isFinished
canRemove = canRemove and item.workState.isFinished
}
menu.findItem(R.id.action_pause)?.isVisible = canPause
menu.findItem(R.id.action_resume)?.isVisible = canResume
menu.findItem(R.id.action_cancel)?.isVisible = canCancel
menu.findItem(R.id.action_remove)?.isVisible = canRemove
return super.onPrepareActionMode(controller, mode, menu)
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
}

View File

@@ -0,0 +1,65 @@
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.model.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(ITEM_TYPE_DOWNLOAD, 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)
}
}
}
companion object {
const val ITEM_TYPE_DOWNLOAD = 0
}
}

View File

@@ -0,0 +1,63 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
class DownloadsMenuProvider(
private val context: Context,
private val viewModel: DownloadsViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_downloads, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_pause -> viewModel.pauseAll()
R.id.action_resume -> viewModel.resumeAll()
R.id.action_cancel_all -> confirmCancelAll()
R.id.action_remove_completed -> confirmRemoveCompleted()
else -> return false
}
return true
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_pause)?.isVisible = viewModel.hasActiveWorks.value == true
menu.findItem(R.id.action_resume)?.isVisible = viewModel.hasPausedWorks.value == true
menu.findItem(R.id.action_cancel_all)?.isVisible = viewModel.hasCancellableWorks.value == true
}
private fun confirmCancelAll() {
MaterialAlertDialogBuilder(
context,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
).setTitle(R.string.cancel_all)
.setMessage(R.string.cancel_all_downloads_confirm)
.setIcon(R.drawable.ic_cancel_multiple)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.confirm) { _, _ ->
viewModel.cancelAll()
}.show()
}
private fun confirmRemoveCompleted() {
MaterialAlertDialogBuilder(
context,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
).setTitle(R.string.remove_completed)
.setMessage(R.string.remove_completed_downloads_confirm)
.setIcon(R.drawable.ic_clear_all)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.removeCompleted()
}.show()
}
}

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR
class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset)
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74,
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(DownloadItemModel::class.java) ?: return NO_ID
return item.id.mostSignificantBits
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
val isCard = child is CardView
val radius = (child as? CardView)?.radius ?: defaultRadius
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
if (isCard) {
checkIcon?.run {
setBounds(
(bounds.right - iconSize - iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.right - iconOffset).toInt(),
(bounds.top + iconOffset + iconSize).toInt(),
)
draw(canvas)
}
}
}
}

View File

@@ -0,0 +1,246 @@
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.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.download.domain.DownloadState
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.parsers.util.mapToSet
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>()
private val cacheMutex = Mutex()
private val works = workScheduler.observeWorks()
.mapLatest { it.toDownloadsList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val onActionDone = SingleLiveEvent<ReversibleAction>()
val items = works.map {
it?.toUiList() ?: listOf(LoadingState)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val hasPausedWorks = works.map {
it?.any { x -> x.canResume } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val hasActiveWorks = works.map {
it?.any { x -> x.canPause } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val hasCancellableWorks = works.map {
it?.any { x -> !x.workState.isFinished } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
fun cancel(id: UUID) {
launchJob(Dispatchers.Default) {
workScheduler.cancel(id)
}
}
fun cancel(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val snapshot = works.value ?: return@launchJob
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.cancel(work.id)
}
}
onActionDone.emitCall(ReversibleAction(R.string.downloads_cancelled, null))
}
}
fun cancelAll() {
launchJob(Dispatchers.Default) {
workScheduler.cancelAll()
onActionDone.emitCall(ReversibleAction(R.string.downloads_cancelled, null))
}
}
fun pause(ids: Set<Long>) {
val snapshot = works.value ?: return
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.pause(work.id)
}
}
onActionDone.call(ReversibleAction(R.string.downloads_paused, null))
}
fun pauseAll() {
val snapshot = works.value ?: return
var isPaused = false
for (work in snapshot) {
if (work.canPause) {
workScheduler.pause(work.id)
isPaused = true
}
}
if (isPaused) {
onActionDone.call(ReversibleAction(R.string.downloads_paused, null))
}
}
fun resumeAll() {
val snapshot = works.value ?: return
var isResumed = false
for (work in snapshot) {
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
workScheduler.resume(work.id)
isResumed = true
}
}
if (isResumed) {
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
}
}
fun resume(ids: Set<Long>) {
val snapshot = works.value ?: return
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.resume(work.id)
}
}
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
}
fun remove(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val snapshot = works.value ?: return@launchJob
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.delete(work.id)
}
}
onActionDone.emitCall(ReversibleAction(R.string.downloads_removed, null))
}
}
fun removeCompleted() {
launchJob(Dispatchers.Default) {
workScheduler.removeCompleted()
onActionDone.emitCall(ReversibleAction(R.string.downloads_removed, null))
}
}
fun snapshot(ids: Set<Long>): Collection<DownloadItemModel> {
return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty()
}
fun allIds(): Set<Long> = works.value?.mapToSet {
it.id.mostSignificantBits
} ?: emptySet()
private suspend fun List<WorkInfo>.toDownloadsList(): List<DownloadItemModel> {
if (isEmpty()) {
return emptyList()
}
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() }
list.sortByDescending { it.timestamp }
return list
}
private fun List<DownloadItemModel>.toUiList(): List<ListModel> {
if (isEmpty()) {
return emptyStateList()
}
val destination = ArrayList<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null
for (item in this) {
val date = timeAgo(item.timestamp)
if (prevDate != date) {
destination += date
}
prevDate = date
destination += item
}
return destination
}
private suspend fun WorkInfo.toUiModel(): DownloadItemModel? {
val workData = if (outputData == Data.EMPTY) progress else outputData
val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null
return DownloadItemModel(
id = id,
workState = state,
manga = manga,
error = DownloadState.getError(workData),
isIndeterminate = DownloadState.isIndeterminate(workData),
isPaused = DownloadState.isPaused(workData),
max = DownloadState.getMax(workData),
progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData),
totalChapters = DownloadState.getDownloadedChapters(workData).size,
)
}
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)
}
}
private fun emptyStateList() = listOf(
EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.text_downloads_list_holder,
textSecondary = 0,
actionStringRes = 0,
),
)
private suspend fun getManga(mangaId: Long): Manga? {
mangaCache[mangaId]?.let {
return it
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null
}
}
}
}

View File

@@ -0,0 +1,270 @@
package org.koitharu.kotatsu.download.ui.worker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.format.DateUtils
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.work.WorkManager
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.util.UUID
import com.google.android.material.R as materialR
private const val CHANNEL_ID = "download"
private const val GROUP_ID = "downloads"
class DownloadNotificationFactory @AssistedInject constructor(
@ApplicationContext private val context: Context,
private val coil: ImageLoader,
@Assisted private val uuid: UUID,
) {
private val covers = HashMap<Manga, Drawable>()
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val mutex = Mutex()
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width,
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val queueIntent = PendingIntentCompat.getActivity(
context,
0,
DownloadsActivity.newIntent(context),
0,
false,
)
private val actionCancel by lazy {
NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
WorkManager.getInstance(context).createCancelPendingIntent(uuid),
)
}
private val actionPause by lazy {
NotificationCompat.Action(
R.drawable.ic_action_pause,
context.getString(R.string.pause),
PausingReceiver.createPausePendingIntent(context, uuid),
)
}
private val actionResume by lazy {
NotificationCompat.Action(
R.drawable.ic_action_resume,
context.getString(R.string.resume),
PausingReceiver.createResumePendingIntent(context, uuid),
)
}
init {
createChannel()
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
suspend fun create(state: DownloadState?): Notification = mutex.withLock {
if (state == null) {
builder.setContentTitle(context.getString(R.string.manga_downloading_))
builder.setContentText(context.getString(R.string.preparing_))
} else {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
}
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(queueIntent)
builder.setStyle(null)
builder.setLargeIcon(if (state != null) getCover(state.manga)?.toBitmap() else null)
builder.clearActions()
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility(
if (state != null && state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
when {
state == null -> Unit
state.localManga != null -> { // downloaded, final state
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga.manga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
}
state.isStopped -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.setSmallIcon(R.drawable.ic_stat_paused)
builder.addAction(actionCancel)
}
state.isPaused -> { // paused (with error or manually)
builder.setProgress(state.max, state.progress, false)
val percent = if (state.percent >= 0) {
context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
} else {
null
}
if (state.error != null) {
builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error))
} else {
builder.setContentText(percent)
}
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
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(state.error)
builder.setAutoCancel(true)
builder.setOngoing(false)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error))
}
else -> {
builder.setProgress(state.max, state.progress, false)
builder.setContentText(getProgressString(state.percent, state.eta))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(actionCancel)
builder.addAction(actionPause)
}
}
return builder.build()
}
private fun getProgressString(percent: Float, eta: Long): CharSequence? {
val percentString = if (percent >= 0f) {
context.getString(R.string.percent_string_pattern, (percent * 100).format())
} else {
null
}
val etaString = if (eta > 0L) {
DateUtils.getRelativeTimeSpanString(
eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
} else {
null
}
return when {
percentString == null && etaString == null -> null
percentString != null && etaString == null -> percentString
percentString == null && etaString != null -> etaString
else -> context.getString(R.string.download_summary_pattern, percentString, etaString)
}
}
private fun createMangaIntent(context: Context, manga: Manga?) = PendingIntentCompat.getActivity(
context,
manga.hashCode(),
if (manga != null) {
DetailsActivity.newIntent(context, manga)
} else {
MangaListActivity.newIntent(context, MangaSource.LOCAL)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false,
)
private suspend fun getCover(manga: Manga) = covers[manga] ?: run {
runCatchingCancellable {
coil.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.allowHardware(false)
.tag(manga.source)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(),
).getDrawableOrThrow()
}.onSuccess {
covers[manga] = it
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = NotificationManagerCompat.from(context)
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW,
)
channel.enableVibration(false)
channel.enableLights(false)
channel.setSound(null, null)
manager.createNotificationChannel(channel)
}
}
}
@AssistedFactory
interface Factory {
fun create(uuid: UUID): DownloadNotificationFactory
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.download.ui.worker
import android.view.View
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
class DownloadStartedObserver(
private val snackbarHost: View,
) : Observer<Unit> {
override fun onChanged(value: Unit) {
val snackbar = Snackbar.make(snackbarHost, R.string.download_started, Snackbar.LENGTH_LONG)
(snackbarHost.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav
}
snackbar.setAction(R.string.details) {
it.context.startActivity(DownloadsActivity.newIntent(it.context))
}
snackbar.show()
}
}

View File

@@ -0,0 +1,455 @@
package org.koitharu.kotatsu.download.ui.worker
import android.app.NotificationManager
import android.content.Context
import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.asFlow
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import okio.buffer
import okio.sink
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.Throttler
import org.koitharu.kotatsu.core.util.WorkManagerHelper
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltWorker
class DownloadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
@MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val mangaDataRepository: MangaDataRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
private val notificationFactory = notificationFactoryFactory.create(params.id)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@Volatile
private var lastPublishedState: DownloadState? = null
private val currentState: DownloadState
get() = checkNotNull(lastPublishedState)
private val pausingHandle = PausingHandle()
private val timeLeftEstimator = TimeLeftEstimator()
private val notificationThrottler = Throttler(400)
private val pausingReceiver = PausingReceiver(params.id, pausingHandle)
override suspend fun doWork(): Result {
setForeground(getForegroundInfo())
val mangaId = inputData.getLong(MANGA_ID, 0L)
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters()
lastPublishedState = DownloadState(manga, isIndeterminate = true)
return try {
downloadMangaImpl(chaptersIds, downloadedIds)
Result.success(currentState.toWorkData())
} catch (e: CancellationException) {
withContext(NonCancellable) {
val notification = notificationFactory.create(currentState.copy(isStopped = true))
notificationManager.notify(id.hashCode(), notification)
}
throw e
} catch (e: IOException) {
e.printStackTraceDebug()
Result.retry()
} catch (e: Exception) {
e.printStackTraceDebug()
Result.failure(
currentState.copy(
error = e.getDisplayMessage(applicationContext.resources),
eta = -1L,
).toWorkData(),
)
} finally {
notificationManager.cancel(id.hashCode())
}
}
override suspend fun getForegroundInfo() = ForegroundInfo(
id.hashCode(),
notificationFactory.create(lastPublishedState),
)
private suspend fun downloadMangaImpl(
includedIds: LongArray?,
excludedIds: LongArray,
) {
var manga = currentState.manga
val chaptersToSkip = excludedIds.toMutableSet()
withMangaLock(manga) {
ContextCompat.registerReceiver(
applicationContext,
pausingReceiver,
PausingReceiver.createIntentFilter(id),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
val destination = localMangaRepository.getOutputDir(manga)
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$id.tmp"
var output: LocalMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance")
}
val repo = mangaRepositoryFactory.create(manga.source)
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, mangaDetails)
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
}
val chapters = getChapters(mangaDetails, includedIds)
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) {
publishState(
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
continue
}
val pages = runFailsafe(pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache.get(url)
?: downloadFile(url, destination, tempFileName, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
publishState(
currentState.copy(
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
isIndeterminate = false,
eta = timeLeftEstimator.getEta(),
),
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
if (output.flushChapter(chapter)) {
runCatchingCancellable {
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug)
}
publishState(
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
}
publishState(currentState.copy(isIndeterminate = true, eta = -1L))
output.mergeWithExisting()
output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga()
localStorageChanges.emit(localManga)
publishState(currentState.copy(localManga = localManga, eta = -1L))
} catch (e: Exception) {
if (e !is CancellationException) {
publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources)))
}
throw e
} finally {
withContext(NonCancellable) {
applicationContext.unregisterReceiver(pausingReceiver)
output?.closeQuietly()
output?.cleanup()
File(destination, tempFileName).deleteAwait()
}
}
}
}
private suspend fun <R> runFailsafe(
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
if (pausingHandle.isPaused) {
publishState(currentState.copy(isPaused = true, eta = -1L))
pausingHandle.awaitResumed()
publishState(currentState.copy(isPaused = false))
}
var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
publishState(
currentState.copy(
isPaused = true,
error = e.getDisplayMessage(applicationContext.resources),
eta = -1L,
),
)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
publishState(currentState.copy(isPaused = false, error = null))
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
}
private suspend fun downloadFile(
url: String,
destination: File,
tempFileName: String,
source: MangaSource,
): File {
val request = Request.Builder()
.url(url)
.tag(MangaSource::class.java, source)
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get()
.build()
val call = okHttp.newCall(request)
val file = File(destination, tempFileName)
val response = call.clone().await()
checkNotNull(response.body).use { body ->
file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
}
return file
}
private suspend fun publishState(state: DownloadState) {
val previousState = currentState
lastPublishedState = state
if (previousState.isParticularProgress && state.isParticularProgress) {
timeLeftEstimator.tick(state.progress, state.max)
} else {
timeLeftEstimator.emptyTick()
notificationThrottler.reset()
}
val notification = notificationFactory.create(state)
if (state.isFinalState) {
notificationManager.notify(id.toString(), id.hashCode(), notification)
} else if (notificationThrottler.throttle()) {
notificationManager.notify(id.hashCode(), notification)
} else {
return
}
setProgress(state.toWorkData())
}
private suspend fun getDoneChapters(): LongArray {
val work = WorkManagerHelper(WorkManager.getInstance(applicationContext)).getWorkInfoById(id)
?: return LongArray(0)
return DownloadState.getDownloadedChapters(work.progress)
}
private fun getChapters(
manga: Manga,
includedIds: LongArray?,
): List<MangaChapter> {
val chapters = checkNotNull(manga.chapters?.toMutableList()) {
"Chapters list must not be null"
}
if (includedIds != null) {
val chaptersIdsSet = includedIds.toMutableSet()
chapters.retainAll { x -> chaptersIdsSet.remove(x.id) }
check(chaptersIdsSet.isEmpty()) {
"${chaptersIdsSet.size} of ${includedIds.size} requested chapters not found in manga"
}
}
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
return chapters
}
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
@Reusable
class Scheduler @Inject constructor(
@ApplicationContext private val context: Context,
private val dataRepository: MangaDataRepository,
private val settings: AppSettings,
) {
private val workManager: WorkManager
inline get() = WorkManager.getInstance(context)
suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?) {
dataRepository.storeManga(manga)
val data = Data.Builder()
.putLong(MANGA_ID, manga.id)
if (!chaptersIds.isNullOrEmpty()) {
data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray())
}
scheduleImpl(listOf(data.build()))
}
suspend fun schedule(manga: Collection<Manga>) {
val data = manga.map {
dataRepository.storeManga(it)
Data.Builder()
.putLong(MANGA_ID, it.id)
.build()
}
scheduleImpl(data)
}
fun observeWorks(): Flow<List<WorkInfo>> = workManager
.getWorkInfosByTagLiveData(TAG)
.asFlow()
suspend fun cancel(id: UUID) {
workManager.cancelWorkById(id).await()
}
suspend fun cancelAll() {
workManager.cancelAllWorkByTag(TAG).await()
}
fun pause(id: UUID) {
val intent = PausingReceiver.getPauseIntent(id)
context.sendBroadcast(intent)
}
fun resume(id: UUID) {
val intent = PausingReceiver.getResumeIntent(id)
context.sendBroadcast(intent)
}
suspend fun delete(id: UUID) {
WorkManagerHelper(workManager).deleteWork(id)
}
suspend fun removeCompleted() {
val helper = WorkManagerHelper(workManager)
val finishedWorks = helper.getFinishedWorkInfosByTag(TAG)
helper.deleteWorks(finishedWorks.mapToSet { it.id })
}
suspend fun updateConstraints() {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
val helper = WorkManagerHelper(workManager)
val works = helper.getWorkInfosByTag(TAG)
for (work in works) {
if (work.state.isFinished) {
continue
}
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.setId(work.id)
.build()
helper.updateWork(request)
}
}
private suspend fun scheduleImpl(data: Collection<Data>) {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
val requests = data.map { inputData ->
OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.addTag(TAG)
.keepResultsForAtLeast(7, TimeUnit.DAYS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
}
workManager.enqueue(requests).await()
}
}
private companion object {
const val MAX_FAILSAFE_ATTEMPTS = 2
const val DOWNLOAD_ERROR_DELAY = 500L
const val SLOWDOWN_DELAY = 100L
const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters"
const val TAG = "download"
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.download.ui.worker
import androidx.annotation.AnyThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
class PausingHandle {
private val paused = MutableStateFlow(false)
@get:AnyThread
val isPaused: Boolean
get() = paused.value
@AnyThread
suspend fun awaitResumed() {
paused.filter { !it }.first()
}
@AnyThread
fun pause() {
paused.value = true
}
@AnyThread
fun resume() {
paused.value = false
}
}

View File

@@ -0,0 +1,67 @@
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.core.util.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()
if (uuid != id) {
return
}
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,
)
}
}