From 557b69d73f5fe932954c9ebf61c5e278de38bb9c Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 10 Oct 2024 08:23:22 +0300 Subject: [PATCH] New download dialog --- app/build.gradle | 4 +- .../core/ui/widgets/TwoLinesItemView.kt | 27 +- .../koitharu/kotatsu/core/util/ext/Bundle.kt | 22 ++ .../koitharu/kotatsu/core/zip/ZipOutput.kt | 1 + .../kotatsu/details/ui/DetailsActivity.kt | 7 +- .../kotatsu/details/ui/DetailsMenuProvider.kt | 20 +- .../details/ui/DownloadDialogHelper.kt | 67 ---- .../ui/dialog/ChapterSelectOptions.kt | 8 + .../download/ui/dialog/ChaptersSelectMacro.kt | 97 +++++ .../download/ui/dialog/DestinationsAdapter.kt | 41 ++ .../ui/dialog/DownloadDialogFragment.kt | 359 ++++++++++++++++++ .../ui/dialog/DownloadDialogViewModel.kt | 241 ++++++++++++ .../download/ui/dialog/DownloadOption.kt | 99 ----- .../download/ui/dialog/DownloadOptionAD.kt | 27 -- .../download/ui/list/DownloadsViewModel.kt | 2 +- .../download/ui/worker/DownloadTask.kt | 73 ++++ .../download/ui/worker/DownloadWorker.kt | 90 +++-- .../download/ui/worker/PausingHandle.kt | 4 +- .../kotatsu/list/ui/MangaListFragment.kt | 12 +- .../kotatsu/list/ui/MangaListViewModel.kt | 9 - .../local/data/LocalMangaRepository.kt | 4 +- .../layout-w600dp-land/activity_details.xml | 6 +- .../layout-w600dp-land/activity_settings.xml | 2 +- .../res/layout/activity_appwidget_shelf.xml | 2 +- app/src/main/res/layout/activity_details.xml | 6 +- app/src/main/res/layout/dialog_download.xml | 224 +++++++++++ .../main/res/layout/item_download_option.xml | 14 - app/src/main/res/layout/item_header.xml | 2 +- app/src/main/res/layout/item_list_group.xml | 2 +- app/src/main/res/layout/view_filter_field.xml | 2 +- .../main/res/layout/view_two_lines_item.xml | 16 +- app/src/main/res/values/attrs.xml | 2 + app/src/main/res/values/strings.xml | 8 +- app/src/main/res/values/styles.xml | 4 - 34 files changed, 1194 insertions(+), 310 deletions(-) delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChapterSelectOptions.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChaptersSelectMacro.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DestinationsAdapter.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadTask.kt create mode 100644 app/src/main/res/layout/dialog_download.xml delete mode 100644 app/src/main/res/layout/item_download_option.xml diff --git a/app/build.gradle b/app/build.gradle index 02ee0fe0e..141dca202 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt index 8bf6e5a49..6bffccaa8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt @@ -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 { 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) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt index 34f3f440e..3913abf87 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.SavedStateHandle import java.io.Serializable import java.util.EnumSet + // https://issuetracker.google.com/issues/240585930 inline fun Bundle.getParcelableCompat(key: String): T? { @@ -84,3 +85,24 @@ fun 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 Parcelable.Creator.unmarshall(bytes: ByteArray): T { + val parcel = Parcel.obtain() + return try { + parcel.unmarshall(bytes, 0, bytes.size) + parcel.setDataPosition(0) + createFromParcel(parcel) + } finally { + parcel.recycle() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt index 82378614a..f124671c3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 951c479db..8c45b23ce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt index 0cc332f30..0f7765a87 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt @@ -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 { +) : 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? = when (item) { - is DownloadOption.WholeManga -> null - is DownloadOption.SelectionHint -> { - viewModel.startChaptersSelection() - return - } - - else -> item.chaptersIds - } - viewModel.download(chaptersIds) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt deleted file mode 100644 index 50f8fe3d9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt +++ /dev/null @@ -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) { - 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 { 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() } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChapterSelectOptions.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChapterSelectOptions.kt new file mode 100644 index 000000000..6a4ff0c6f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChapterSelectOptions.kt @@ -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?, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChaptersSelectMacro.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChaptersSelectMacro.kt new file mode 100644 index 000000000..5302df6d3 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChaptersSelectMacro.kt @@ -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): Set? + + class WholeManga( + val chaptersCount: Int, + ) : ChaptersSelectMacro { + + override fun getChaptersIds(mangaId: Long, chapters: List): Set? = null + } + + class WholeBranch( + val branches: Map, + val selectedBranch: String?, + ) : ChaptersSelectMacro { + + val chaptersCount: Int = branches[selectedBranch] ?: 0 + + override fun getChaptersIds( + mangaId: Long, + chapters: List + ): Set = 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): Set { + val result = ArraySet(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): Set? { + if (chapters.isEmpty()) { + return null + } + val currentChapterId = currentChaptersIds.getOrDefault(mangaId, chapters.first().id) + var branch: String? = null + var isAdding = false + val result = ArraySet(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) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DestinationsAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DestinationsAdapter.kt new file mode 100644 index 000000000..bb9a34b88 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DestinationsAdapter.kt @@ -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) : + ArrayAdapter(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(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 + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt new file mode 100644 index 000000000..007902926 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt @@ -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(), View.OnClickListener { + + private val viewModel by viewModels() + private var optionViews: Array? = 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) { + 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) = 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), + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt new file mode 100644 index 000000000..f8d8e3b77 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt @@ -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>(DownloadDialogFragment.ARG_MANGA).map { + it.manga + } + private val mangaDetails = SuspendLazy { + coroutineScope { + manga.map { m -> + async { m.getDetails() } + }.awaitAll() + } + } + + val onScheduled = MutableEventFlow() + val defaultFormat = MutableStateFlow(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() + var maxChapters = 0 + var maxUnreadChapters = 0 + val preferredBranches = ArraySet(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 MutableMap.increment(key: T) { + put(key, getOrDefault(key, 0) + 1) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt deleted file mode 100644 index ae9bf076a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt +++ /dev/null @@ -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 - - @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, - ) : 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, - ) : 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, - ) : 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, - 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, - ) : 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 = emptySet() - override val iconResId = R.drawable.ic_tap - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString(R.string.download_option_manual_selection) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt deleted file mode 100644 index 3a277787f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt +++ /dev/null @@ -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, -) = adapterDelegateViewBinding( - { 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) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index a58e152f3..bd6229d96 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -299,7 +299,7 @@ class DownloadsViewModel @Inject constructor( } private fun observeChapters(manga: Manga, workId: UUID): StateFlow?> = 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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadTask.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadTask.kt new file mode 100644 index 000000000..8f6edcc76 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadTask.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 9fe15de56..73c7bd556 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -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, ) { 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> { val chapters = checkNotNull(manga.chapters) { "Chapters list must not be null" } - val chaptersIdsSet = includedIds?.toMutableSet() + val chaptersIdsSet = task.chaptersIds?.toMutableSet() val result = ArrayList>((chaptersIdsSet ?: chapters).size) val counters = HashMap() 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?, + chaptersIds: Set?, 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, 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> = 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) { - if (data.isEmpty()) { + suspend fun schedule(tasks: Collection) { + if (tasks.isEmpty()) { return } val constraints = createConstraints() - val requests = data.map { inputData -> + val requests = tasks.map { task -> OneTimeWorkRequestBuilder() .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" } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt index e02205230..3eb184121 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt @@ -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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 771ac4103..46e5ae20a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index 700c26e62..372edaba8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -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() val isIncognitoModeEnabled: Boolean get() = settings.isIncognitoModeEnabled @@ -46,13 +44,6 @@ abstract class MangaListViewModel( abstract fun onRetry() - fun download(items: Set, isPaused: Boolean) { - launchJob(Dispatchers.Default) { - downloadScheduler.schedule(items, isPaused) - onDownloadStarted.call(Unit) - } - } - protected fun List.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) { filterNot { it.isNsfw } } else { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index aa9cb07a2..caa49998a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -200,8 +200,8 @@ class LocalMangaRepository @Inject constructor( override suspend fun getRelated(seed: Manga): List = 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 } diff --git a/app/src/main/res/layout-w600dp-land/activity_details.xml b/app/src/main/res/layout-w600dp-land/activity_details.xml index e3e6fab06..f9fb29423 100644 --- a/app/src/main/res/layout-w600dp-land/activity_details.xml +++ b/app/src/main/res/layout-w600dp-land/activity_details.xml @@ -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" /> diff --git a/app/src/main/res/layout-w600dp-land/activity_settings.xml b/app/src/main/res/layout-w600dp-land/activity_settings.xml index 4b3d15d9e..244fedc9d 100644 --- a/app/src/main/res/layout-w600dp-land/activity_settings.xml +++ b/app/src/main/res/layout-w600dp-land/activity_settings.xml @@ -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" diff --git a/app/src/main/res/layout/activity_appwidget_shelf.xml b/app/src/main/res/layout/activity_appwidget_shelf.xml index 8fae4aa9a..c69f0d86b 100644 --- a/app/src/main/res/layout/activity_appwidget_shelf.xml +++ b/app/src/main/res/layout/activity_appwidget_shelf.xml @@ -51,7 +51,7 @@ android:paddingEnd="?listPreferredItemPaddingEnd" android:singleLine="true" android:text="@string/favourites_categories" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" /> + android:textAppearance="?textAppearanceTitleSmall" /> diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/activity_details.xml index ecb78409f..a4c2ac740 100644 --- a/app/src/main/res/layout/activity_details.xml +++ b/app/src/main/res/layout/activity_details.xml @@ -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" /> diff --git a/app/src/main/res/layout/dialog_download.xml b/app/src/main/res/layout/dialog_download.xml new file mode 100644 index 000000000..16841ae30 --- /dev/null +++ b/app/src/main/res/layout/dialog_download.xml @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +