From 635839065dfdb27ac27d2ae4dd1cd13079b1cc46 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 9 Nov 2024 11:44:51 +0200 Subject: [PATCH] Batch pages saving --- .../org/koitharu/kotatsu/core/model/Manga.kt | 4 + .../details/ui/pager/pages/PagesFragment.kt | 74 ++++++++++++++++++- .../ui/pager/pages/PagesSavedObserver.kt | 28 +++++++ .../pager/pages/PagesSelectionDecoration.kt | 16 ++++ .../details/ui/pager/pages/PagesViewModel.kt | 27 +++++++ .../kotatsu/list/ui/MangaListFragment.kt | 4 +- .../kotatsu/reader/ui/PageSaveHelper.kt | 24 +++--- .../kotatsu/reader/ui/ReaderActivity.kt | 14 +--- .../kotatsu/reader/ui/ReaderViewModel.kt | 7 +- app/src/main/res/menu/mode_pages.xml | 12 +++ app/src/main/res/values/strings.xml | 1 + 11 files changed, 183 insertions(+), 28 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSavedObserver.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSelectionDecoration.kt create mode 100644 app/src/main/res/menu/mode_pages.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index 74bcb3a30..217d9bad0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -88,6 +88,10 @@ fun Manga.findChapter(id: Long): MangaChapter? { return chapters?.findById(id) } +fun Manga.requireChapter(id: Long): MangaChapter = checkNotNull(findChapter(id)) { + "Chapter $id not found" +} + fun Manga.getPreferredBranch(history: MangaHistory?): String? { val ch = chapters if (ch.isNullOrEmpty()) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt index 0edc18907..eadeec114 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt @@ -2,8 +2,13 @@ package org.koitharu.kotatsu.details.ui.pager.pages import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.appcompat.view.ActionMode +import androidx.collection.ArraySet import androidx.core.graphics.Insets import androidx.core.view.isInvisible import androidx.core.view.isVisible @@ -20,10 +25,12 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener +import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.dismissParentDialog +import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent @@ -34,16 +41,18 @@ import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.reader.ui.PageSaveHelper import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject import kotlin.math.roundToInt @AndroidEntryPoint class PagesFragment : BaseFragment(), - OnListItemClickListener { + OnListItemClickListener, ListSelectionController.Callback { @Inject lateinit var coil: ImageLoader @@ -51,17 +60,23 @@ class PagesFragment : @Inject lateinit var settings: AppSettings + @Inject + lateinit var pageSaveHelperFactory: PageSaveHelper.Factory + private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this) private val viewModel by viewModels() + private lateinit var pageSaveHelper: PageSaveHelper private var thumbnailsAdapter: PageThumbnailAdapter? = null private var spanResolver: GridSpanResolver? = null private var scrollListener: ScrollListener? = null + private var selectionController: ListSelectionController? = null private val spanSizeLookup = SpanSizeLookup() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + pageSaveHelper = pageSaveHelperFactory.create(this) combine( parentViewModel.mangaDetails, parentViewModel.readingState, @@ -83,6 +98,12 @@ class PagesFragment : override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) spanResolver = GridSpanResolver(binding.root.resources) + selectionController = ListSelectionController( + appCompatDelegate = checkNotNull(findAppCompatDelegate()), + decoration = PagesSelectionDecoration(binding.root.context), + registryOwner = this, + callback = this, + ) thumbnailsAdapter = PageThumbnailAdapter( coil = coil, lifecycleOwner = viewLifecycleOwner, @@ -91,6 +112,7 @@ class PagesFragment : viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization with(binding.recyclerView) { addItemDecoration(TypedListSpacingDecoration(context, false)) + checkNotNull(selectionController).attachToRecyclerView(this) adapter = thumbnailsAdapter setHasFixedSize(true) PagerNestedScrollHelper(this).bind(viewLifecycleOwner) @@ -103,6 +125,7 @@ class PagesFragment : } parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged) viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) + viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) } @@ -113,6 +136,7 @@ class PagesFragment : spanResolver = null scrollListener = null thumbnailsAdapter = null + selectionController = null spanSizeLookup.invalidateCache() super.onDestroyView() } @@ -120,6 +144,9 @@ class PagesFragment : override fun onWindowInsetsChanged(insets: Insets) = Unit override fun onItemClick(item: PageThumbnail, view: View) { + if (selectionController?.onItemClick(item.page.id) == true) { + return + } val listener = findParentCallback(ReaderNavigationCallback::class.java) if (listener != null && listener.onPageSelected(item.page)) { dismissParentDialog() @@ -133,6 +160,39 @@ class PagesFragment : } } + override fun onItemLongClick(item: PageThumbnail, view: View): Boolean { + return selectionController?.onItemLongClick(view, item.page.id) ?: false + } + + override fun onItemContextClick(item: PageThumbnail, view: View): Boolean { + return selectionController?.onItemContextClick(view, item.page.id) ?: false + } + + override fun onSelectionChanged(controller: ListSelectionController, count: Int) { + viewBinding?.recyclerView?.invalidateItemDecorations() + } + + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu, + ): Boolean { + menuInflater.inflate(R.menu.mode_pages, menu) + return true + } + + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_save -> { + viewModel.savePages(pageSaveHelper, collectSelectedPages()) + mode?.finish() + true + } + + else -> false + } + } + private suspend fun onThumbnailsChanged(list: List) { val adapter = thumbnailsAdapter ?: return if (adapter.itemCount == 0) { @@ -172,6 +232,18 @@ class PagesFragment : } } + private fun collectSelectedPages(): Set { + val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet() + val items = thumbnailsAdapter?.items ?: return emptySet() + val result = ArraySet(checkedIds.size) + for (item in items) { + if (item is PageThumbnail && item.page.id in checkedIds) { + result.add(item.page) + } + } + return result + } + private inner class ScrollListener : BoundsScrollListener(3, 3) { override fun onScrolledToStart(recyclerView: RecyclerView) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSavedObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSavedObserver.kt new file mode 100644 index 000000000..71ebf88ee --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSavedObserver.kt @@ -0,0 +1,28 @@ +package org.koitharu.kotatsu.details.ui.pager.pages + +import android.net.Uri +import android.view.View +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.FlowCollector +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ShareHelper + +class PagesSavedObserver( + private val snackbarHost: View, +) : FlowCollector> { + + override suspend fun emit(value: Collection) { + val msg = when (value.size) { + 0 -> R.string.nothing_found + 1 -> R.string.page_saved + else -> R.string.pages_saved + } + val snackbar = Snackbar.make(snackbarHost, msg, Snackbar.LENGTH_LONG) + value.singleOrNull()?.let { uri -> + snackbar.setAction(R.string.share) { + ShareHelper(snackbarHost.context).shareImage(uri) + } + } + snackbar.show() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSelectionDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSelectionDecoration.kt new file mode 100644 index 000000000..1dc95fecb --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSelectionDecoration.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.details.ui.pager.pages + +import android.content.Context +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.core.util.ext.getItem +import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration + +class PagesSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { + + override fun getItemId(parent: RecyclerView, child: View): Long { + val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID + val item = holder.getItem(PageThumbnail::class.java) ?: return RecyclerView.NO_ID + return item.page.id + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt index ca0aba9ca..9b2de230b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.details.ui.pager.pages +import android.net.Uri import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -7,15 +8,21 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.plus +import org.koitharu.kotatsu.core.model.requireChapter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsStateFlow 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.firstNotNull +import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.domain.ChaptersLoader +import org.koitharu.kotatsu.reader.ui.PageSaveHelper import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject @HiltViewModel @@ -32,6 +39,7 @@ class PagesViewModel @Inject constructor( val thumbnails = MutableStateFlow>(emptyList()) val isLoadingUp = MutableStateFlow(false) val isLoadingDown = MutableStateFlow(false) + val onPageSaved = MutableEventFlow>() val gridScale = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, @@ -73,6 +81,25 @@ class PagesViewModel @Inject constructor( loadingNextJob = loadPrevNextChapter(isNext = true) } + fun savePages( + pageSaveHelper: PageSaveHelper, + pages: Set, + ) { + launchLoadingJob(Dispatchers.Default) { + val manga = state.requireValue().details.toManga() + val tasks = pages.map { + PageSaveHelper.Task( + manga = manga, + chapter = manga.requireChapter(it.chapterId), + pageNumber = it.index + 1, + page = it.toMangaPage(), + ) + } + val dest = pageSaveHelper.save(tasks) + onPageSaved.call(dest) + } + } + private suspend fun doInit(state: State) { chaptersLoader.init(state.details) val initialChapterId = state.readerState?.chapterId?.takeIf { 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 0cf0a0bc9..2c1cd08b2 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 @@ -115,9 +115,9 @@ abstract class MangaListFragment : with(binding.recyclerView) { setHasFixedSize(true) adapter = listAdapter - checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView) + checkNotNull(selectionController).attachToRecyclerView(this) addItemDecoration(TypedListSpacingDecoration(context, false)) - addOnScrollListener(paginationListener!!) + addOnScrollListener(checkNotNull(paginationListener)) fastScroller.setFastScrollListener(this@MangaListFragment) } with(binding.swipeRefreshLayout) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index 059f4a824..d44c258ca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -67,17 +67,14 @@ class PageSaveHelper @AssistedInject constructor( } } - suspend fun save(tasks: Set): Uri? = when (tasks.size) { - 0 -> null - 1 -> saveImpl(tasks.first()) - else -> { - saveImpl(tasks) - null - } + suspend fun save(tasks: Collection): Collection = when (tasks.size) { + 0 -> emptySet() + 1 -> setOf(saveImpl(tasks.first())) + else -> saveImpl(tasks) } private suspend fun saveImpl(task: Task): Uri { - val pageLoader = pageLoaderProvider.get() + val pageLoader = getPageLoader() val pageUrl = pageLoader.getPageUrl(task.page).toUri() val pageUri = pageLoader.loadPage(task.page, force = false) val proposedName = task.getFileBaseName() + "." + getPageExtension(pageUrl, pageUri) @@ -89,13 +86,14 @@ class PageSaveHelper @AssistedInject constructor( return destination } - private suspend fun saveImpl(tasks: Collection) { - val pageLoader = pageLoaderProvider.get() + private suspend fun saveImpl(tasks: Collection): Collection { + val pageLoader = getPageLoader() val destinationDir = getDefaultFileUri(null) ?: run { val defaultUri = settings.getPagesSaveDir(context)?.uri DocumentFile.fromTreeUri(context, pickDirectoryRequest.launchAndAwait(defaultUri)) } ?: throw IOException("Cannot get destination directory") + val result = ArrayList(tasks.size) for (task in tasks) { val pageUrl = pageLoader.getPageUrl(task.page).toUri() val pageUri = pageLoader.loadPage(task.page, force = false) @@ -106,7 +104,9 @@ class PageSaveHelper @AssistedInject constructor( } val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.')) copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file")) + result.add(destination.uri) } + return result } private suspend fun getPageExtension(url: Uri, fileUri: Uri): String { @@ -143,6 +143,10 @@ class PageSaveHelper @AssistedInject constructor( } } + private suspend fun getPageLoader() = withContext(Dispatchers.Main.immediate) { + pageLoaderProvider.get() + } + private fun getDefaultFileUri(proposedName: String?): DocumentFile? { if (settings.isPagesSavingAskEnabled) { return null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index e59e4d019..4adbad36a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -53,6 +53,7 @@ import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.zipWithPrevious import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.details.ui.pager.pages.PagesSavedObserver import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.reader.data.TapGridSettings @@ -143,7 +144,7 @@ class ReaderActivity : ), ) viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader) - viewModel.onPageSaved.observeEvent(this, this::onPageSaved) + viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container)) viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.content.observe(this) { @@ -289,17 +290,6 @@ class ReaderActivity : readerManager.setDoubleReaderMode(isEnabled) } - private fun onPageSaved(uri: Uri?) { - val snackbar = Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG) - if (uri != null) { - snackbar.setAction(R.string.share) { - ShareHelper(this).shareImage(uri) - } - } - snackbar.setAnchorView(viewBinding.appbarBottom) - snackbar.show() - } - private fun setKeepScreenOn(isKeep: Boolean) { if (isKeep) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index c1a5c70f3..e4a41f77d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -32,6 +32,7 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.findChapter import org.koitharu.kotatsu.core.model.getPreferredBranch +import org.koitharu.kotatsu.core.model.requireChapter import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaIntent @@ -111,7 +112,7 @@ class ReaderViewModel @Inject constructor( } val readerMode = MutableStateFlow(null) - val onPageSaved = MutableEventFlow() + val onPageSaved = MutableEventFlow>() val onShowToast = MutableEventFlow() val uiState = MutableStateFlow(null) @@ -261,8 +262,8 @@ class ReaderViewModel @Inject constructor( val currentManga = manga.requireValue() val task = PageSaveHelper.Task( manga = currentManga, - chapter = checkNotNull(currentManga.findChapter(state.chapterId)), - pageNumber = state.page, + chapter = currentManga.requireChapter(state.chapterId), + pageNumber = state.page + 1, page = checkNotNull(getCurrentPage()) { "Cannot find current page" }, ) val dest = pageSaveHelper.save(setOf(task)) diff --git a/app/src/main/res/menu/mode_pages.xml b/app/src/main/res/menu/mode_pages.xml new file mode 100644 index 000000000..3263d5f88 --- /dev/null +++ b/app/src/main/res/menu/mode_pages.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e40a23fa8..6eb2d8e4a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,6 +57,7 @@ \"%s\" deleted from local storage Save page Page saved + Pages saved Share image Import Delete