New download dialog

master
Koitharu 2 years ago
parent 1e22e8de45
commit 557b69d73f
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

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

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

@ -14,6 +14,7 @@ import androidx.lifecycle.SavedStateHandle
import java.io.Serializable import java.io.Serializable
import java.util.EnumSet import java.util.EnumSet
// https://issuetracker.google.com/issues/240585930 // https://issuetracker.google.com/issues/240585930
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? { 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" "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()
}
}

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

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

@ -19,9 +19,8 @@ import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.AppShortcutManager 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.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.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.SearchActivity import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
@ -31,7 +30,7 @@ class DetailsMenuProvider(
private val viewModel: DetailsViewModel, private val viewModel: DetailsViewModel,
private val snackbarHost: View, private val snackbarHost: View,
private val appShortcutManager: AppShortcutManager, private val appShortcutManager: AppShortcutManager,
) : MenuProvider, OnListItemClickListener<DownloadOption> { ) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details, menu) menuInflater.inflate(R.menu.opt_details, menu)
@ -75,7 +74,7 @@ class DetailsMenuProvider(
} }
R.id.action_save -> { R.id.action_save -> {
DownloadDialogHelper(snackbarHost, viewModel).show(this) DownloadDialogFragment.show(activity.supportFragmentManager, listOfNotNull(viewModel.manga.value))
} }
R.id.action_browser -> { R.id.action_browser -> {
@ -129,17 +128,4 @@ class DetailsMenuProvider(
} }
return true 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)
}
} }

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

@ -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?,
)

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

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

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

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

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

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

@ -299,7 +299,7 @@ class DownloadsViewModel @Inject constructor(
} }
private fun observeChapters(manga: Manga, workId: UUID): StateFlow<List<DownloadChapter>?> = flow { 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 val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow
suspend fun mapChapters(): List<DownloadChapter> { suspend fun mapChapters(): List<DownloadChapter> {

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

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

@ -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> { companion object : CoroutineContext.Key<PausingHandle> {

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

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

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

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

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

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

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

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

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

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

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

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

@ -6,7 +6,7 @@
tools:orientation="horizontal" tools:orientation="horizontal"
tools:parentTag="android.widget.LinearLayout"> tools:parentTag="android.widget.LinearLayout">
<ImageView <org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
android:id="@+id/icon" android:id="@+id/icon"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -15,9 +15,10 @@
<LinearLayout <LinearLayout
android:id="@+id/layout_text" android:id="@+id/layout_text"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:layout_weight="1"
android:orientation="vertical" android:orientation="vertical"
android:paddingVertical="6dp"> android:paddingVertical="6dp">
@ -35,4 +36,15 @@
tools:text="@tools:sample/lorem[12]" /> tools:text="@tools:sample/lorem[12]" />
</LinearLayout> </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> </merge>

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

@ -396,7 +396,7 @@
<string name="enable">Enable</string> <string name="enable">Enable</string>
<string name="no_thanks">No thanks</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="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="text_downloads_list_holder">You don\'t have any downloads</string>
<string name="downloads_resumed">Downloads have been resumed</string> <string name="downloads_resumed">Downloads have been resumed</string>
<string name="downloads_paused">Downloads have been paused</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_confirm">Save selected manga? This may consume traffic and disk space</string>
<string name="save_manga">Save manga</string> <string name="save_manga">Save manga</string>
<string name="genre">Genre</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> </resources>

@ -238,10 +238,6 @@
<style name="TextAppearance.Kotatsu.Menu" parent="TextAppearance.Material3.BodyLarge" /> <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" parent="TextAppearance.Material3.TitleSmall" />
<style name="TextAppearance.Kotatsu.GridTitle.Small" parent="TextAppearance.Material3.TitleSmall"> <style name="TextAppearance.Kotatsu.GridTitle.Small" parent="TextAppearance.Material3.TitleSmall">

Loading…
Cancel
Save