New download dialog

This commit is contained in:
Koitharu
2024-10-10 08:23:22 +03:00
parent 1e22e8de45
commit 557b69d73f
34 changed files with 1194 additions and 310 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 676
versionName = '7.6.3'
versionCode = 680
versionName = '7.7-a1'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {

View File

@@ -11,11 +11,13 @@ import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RoundRectShape
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.Checkable
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.widget.ImageViewCompat
import androidx.core.widget.TextViewCompat
@@ -23,6 +25,7 @@ import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDrawableCompat
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
@@ -32,7 +35,7 @@ class TwoLinesItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {
) : LinearLayout(context, attrs, defStyleAttr), Checkable {
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
@@ -48,6 +51,12 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.subtitle.textAndVisible = value
}
var isButtonEnabled: Boolean
get() = binding.button.isEnabled
set(value) {
binding.button.isEnabled = value
}
init {
var textColors: ColorStateList? = null
context.withStyledAttributes(
@@ -68,7 +77,7 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding }
setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0))
binding.title.text = getText(R.styleable.TwoLinesItemView_title)
binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle)
binding.subtitle.textAndVisible = getText(R.styleable.TwoLinesItemView_subtitle)
textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor)
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
TextViewCompat.setTextAppearance(
@@ -79,6 +88,10 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.subtitle,
getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback),
)
binding.icon.isChecked = getBoolean(R.styleable.TwoLinesItemView_android_checked, false)
val button = getDrawableCompat(context, R.styleable.TwoLinesItemView_android_button)
binding.button.setImageDrawable(button)
binding.button.isVisible = button != null
}
if (textColors == null) {
textColors = binding.title.textColors
@@ -88,6 +101,16 @@ class TwoLinesItemView @JvmOverloads constructor(
ImageViewCompat.setImageTintList(binding.icon, textColors)
}
override fun isChecked() = binding.icon.isChecked
override fun toggle() = binding.icon.toggle()
override fun setChecked(checked: Boolean) {
binding.icon.isChecked = checked
}
fun setOnButtonClickListener(listener: OnClickListener?) = binding.button.setOnClickListener(listener)
fun setIconResource(@DrawableRes resId: Int) {
binding.icon.setImageResource(resId)
}

View File

@@ -14,6 +14,7 @@ import androidx.lifecycle.SavedStateHandle
import java.io.Serializable
import java.util.EnumSet
// https://issuetracker.google.com/issues/240585930
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
@@ -84,3 +85,24 @@ fun <T> SavedStateHandle.require(key: String): T {
"Value $key not found in SavedStateHandle or has a wrong type"
}
}
fun Parcelable.marshall(): ByteArray {
val parcel = Parcel.obtain()
return try {
this.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}
fun <T : Parcelable> Parcelable.Creator<T>.unmarshall(bytes: ByteArray): T {
val parcel = Parcel.obtain()
return try {
parcel.unmarshall(bytes, 0, bytes.size)
parcel.setDataPosition(0)
createFromParcel(parcel)
} finally {
parcel.recycle()
}
}

View File

@@ -21,6 +21,7 @@ class ZipOutput(
private val isClosed = AtomicBoolean(false)
private val output = ZipOutputStream(file.outputStream()).apply {
setLevel(compressionLevel)
// FIXME: Deflater has been closed
}
@WorkerThread

View File

@@ -88,6 +88,7 @@ import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity
@@ -195,6 +196,7 @@ class DetailsActivity :
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.observeEvent(this, DownloadStartedObserver(viewBinding.scrollView))
DownloadDialogFragment.registerCallback(this, viewBinding.scrollView)
menuProvider = DetailsMenuProvider(
activity = this,
viewModel = viewModel,
@@ -210,7 +212,10 @@ class DetailsActivity :
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.chip_branch -> showBranchPopupMenu(v)
R.id.button_download -> DownloadDialogHelper(v, viewModel).show(menuProvider)
R.id.button_download -> {
val manga = viewModel.manga.value ?: return
DownloadDialogFragment.show(supportFragmentManager, listOf(manga))
}
R.id.chip_author -> {
val manga = viewModel.manga.value ?: return

View File

@@ -19,9 +19,8 @@ import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
@@ -31,7 +30,7 @@ class DetailsMenuProvider(
private val viewModel: DetailsViewModel,
private val snackbarHost: View,
private val appShortcutManager: AppShortcutManager,
) : MenuProvider, OnListItemClickListener<DownloadOption> {
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details, menu)
@@ -75,7 +74,7 @@ class DetailsMenuProvider(
}
R.id.action_save -> {
DownloadDialogHelper(snackbarHost, viewModel).show(this)
DownloadDialogFragment.show(activity.supportFragmentManager, listOfNotNull(viewModel.manga.value))
}
R.id.action_browser -> {
@@ -129,17 +128,4 @@ class DetailsMenuProvider(
}
return true
}
override fun onItemClick(item: DownloadOption, view: View) {
val chaptersIds: Set<Long>? = when (item) {
is DownloadOption.WholeManga -> null
is DownloadOption.SelectionHint -> {
viewModel.startChaptersSelection()
return
}
else -> item.chaptersIds
}
viewModel.download(chaptersIds)
}
}

View File

@@ -1,67 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.content.DialogInterface
import android.view.View
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.setRecyclerViewList
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadDialogHelper(
private val host: View,
private val viewModel: DetailsViewModel,
) {
fun show(callback: OnListItemClickListener<DownloadOption>) {
val branch = viewModel.selectedBranchValue
val allChapters = viewModel.manga.value?.chapters ?: return
val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty()
val history = viewModel.history.value
val options = buildList {
add(DownloadOption.WholeManga(allChapters.ids()))
if (branch != null && branchChapters.isNotEmpty()) {
add(DownloadOption.AllChapters(branch, branchChapters.ids()))
}
if (history != null) {
val unreadChapters = branchChapters.dropWhile { it.id != history.chapterId }
if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) {
add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch))
if (unreadChapters.size > 5) {
add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids()))
if (unreadChapters.size > 10) {
add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids()))
}
}
}
} else {
if (branchChapters.size > 5) {
add(DownloadOption.FirstChapters(branchChapters.take(5).ids()))
if (branchChapters.size > 10) {
add(DownloadOption.FirstChapters(branchChapters.take(10).ids()))
}
}
}
add(DownloadOption.SelectionHint())
}
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<DownloadOption> { item, _ ->
callback.onItemClick(item, host)
dialog?.dismiss()
}
dialog = buildAlertDialog(host.context) {
setCancelable(true)
setTitle(R.string.download)
setNegativeButton(android.R.string.cancel, null)
setNeutralButton(R.string.settings) { _, _ ->
host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context))
}
setRecyclerViewList(options, downloadOptionAD(listener))
}.also { it.show() }
}
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.download.ui.dialog
data class ChapterSelectOptions(
val wholeManga: ChaptersSelectMacro.WholeManga,
val wholeBranch: ChaptersSelectMacro.WholeBranch?,
val firstChapters: ChaptersSelectMacro.FirstChapters?,
val unreadChapters: ChaptersSelectMacro.UnreadChapters?,
)

View File

@@ -0,0 +1,97 @@
package org.koitharu.kotatsu.download.ui.dialog
import androidx.collection.ArraySet
import androidx.collection.LongLongMap
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
interface ChaptersSelectMacro {
fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long>?
class WholeManga(
val chaptersCount: Int,
) : ChaptersSelectMacro {
override fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long>? = null
}
class WholeBranch(
val branches: Map<String?, Int>,
val selectedBranch: String?,
) : ChaptersSelectMacro {
val chaptersCount: Int = branches[selectedBranch] ?: 0
override fun getChaptersIds(
mangaId: Long,
chapters: List<MangaChapter>
): Set<Long> = chapters.mapNotNullToSet { c ->
if (c.branch == selectedBranch) {
c.id
} else {
null
}
}
fun copy(branch: String?) = WholeBranch(branches, branch)
}
class FirstChapters(
val chaptersCount: Int,
val maxAvailableCount: Int,
val branch: String?,
) : ChaptersSelectMacro {
override fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long> {
val result = ArraySet<Long>(chaptersCount)
for (c in chapters) {
if (c.branch == branch) {
result.add(c.id)
if (result.size >= chaptersCount) {
break
}
}
}
return result
}
fun copy(count: Int) = FirstChapters(count, maxAvailableCount, branch)
}
class UnreadChapters(
val chaptersCount: Int,
val maxAvailableCount: Int,
private val currentChaptersIds: LongLongMap,
) : ChaptersSelectMacro {
override fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long>? {
if (chapters.isEmpty()) {
return null
}
val currentChapterId = currentChaptersIds.getOrDefault(mangaId, chapters.first().id)
var branch: String? = null
var isAdding = false
val result = ArraySet<Long>(chaptersCount)
for (c in chapters) {
if (!isAdding) {
if (c.id == currentChapterId) {
branch = c.branch
isAdding = true
}
}
if (isAdding) {
if (c.branch == branch) {
result.add(c.id)
if (result.size >= chaptersCount) {
break
}
}
}
}
return result
}
fun copy(count: Int) = UnreadChapters(count, maxAvailableCount, currentChaptersIds)
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.download.ui.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import androidx.core.view.isVisible
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
import org.koitharu.kotatsu.settings.storage.DirectoryModel
class DestinationsAdapter(context: Context, dataset: List<DirectoryModel>) :
ArrayAdapter<DirectoryModel>(context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, dataset) {
init {
setDropDownViewResource(R.layout.item_storage_config)
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(android.R.layout.simple_spinner_dropdown_item, parent, false)
val item = getItem(position) ?: return view
view.findViewById<TextView>(android.R.id.text1).text = item.title ?: view.context.getString(item.titleRes)
return view
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_storage_config, parent, false)
val item = getItem(position) ?: return view
val binding =
view.tag as? ItemStorageConfigBinding ?: ItemStorageConfigBinding.bind(view).also { view.tag = it }
binding.imageViewRemove.isVisible = false
binding.textViewTitle.text = item.title ?: view.context.getString(item.titleRes)
binding.textViewSubtitle.textAndVisible = item.file?.path
return view
}
}

View File

@@ -0,0 +1,359 @@
package org.koitharu.kotatsu.download.ui.dialog
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.widget.Spinner
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
import org.koitharu.kotatsu.core.util.ext.mapToArray
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogDownloadBinding
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.settings.storage.DirectoryModel
@AndroidEntryPoint
class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), View.OnClickListener {
private val viewModel by viewModels<DownloadDialogViewModel>()
private var optionViews: Array<out TwoLinesItemView>? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) =
DialogDownloadBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.save_manga)
.setCancelable(true)
}
override fun onViewBindingCreated(binding: DialogDownloadBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
optionViews = arrayOf(
binding.optionWholeManga,
binding.optionWholeBranch,
binding.optionFirstChapters,
binding.optionUnreadChapters,
).onEach {
it.setOnClickListener(this)
it.setOnButtonClickListener(this)
}
binding.buttonCancel.setOnClickListener(this)
binding.buttonConfirm.setOnClickListener(this)
binding.textViewMore.setOnClickListener(this)
binding.textViewSummary.text = viewModel.manga.joinToStringWithLimit(binding.root.context, 120) { it.title }
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.onScheduled.observeEvent(viewLifecycleOwner, this::onDownloadScheduled)
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
viewModel.defaultFormat.observe(viewLifecycleOwner, this::onDefaultFormatChanged)
viewModel.availableDestinations.observe(viewLifecycleOwner, this::onDestinationsChanged)
viewModel.chaptersSelectOptions.observe(viewLifecycleOwner, this::onChapterSelectOptionsChanged)
viewModel.isOptionsLoading.observe(viewLifecycleOwner, binding.progressBar::showOrHide)
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
showMoreOptions(requireViewBinding().textViewMore.isChecked)
setCheckedOption(
savedInstanceState?.getInt(KEY_CHECKED_OPTION, R.id.option_whole_manga) ?: R.id.option_whole_manga,
)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
optionViews?.find { it.isChecked }?.let {
outState.putInt(KEY_CHECKED_OPTION, it.id)
}
}
override fun onDestroyView() {
super.onDestroyView()
optionViews = null
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> dialog?.cancel()
R.id.button_confirm -> viewBinding?.run {
val options = viewModel.chaptersSelectOptions.value
viewModel.confirm(
startNow = switchStart.isChecked,
chaptersMacro = when {
optionWholeManga.isChecked -> options.wholeManga
optionWholeBranch.isChecked -> options.wholeBranch ?: return@run
optionFirstChapters.isChecked -> options.firstChapters ?: return@run
optionUnreadChapters.isChecked -> options.unreadChapters ?: return@run
else -> return@run
},
format = DownloadFormat.entries.getOrNull(spinnerFormat.selectedItemPosition),
destination = viewModel.availableDestinations.value.getOrNull(spinnerDestination.selectedItemPosition),
)
}
R.id.textView_more -> {
val binding = viewBinding ?: return
binding.textViewMore.toggle()
showMoreOptions(binding.textViewMore.isChecked)
}
R.id.button -> when (v.parentView?.id ?: return) {
R.id.option_whole_branch -> showBranchSelection(v)
R.id.option_first_chapters -> showFirstChaptersCountSelection(v)
R.id.option_unread_chapters -> showUnreadChaptersCountSelection(v)
}
else -> if (v is TwoLinesItemView) {
setCheckedOption(v.id)
}
}
}
private fun onError(e: Throwable) {
MaterialAlertDialogBuilder(context ?: return)
.setNegativeButton(R.string.close, null)
.setTitle(R.string.error)
.setMessage(e.getDisplayMessage(resources))
.show()
dismiss()
}
private fun onLoadingStateChanged(value: Boolean) {
with(requireViewBinding()) {
buttonConfirm.isEnabled = !value
}
}
private fun onDefaultFormatChanged(format: DownloadFormat?) {
val spinner = viewBinding?.spinnerFormat ?: return
spinner.setSelection(format?.ordinal ?: Spinner.INVALID_POSITION)
}
private fun onDestinationsChanged(directories: List<DirectoryModel>) {
viewBinding?.spinnerDestination?.run {
adapter = DestinationsAdapter(context, directories)
setSelection(directories.indexOfFirst { it.isChecked })
}
}
private fun onChapterSelectOptionsChanged(options: ChapterSelectOptions) {
with(viewBinding ?: return) {
// Whole manga
optionWholeManga.subtitle = if (options.wholeManga.chaptersCount > 0) {
resources.getQuantityString(
R.plurals.chapters,
options.wholeManga.chaptersCount,
options.wholeManga.chaptersCount,
)
} else {
null
}
// All chapters for branch
optionWholeBranch.isVisible = options.wholeBranch != null
options.wholeBranch?.let {
optionWholeBranch.title = resources.getString(
R.string.download_option_all_chapters,
it.selectedBranch,
)
optionWholeBranch.subtitle = if (it.chaptersCount > 0) {
resources.getQuantityString(
R.plurals.chapters,
it.chaptersCount,
it.chaptersCount,
)
} else {
null
}
}
// First N chapters
optionFirstChapters.isVisible = options.firstChapters != null
options.firstChapters?.let {
optionFirstChapters.title = resources.getString(
R.string.download_option_first_n_chapters,
resources.getQuantityString(
R.plurals.chapters,
it.chaptersCount,
it.chaptersCount,
),
)
optionFirstChapters.subtitle = it.branch
}
// Next N unread chapters
optionUnreadChapters.isVisible = options.unreadChapters != null
options.unreadChapters?.let {
optionUnreadChapters.title = if (it.chaptersCount == Int.MAX_VALUE) {
resources.getString(R.string.download_option_all_unread)
} else {
resources.getString(
R.string.download_option_next_unread_n_chapters,
resources.getQuantityString(
R.plurals.chapters,
it.chaptersCount,
it.chaptersCount,
),
)
}
}
}
}
private fun onDownloadScheduled(isStarted: Boolean) {
val bundle = Bundle(1)
bundle.putBoolean(ARG_STARTED, isStarted)
setFragmentResult(RESULT_KEY, bundle)
dismiss()
}
private fun showMoreOptions(isVisible: Boolean) = viewBinding?.apply {
cardFormat.isVisible = isVisible
textViewFormat.isVisible = isVisible
cardDestination.isVisible = isVisible
textViewDestination.isVisible = isVisible
}
private fun setCheckedOption(id: Int) {
for (optionView in optionViews ?: return) {
optionView.isChecked = id == optionView.id
optionView.isButtonEnabled = optionView.isChecked
}
}
private fun showBranchSelection(v: View) {
val option = viewModel.chaptersSelectOptions.value.wholeBranch ?: return
val branches = option.branches.keys.toList()
if (branches.size <= 1) {
return
}
val menu = PopupMenu(v.context, v)
for ((i, branch) in branches.withIndex()) {
menu.menu.add(Menu.NONE, Menu.NONE, i, branch ?: getString(R.string.unknown))
}
menu.setOnMenuItemClickListener {
viewModel.setSelectedBranch(branches.getOrNull(it.order))
true
}
menu.show()
}
private fun showFirstChaptersCountSelection(v: View) {
val option = viewModel.chaptersSelectOptions.value.firstChapters ?: return
val menu = PopupMenu(v.context, v)
chaptersCount(option.maxAvailableCount).forEach { i ->
menu.menu.add(i.format())
}
menu.setOnMenuItemClickListener {
viewModel.setFirstChaptersCount(
it.title?.toString()?.toIntOrNull() ?: return@setOnMenuItemClickListener false,
)
true
}
menu.show()
}
private fun showUnreadChaptersCountSelection(v: View) {
val option = viewModel.chaptersSelectOptions.value.unreadChapters ?: return
val menu = PopupMenu(v.context, v)
chaptersCount(option.maxAvailableCount).forEach { i ->
menu.menu.add(i.format())
}
menu.menu.add(getString(R.string.chapters_all))
menu.setOnMenuItemClickListener {
viewModel.setUnreadChaptersCount(it.title?.toString()?.toIntOrNull() ?: Int.MAX_VALUE)
true
}
menu.show()
}
private fun chaptersCount(max: Int) = sequence {
yield(1)
var seed = 5
var step = 5
while (seed + step <= max) {
yield(seed)
step = when {
seed < 20 -> 5
seed < 60 -> 10
else -> 20
}
seed += step
}
if (seed < max) {
yield(max)
}
}
private class SnackbarResultListener(private val host: View) : FragmentResultListener {
override fun onFragmentResult(requestKey: String, result: Bundle) {
val isStarted = result.getBoolean(ARG_STARTED, true)
val snackbar = Snackbar.make(
host,
if (isStarted) R.string.download_started else R.string.download_added,
Snackbar.LENGTH_LONG,
)
(host.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav
}
snackbar.setAction(R.string.details) {
it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
}
snackbar.show()
}
}
companion object {
private const val TAG = "DownloadDialogFragment"
private const val RESULT_KEY = "DOWNLOAD_STARTED"
private const val ARG_STARTED = "started"
private const val KEY_CHECKED_OPTION = "checked_opt"
const val ARG_MANGA = "manga"
fun show(fm: FragmentManager, manga: Collection<Manga>) = DownloadDialogFragment().withArgs(1) {
putParcelableArray(ARG_MANGA, manga.mapToArray { ParcelableManga(it) })
}.showDistinct(fm, TAG)
fun registerCallback(activity: FragmentActivity, snackbarHost: View) =
activity.supportFragmentManager.setFragmentResultListener(
RESULT_KEY,
activity,
SnackbarResultListener(snackbarHost),
)
fun registerCallback(fragment: Fragment, snackbarHost: View) =
fragment.childFragmentManager.setFragmentResultListener(
RESULT_KEY,
fragment.viewLifecycleOwner,
SnackbarResultListener(snackbarHost),
)
}
}

View File

@@ -0,0 +1,241 @@
package org.koitharu.kotatsu.download.ui.dialog
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import androidx.collection.MutableLongLongMap
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
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.prefs.DownloadFormat
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sizeOrZero
import org.koitharu.kotatsu.download.ui.worker.DownloadTask
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import javax.inject.Inject
@HiltViewModel
class DownloadDialogViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mangaDataRepository: MangaDataRepository,
private val scheduler: DownloadWorker.Scheduler,
private val localStorageManager: LocalStorageManager,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val manga = savedStateHandle.require<Array<ParcelableManga>>(DownloadDialogFragment.ARG_MANGA).map {
it.manga
}
private val mangaDetails = SuspendLazy {
coroutineScope {
manga.map { m ->
async { m.getDetails() }
}.awaitAll()
}
}
val onScheduled = MutableEventFlow<Boolean>()
val defaultFormat = MutableStateFlow<DownloadFormat?>(null)
val availableDestinations = MutableStateFlow(listOf(defaultDestination()))
val chaptersSelectOptions = MutableStateFlow(
ChapterSelectOptions(
wholeManga = ChaptersSelectMacro.WholeManga(0),
wholeBranch = null,
firstChapters = null,
unreadChapters = null,
),
)
val isOptionsLoading = MutableStateFlow(true)
init {
launchJob(Dispatchers.Default) {
defaultFormat.value = settings.preferredDownloadFormat
}
launchJob(Dispatchers.Default) {
try {
loadAvailableOptions()
} finally {
isOptionsLoading.value = false
}
}
loadAvailableDestinations()
}
fun confirm(
startNow: Boolean,
chaptersMacro: ChaptersSelectMacro,
format: DownloadFormat?,
destination: DirectoryModel?,
) {
launchLoadingJob(Dispatchers.Default) {
val tasks = mangaDetails.get().map { m ->
val chapters = checkNotNull(m.chapters) { "Manga \"${m.title}\" cannot be loaded" }
mangaDataRepository.storeManga(m)
DownloadTask(
mangaId = m.id,
isPaused = !startNow,
isSilent = false,
chaptersIds = chaptersMacro.getChaptersIds(m.id, chapters)?.toLongArray(),
destination = destination?.file,
format = format,
)
}
scheduler.schedule(tasks)
onScheduled.call(startNow)
}
}
fun setSelectedBranch(branch: String?) {
val snapshot = chaptersSelectOptions.value
chaptersSelectOptions.value = snapshot.copy(
wholeBranch = snapshot.wholeBranch?.copy(branch),
)
}
fun setFirstChaptersCount(count: Int) {
val snapshot = chaptersSelectOptions.value
chaptersSelectOptions.value = snapshot.copy(
firstChapters = snapshot.firstChapters?.copy(count),
)
}
fun setUnreadChaptersCount(count: Int) {
val snapshot = chaptersSelectOptions.value
chaptersSelectOptions.value = snapshot.copy(
unreadChapters = snapshot.unreadChapters?.copy(count),
)
}
private fun defaultDestination() = DirectoryModel(
title = null,
titleRes = R.string.system_default,
file = null,
isRemovable = false,
isChecked = true,
isAvailable = true,
)
private suspend fun loadAvailableOptions() {
val details = mangaDetails.get()
var totalChapters = 0
val branches = ArrayMap<String?, Int>()
var maxChapters = 0
var maxUnreadChapters = 0
val preferredBranches = ArraySet<String?>(details.size)
val currentChaptersIds = MutableLongLongMap(details.size)
details.forEach { m ->
val history = historyRepository.getOne(m)
if (history != null) {
currentChaptersIds[m.id] = history.chapterId
val unreadChaptersCount = m.chapters?.dropWhile { it.id != history.chapterId }.sizeOrZero()
maxUnreadChapters = maxOf(maxUnreadChapters, unreadChaptersCount)
} else {
maxUnreadChapters = maxOf(maxUnreadChapters, m.chapters.sizeOrZero())
}
maxChapters = maxOf(maxChapters, m.chapters.sizeOrZero())
preferredBranches.add(m.getPreferredBranch(history))
m.chapters?.forEach { c ->
totalChapters++
branches.increment(c.branch)
}
}
val defaultBranch = preferredBranches.firstOrNull()
chaptersSelectOptions.value = ChapterSelectOptions(
wholeManga = ChaptersSelectMacro.WholeManga(totalChapters),
wholeBranch = if (branches.size > 1) {
ChaptersSelectMacro.WholeBranch(
branches = branches,
selectedBranch = defaultBranch,
)
} else {
null
},
firstChapters = if (maxChapters > 0) {
ChaptersSelectMacro.FirstChapters(
chaptersCount = minOf(5, maxChapters),
maxAvailableCount = maxChapters,
branch = defaultBranch,
)
} else {
null
},
unreadChapters = if (currentChaptersIds.isNotEmpty()) {
ChaptersSelectMacro.UnreadChapters(
chaptersCount = minOf(5, maxUnreadChapters),
maxAvailableCount = maxUnreadChapters,
currentChaptersIds = currentChaptersIds,
)
} else {
null
},
)
}
private fun loadAvailableDestinations() = launchJob(Dispatchers.Default) {
val defaultDir = manga.mapToSet {
localMangaRepository.getOutputDir(it, null)
}.singleOrNull()
val dirs = localStorageManager.getWriteableDirs()
availableDestinations.value = buildList(dirs.size + 1) {
if (defaultDir == null) {
add(defaultDestination())
} else if (defaultDir !in dirs) {
add(
DirectoryModel(
title = localStorageManager.getDirectoryDisplayName(defaultDir, isFullPath = false),
titleRes = 0,
file = defaultDir,
isChecked = true,
isAvailable = true,
isRemovable = false,
),
)
}
dirs.mapTo(this) { dir ->
DirectoryModel(
title = localStorageManager.getDirectoryDisplayName(dir, isFullPath = false),
titleRes = 0,
file = dir,
isChecked = dir == defaultDir,
isAvailable = true,
isRemovable = false,
)
}
}
}
private suspend fun Manga.getDetails(): Manga = runCatchingCancellable {
mangaRepositoryFactory.create(source).getDetails(this)
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrDefault(this)
private fun <T> MutableMap<T, Int>.increment(key: T) {
put(key, getOrDefault(key, 0) + 1)
}
}

View File

@@ -1,99 +0,0 @@
package org.koitharu.kotatsu.download.ui.dialog
import android.content.res.Resources
import androidx.annotation.DrawableRes
import org.koitharu.kotatsu.R
import java.util.Locale
import com.google.android.material.R as materialR
sealed interface DownloadOption {
val chaptersIds: Set<Long>
@get:DrawableRes
val iconResId: Int
val chaptersCount: Int
get() = chaptersIds.size
fun getLabel(resources: Resources): CharSequence
class AllChapters(
val branch: String,
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_select_group
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_all_chapters, branch)
}
}
class WholeManga(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_whole_manga)
}
}
class FirstChapters(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_start
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(
R.string.download_option_first_n_chapters,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
.lowercase(Locale.getDefault()),
)
}
}
class AllUnreadChapters(
override val chaptersIds: Set<Long>,
val branch: String?,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_end
override fun getLabel(resources: Resources): CharSequence {
return if (branch == null) {
resources.getString(R.string.download_option_all_unread)
} else {
resources.getString(R.string.download_option_all_unread_b, branch)
}
}
}
class NextUnreadChapters(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_next
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(
R.string.download_option_next_unread_n_chapters,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
.lowercase(Locale.getDefault()),
)
}
}
class SelectionHint : DownloadOption {
override val chaptersIds: Set<Long> = emptySet()
override val iconResId = R.drawable.ic_tap
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_manual_selection)
}
}
}

View File

@@ -1,27 +0,0 @@
package org.koitharu.kotatsu.download.ui.dialog
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding
fun downloadOptionAD(
onClickListener: OnListItemClickListener<DownloadOption>,
) = adapterDelegateViewBinding<DownloadOption, DownloadOption, ItemDownloadOptionBinding>(
{ layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) }
bind {
with(binding.root) {
title = item.getLabel(resources)
subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString(
R.plurals.chapters,
item.chaptersCount,
item.chaptersCount,
)
setIconResource(item.iconResId)
}
}
}

View File

@@ -299,7 +299,7 @@ class DownloadsViewModel @Inject constructor(
}
private fun observeChapters(manga: Manga, workId: UUID): StateFlow<List<DownloadChapter>?> = flow {
val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet()
val chapterIds = workScheduler.getTask(workId)?.chaptersIds
val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow
suspend fun mapChapters(): List<DownloadChapter> {

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.download.ui.worker
import android.os.Parcelable
import androidx.work.Data
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.parsers.util.find
import java.io.File
@Parcelize
class DownloadTask(
val mangaId: Long,
val isPaused: Boolean,
val isSilent: Boolean,
val chaptersIds: LongArray?,
val destination: File?,
val format: DownloadFormat?,
) : Parcelable {
constructor(data: Data) : this(
mangaId = data.getLong(MANGA_ID, 0L),
isPaused = data.getBoolean(START_PAUSED, false),
isSilent = data.getBoolean(IS_SILENT, false),
chaptersIds = data.getLongArray(CHAPTERS)?.takeUnless(LongArray::isEmpty),
destination = data.getString(DESTINATION)?.let { File(it) },
format = data.getString(FORMAT)?.let { DownloadFormat.entries.find(it) },
)
fun toData(): Data = Data.Builder()
.putLong(MANGA_ID, mangaId)
.putBoolean(START_PAUSED, isPaused)
.putBoolean(IS_SILENT, isSilent)
.putLongArray(CHAPTERS, chaptersIds ?: LongArray(0))
.putString(DESTINATION, destination?.path)
.putString(FORMAT, format?.name)
.build()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadTask
if (mangaId != other.mangaId) return false
if (isPaused != other.isPaused) return false
if (isSilent != other.isSilent) return false
if (!(chaptersIds contentEquals other.chaptersIds)) return false
if (destination != other.destination) return false
if (format != other.format) return false
return true
}
override fun hashCode(): Int {
var result = mangaId.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + isSilent.hashCode()
result = 31 * result + (chaptersIds?.contentHashCode() ?: 0)
result = 31 * result + (destination?.hashCode() ?: 0)
result = 31 * result + (format?.hashCode() ?: 0)
return result
}
private companion object {
const val MANGA_ID = "manga_id"
const val IS_SILENT = "silent"
const val START_PAUSED = "paused"
const val CHAPTERS = "chapters"
const val DESTINATION = "dest"
const val FORMAT = "format"
}
}

View File

@@ -105,10 +105,8 @@ class DownloadWorker @AssistedInject constructor(
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
private val notificationFactory = notificationFactoryFactory.create(
uuid = params.id,
isSilent = params.inputData.getBoolean(IS_SILENT, false),
)
private val task = DownloadTask(params.inputData)
private val notificationFactory = notificationFactoryFactory.create(uuid = params.id, isSilent = task.isSilent)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
@@ -122,18 +120,16 @@ class DownloadWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
setForeground(getForegroundInfo())
val mangaId = inputData.getLong(MANGA_ID, 0L)
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
val manga = mangaDataRepository.findMangaById(task.mangaId) ?: return Result.failure()
publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it })
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga)
return try {
val pausingHandle = PausingHandle()
if (inputData.getBoolean(START_PAUSED, false)) {
if (task.isPaused) {
pausingHandle.pause()
}
withContext(pausingHandle) {
downloadMangaImpl(manga, chaptersIds, downloadedIds)
downloadMangaImpl(manga, task, downloadedIds)
}
Result.success(currentState.toWorkData())
} catch (e: CancellationException) {
@@ -174,7 +170,7 @@ class DownloadWorker @AssistedInject constructor(
private suspend fun downloadMangaImpl(
subject: Manga,
includedIds: LongArray?,
task: DownloadTask,
excludedIds: Set<Long>,
) {
var manga = subject
@@ -187,7 +183,7 @@ class DownloadWorker @AssistedInject constructor(
PausingReceiver.createIntentFilter(id),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
val destination = localMangaRepository.getOutputDir(manga)
val destination = localMangaRepository.getOutputDir(manga, task.destination)
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
var output: LocalMangaOutput? = null
try {
@@ -197,7 +193,11 @@ class DownloadWorker @AssistedInject constructor(
}
val repo = mangaRepositoryFactory.create(manga.source)
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, mangaDetails, settings.preferredDownloadFormat)
output = LocalMangaOutput.getOrCreate(
root = destination,
manga = mangaDetails,
format = task.format ?: settings.preferredDownloadFormat,
)
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, repo.source).let { file ->
@@ -205,7 +205,7 @@ class DownloadWorker @AssistedInject constructor(
file.deleteAwait()
}
}
val chapters = getChapters(mangaDetails, includedIds)
val chapters = getChapters(mangaDetails, task)
for ((chapterIndex, chapter) in chapters.withIndex()) {
checkIsPaused()
if (chaptersToSkip.remove(chapter.value.id)) {
@@ -311,6 +311,10 @@ class DownloadWorker @AssistedInject constructor(
DOWNLOAD_ERROR_DELAY
}
if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) {
val pausingHandle = PausingHandle.current()
if (pausingHandle.skipAllErrors()) {
return null
}
publishState(
currentState.copy(
isPaused = true,
@@ -321,7 +325,6 @@ class DownloadWorker @AssistedInject constructor(
),
)
countDown = MAX_FAILSAFE_ATTEMPTS
val pausingHandle = PausingHandle.current()
pausingHandle.pause()
try {
pausingHandle.awaitResumed()
@@ -404,10 +407,10 @@ class DownloadWorker @AssistedInject constructor(
private fun getChapters(
manga: Manga,
includedIds: LongArray?,
task: DownloadTask,
): List<IndexedValue<MangaChapter>> {
val chapters = checkNotNull(manga.chapters) { "Chapters list must not be null" }
val chaptersIdsSet = includedIds?.toMutableSet()
val chaptersIdsSet = task.chaptersIds?.toMutableSet()
val result = ArrayList<IndexedValue<MangaChapter>>((chaptersIdsSet ?: chapters).size)
val counters = HashMap<String?, Int>()
for (chapter in chapters) {
@@ -420,7 +423,7 @@ class DownloadWorker @AssistedInject constructor(
}
if (chaptersIdsSet != null) {
check(chaptersIdsSet.isEmpty()) {
"${chaptersIdsSet.size} of ${includedIds.size} requested chapters not found in manga"
"${chaptersIdsSet.size} of ${task.chaptersIds.size} requested chapters not found in manga"
}
}
check(result.isNotEmpty()) { "Chapters list must not be empty" }
@@ -435,35 +438,42 @@ class DownloadWorker @AssistedInject constructor(
private val settings: AppSettings,
) {
@Deprecated("")
suspend fun schedule(
manga: Manga,
chaptersIds: Collection<Long>?,
chaptersIds: Set<Long>?,
isPaused: Boolean,
isSilent: Boolean,
) {
dataRepository.storeManga(manga)
val data = Data.Builder()
.putLong(MANGA_ID, manga.id)
.putBoolean(START_PAUSED, isPaused)
.putBoolean(IS_SILENT, isSilent)
if (!chaptersIds.isNullOrEmpty()) {
data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray())
}
scheduleImpl(listOf(data.build()))
val task = DownloadTask(
mangaId = manga.id,
isPaused = isPaused,
isSilent = isSilent,
chaptersIds = chaptersIds?.toLongArray(),
destination = null,
format = null,
)
schedule(listOf(task))
}
@Deprecated("")
suspend fun schedule(
manga: Collection<Manga>,
isPaused: Boolean,
) {
val data = manga.map {
val tasks = manga.map {
dataRepository.storeManga(it)
Data.Builder()
.putLong(MANGA_ID, it.id)
.putBoolean(START_PAUSED, isPaused)
.build()
DownloadTask(
mangaId = it.id,
isPaused = isPaused,
isSilent = false,
chaptersIds = null,
destination = null,
format = null,
)
}
scheduleImpl(data)
schedule(tasks)
}
fun observeWorks(): Flow<List<WorkInfo>> = workManager
@@ -478,8 +488,8 @@ class DownloadWorker @AssistedInject constructor(
.build()
}
suspend fun getInputChaptersIds(workId: UUID): LongArray? {
return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
suspend fun getTask(workId: UUID): DownloadTask? {
return workManager.getWorkInputData(workId)?.let { DownloadTask(it) }
}
suspend fun cancel(id: UUID) {
@@ -537,18 +547,18 @@ class DownloadWorker @AssistedInject constructor(
}
}
private suspend fun scheduleImpl(data: Collection<Data>) {
if (data.isEmpty()) {
suspend fun schedule(tasks: Collection<DownloadTask>) {
if (tasks.isEmpty()) {
return
}
val constraints = createConstraints()
val requests = data.map { inputData ->
val requests = tasks.map { task ->
OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.addTag(TAG)
.keepResultsForAtLeast(30, TimeUnit.DAYS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.setInputData(task.toData())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
}
@@ -567,10 +577,6 @@ class DownloadWorker @AssistedInject constructor(
const val DOWNLOAD_ERROR_DELAY = 2_000L
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
const val SLOWDOWN_DELAY = 200L
const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters"
const val IS_SILENT = "silent"
const val START_PAUSED = "paused"
const val TAG = "download"
}
}

View File

@@ -53,7 +53,9 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
}
}
fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = skipAllErrors)
fun skipAllErrors(): Boolean = skipAllErrors
fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = false)
companion object : CoroutineContext.Key<PausingHandle> {

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager
@@ -46,7 +45,7 @@ import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.QuickFilterListener
@@ -126,6 +125,7 @@ abstract class MangaListFragment :
isEnabled = isSwipeRefreshEnabled
}
addMenuProvider(MangaListMenuProvider(this))
DownloadDialogFragment.registerCallback(this, binding.recyclerView)
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
@@ -133,7 +133,6 @@ abstract class MangaListFragment :
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView))
}
override fun onDestroyView() {
@@ -324,11 +323,8 @@ abstract class MangaListFragment :
}
R.id.action_save -> {
val itemsSnapshot = selectedItems
CommonAlertDialogs.showDownloadConfirmation(context ?: return false) { startPaused ->
mode?.finish()
viewModel.download(itemsSnapshot, isPaused = startPaused)
}
DownloadDialogFragment.show(childFragmentManager, selectedItems)
mode?.finish()
true
}

View File

@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -37,7 +36,6 @@ abstract class MangaListViewModel(
key = AppSettings.KEY_GRID_SIZE,
valueProducer = { gridSize / 100f },
)
val onDownloadStarted = MutableEventFlow<Unit>()
val isIncognitoModeEnabled: Boolean
get() = settings.isIncognitoModeEnabled
@@ -46,13 +44,6 @@ abstract class MangaListViewModel(
abstract fun onRetry()
fun download(items: Set<Manga>, isPaused: Boolean) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(items, isPaused)
onDownloadStarted.call(Unit)
}
}
protected fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) {
filterNot { it.isNsfw }
} else {

View File

@@ -200,8 +200,8 @@ class LocalMangaRepository @Inject constructor(
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
suspend fun getOutputDir(manga: Manga): File? {
val defaultDir = storageManager.getDefaultWriteableDir()
suspend fun getOutputDir(manga: Manga, fallback: File?): File? {
val defaultDir = fallback ?: storageManager.getDefaultWriteableDir()
if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) {
return defaultDir
}

View File

@@ -218,7 +218,7 @@
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:text="@string/description"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
app:layout_constraintEnd_toStartOf="@id/button_description_more"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_read" />
@@ -274,7 +274,7 @@
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:text="@string/tracking"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
app:layout_constraintEnd_toStartOf="@id/button_scrobbling_more"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chips_tags" />
@@ -343,7 +343,7 @@
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:text="@string/related_manga"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
app:layout_constraintEnd_toStartOf="@id/button_related_more"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_scrobbling" />

View File

@@ -47,7 +47,7 @@
android:gravity="center_vertical|start"
android:padding="8dp"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/container_master"
app:layout_constraintTop_toTopOf="parent"

View File

@@ -51,7 +51,7 @@
android:paddingEnd="?listPreferredItemPaddingEnd"
android:singleLine="true"
android:text="@string/favourites_categories"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" />
android:textAppearance="?textAppearanceTitleSmall" />
</com.google.android.material.appbar.AppBarLayout>

View File

@@ -227,7 +227,7 @@
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:text="@string/description"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
app:layout_constraintEnd_toStartOf="@id/button_description_more"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_read" />
@@ -283,7 +283,7 @@
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:text="@string/tracking"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
app:layout_constraintEnd_toStartOf="@id/button_scrobbling_more"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chips_tags" />
@@ -352,7 +352,7 @@
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:text="@string/related_manga"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
app:layout_constraintEnd_toStartOf="@id/button_related_more"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_scrobbling" />

View File

@@ -0,0 +1,224 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="?dialogPreferredPadding">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scrollIndicators="top|bottom"
tools:ignore="UnusedAttribute">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/textView_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:paddingHorizontal="@dimen/margin_normal"
android:textAppearance="?textAppearanceBody2"
tools:text="@tools:sample/lorem[15]" />
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
android:id="@+id/option_whole_manga"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:checked="true"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
app:icon="?android:listChoiceIndicatorSingle"
app:title="@string/download_option_whole_manga"
tools:subtitle="@string/no_chapters" />
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
android:id="@+id/option_whole_branch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:button="@drawable/ic_expand_more"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:visibility="gone"
app:icon="?android:listChoiceIndicatorSingle"
tools:subtitle="@string/no_chapters"
tools:title="@string/download_option_all_chapters"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
android:id="@+id/option_first_chapters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:button="@drawable/ic_expand_more"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:visibility="gone"
app:icon="?android:listChoiceIndicatorSingle"
tools:title="@string/download_option_first_n_chapters"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
android:id="@+id/option_unread_chapters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:button="@drawable/ic_expand_more"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:visibility="gone"
app:icon="?android:listChoiceIndicatorSingle"
tools:title="@string/download_option_next_unread_n_chapters"
tools:visibility="visible" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/chapter_selection_hint"
android:textAppearance="?attr/textAppearanceBodySmall" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_start"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:layout_marginTop="@dimen/margin_normal"
android:checked="true"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:ellipsize="end"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:singleLine="true"
android:text="@string/start_download"
android:textAppearance="?attr/textAppearanceButton"
android:textColor="?colorOnSurfaceVariant" />
<CheckedTextView
android:id="@+id/textView_more"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="@drawable/list_selector"
android:checked="false"
android:drawableEnd="@drawable/ic_expand_collapse"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:ellipsize="end"
android:gravity="center_vertical"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:singleLine="true"
android:text="@string/more_options"
android:textAppearance="?attr/textAppearanceButton"
android:textColor="?colorOnSurfaceVariant" />
<TextView
android:id="@+id/textView_destination"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:paddingHorizontal="@dimen/margin_normal"
android:text="@string/destination_directory"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone"
tools:visibility="visible" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_destination"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="@dimen/margin_normal"
android:visibility="gone"
tools:visibility="visible">
<Spinner
android:id="@+id/spinner_destination"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/spinner_height"
android:paddingHorizontal="8dp" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/textView_format"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:paddingHorizontal="@dimen/margin_normal"
android:text="@string/preferred_download_format"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone"
tools:visibility="visible" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_format"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="@dimen/margin_normal"
android:visibility="gone"
tools:visibility="visible">
<Spinner
android:id="@+id/spinner_format"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:entries="@array/download_formats"
android:minHeight="@dimen/spinner_height"
android:paddingHorizontal="8dp" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
<LinearLayout
style="?buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="?dialogPreferredPadding"
android:layout_marginTop="@dimen/margin_small"
android:gravity="end"
android:orientation="horizontal">
<Button
android:id="@+id/button_cancel"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@android:string/cancel" />
<Button
android:id="@+id/button_confirm"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/save" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/button_file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
tools:subtitle="@string/chapters"
tools:title="@string/download_option_whole_manga" />

View File

@@ -15,7 +15,7 @@
android:gravity="center_vertical|start"
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
tools:text="@tools:sample/lorem[2]" />
<com.google.android.material.button.MaterialButton

View File

@@ -22,7 +22,7 @@
android:gravity="center_vertical|start"
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
tools:text="@tools:sample/lorem[2]" />
<Button

View File

@@ -15,7 +15,7 @@
android:gravity="center_vertical|start"
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
android:textAppearance="?textAppearanceTitleSmall"
tools:text="@string/genres" />
<com.google.android.material.button.MaterialButton

View File

@@ -6,7 +6,7 @@
tools:orientation="horizontal"
tools:parentTag="android.widget.LinearLayout">
<ImageView
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -15,9 +15,10 @@
<LinearLayout
android:id="@+id/layout_text"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:orientation="vertical"
android:paddingVertical="6dp">
@@ -35,4 +36,15 @@
tools:text="@tools:sample/lorem[12]" />
</LinearLayout>
<ImageView
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:background="?selectableItemBackgroundBorderless"
android:minWidth="?minTouchTargetSize"
android:minHeight="?minTouchTargetSize"
android:scaleType="center"
tools:src="@drawable/ic_expand_more" />
</merge>

View File

@@ -50,6 +50,8 @@
<attr name="icon" />
<attr name="titleTextAppearance" />
<attr name="subtitleTextAppearance" />
<attr name="android:checked" />
<attr name="android:button" />
</declare-styleable>
<declare-styleable name="ProgressDrawable">

View File

@@ -396,7 +396,7 @@
<string name="enable">Enable</string>
<string name="no_thanks">No thanks</string>
<string name="cancel_all_downloads_confirm">All active downloads will be cancelled, partially downloaded data will be lost</string>
<string name="remove_completed_downloads_confirm">Your downloads history will be permanently deleted</string>
<string name="remove_completed_downloads_confirm">Your downloads history will be permanently deleted. No downloaded files will be affected</string>
<string name="text_downloads_list_holder">You don\'t have any downloads</string>
<string name="downloads_resumed">Downloads have been resumed</string>
<string name="downloads_paused">Downloads have been paused</string>
@@ -742,4 +742,10 @@
<string name="save_manga_confirm">Save selected manga? This may consume traffic and disk space</string>
<string name="save_manga">Save manga</string>
<string name="genre">Genre</string>
<string name="download_added">Download added</string>
<string name="more_options">More options</string>
<string name="destination_directory">Destination directory</string>
<string name="chapter_selection_hint">You can select chapters to download by long click on item in the chapter list.</string>
<!-- For chapters -->
<string name="chapters_all">All</string>
</resources>

View File

@@ -238,10 +238,6 @@
<style name="TextAppearance.Kotatsu.Menu" parent="TextAppearance.Material3.BodyLarge" />
<style name="TextAppearance.Kotatsu.SectionHeader" parent="TextAppearance.Material3.LabelLarge">
<item name="android:textColor">?android:attr/textColorSecondary</item>
</style>
<style name="TextAppearance.Kotatsu.GridTitle" parent="TextAppearance.Material3.TitleSmall" />
<style name="TextAppearance.Kotatsu.GridTitle.Small" parent="TextAppearance.Material3.TitleSmall">