From 9fe1a7e2ae14c4d7e70bfd85516d91c44514f04a Mon Sep 17 00:00:00 2001 From: Hunter Nickel Date: Fri, 19 Nov 2021 09:24:46 -0700 Subject: [PATCH] Add feature to clear database manga by source (#6241) * Implement feature to selectively clear manga from database based on it's source * Code cleanup and refactoring --- .../database/models/SourceIdMangaCount.kt | 3 + .../data/database/queries/MangaQueries.kt | 20 +- .../data/database/queries/RawQueries.kt | 12 ++ .../SourceIdMangaCountGetResolver.kt | 23 +++ .../ui/setting/SettingsAdvancedController.kt | 27 +-- .../database/ClearDatabaseController.kt | 172 ++++++++++++++++++ .../database/ClearDatabasePresenter.kt | 39 ++++ .../database/ClearDatabaseSourceItem.kt | 55 ++++++ .../res/layout/clear_database_controller.xml | 32 ++++ .../res/layout/clear_database_source_item.xml | 68 +++++++ app/src/main/res/values/strings.xml | 2 + 11 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/database/models/SourceIdMangaCount.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/SourceIdMangaCountGetResolver.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseSourceItem.kt create mode 100644 app/src/main/res/layout/clear_database_controller.xml create mode 100644 app/src/main/res/layout/clear_database_source_item.xml diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/SourceIdMangaCount.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/SourceIdMangaCount.kt new file mode 100644 index 0000000000..bb91e1337c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/SourceIdMangaCount.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.data.database.models + +data class SourceIdMangaCount(val source: Long, val count: Int) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt index 667720aa0b..e82305c07e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.data.database.queries +import com.pushtorefresh.storio.Queries import com.pushtorefresh.storio.sqlite.operations.get.PreparedGetListOfObjects import com.pushtorefresh.storio.sqlite.queries.DeleteQuery import com.pushtorefresh.storio.sqlite.queries.Query @@ -7,6 +8,7 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.SourceIdMangaCount import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaCoverLastModifiedPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver @@ -14,6 +16,7 @@ import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaNextUpdatedPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaTitlePutResolver +import eu.kanade.tachiyomi.data.database.resolvers.SourceIdMangaCountGetResolver import eu.kanade.tachiyomi.data.database.tables.CategoryTable import eu.kanade.tachiyomi.data.database.tables.ChapterTable import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable @@ -70,6 +73,17 @@ interface MangaQueries : DbProvider { ) .prepare() + fun getSourceIdsWithNonLibraryManga() = db.get() + .listOfObjects(SourceIdMangaCount::class.java) + .withQuery( + RawQuery.builder() + .query(getSourceIdsWithNonLibraryMangaQuery()) + .observesTables(MangaTable.TABLE) + .build() + ) + .withGetResolver(SourceIdMangaCountGetResolver.INSTANCE) + .prepare() + fun insertManga(manga: Manga) = db.put().`object`(manga).prepare() fun insertMangas(mangas: List) = db.put().objects(mangas).prepare() @@ -123,12 +137,12 @@ interface MangaQueries : DbProvider { fun deleteMangas(mangas: List) = db.delete().objects(mangas).prepare() - fun deleteMangasNotInLibrary() = db.delete() + fun deleteMangasNotInLibraryBySourceIds(sourceIds: List) = db.delete() .byQuery( DeleteQuery.builder() .table(MangaTable.TABLE) - .where("${MangaTable.COL_FAVORITE} = ?") - .whereArgs(0) + .where("${MangaTable.COL_FAVORITE} = ? AND ${MangaTable.COL_SOURCE} IN (${Queries.placeholders(sourceIds.size)})") + .whereArgs(0, *sourceIds.toTypedArray()) .build() ) .prepare() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt index c79246e6b1..57091cd07b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.data.database.queries +import eu.kanade.tachiyomi.data.database.resolvers.SourceIdMangaCountGetResolver import eu.kanade.tachiyomi.data.database.tables.CategoryTable as Category import eu.kanade.tachiyomi.data.database.tables.ChapterTable as Chapter import eu.kanade.tachiyomi.data.database.tables.HistoryTable as History @@ -142,3 +143,14 @@ fun getCategoriesForMangaQuery() = ${MangaCategory.TABLE}.${MangaCategory.COL_CATEGORY_ID} WHERE ${MangaCategory.COL_MANGA_ID} = ? """ + +/** Query to get the list of sources in the database that have + * non-library manga, and how many + */ +fun getSourceIdsWithNonLibraryMangaQuery() = + """ + SELECT ${Manga.COL_SOURCE}, COUNT(*) as ${SourceIdMangaCountGetResolver.COL_COUNT} + FROM ${Manga.TABLE} + WHERE ${Manga.COL_FAVORITE} = 0 + GROUP BY ${Manga.COL_SOURCE} + """ diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/SourceIdMangaCountGetResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/SourceIdMangaCountGetResolver.kt new file mode 100644 index 0000000000..ace4cc252c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/SourceIdMangaCountGetResolver.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.data.database.resolvers + +import android.annotation.SuppressLint +import android.database.Cursor +import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver +import eu.kanade.tachiyomi.data.database.models.SourceIdMangaCount +import eu.kanade.tachiyomi.data.database.tables.MangaTable + +class SourceIdMangaCountGetResolver : DefaultGetResolver() { + + companion object { + val INSTANCE = SourceIdMangaCountGetResolver() + const val COL_COUNT = "manga_count" + } + + @SuppressLint("Range") + override fun mapFromCursor(cursor: Cursor): SourceIdMangaCount { + val sourceID = cursor.getLong(cursor.getColumnIndex(MangaTable.COL_SOURCE)) + val count = cursor.getInt(cursor.getColumnIndex(COL_COUNT)) + + return SourceIdMangaCount(sourceID, count) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index 4ab35e05e4..858df6d165 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -1,10 +1,8 @@ package eu.kanade.tachiyomi.ui.setting import android.annotation.SuppressLint -import android.app.Dialog import android.content.ActivityNotFoundException import android.content.Intent -import android.os.Bundle import android.provider.Settings import androidx.core.net.toUri import androidx.preference.PreferenceScreen @@ -20,8 +18,9 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE -import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.openInBrowser +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.setting.database.ClearDatabaseController import eu.kanade.tachiyomi.util.CrashLogUtil import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.withUIContext @@ -143,9 +142,7 @@ class SettingsAdvancedController : SettingsController() { summaryRes = R.string.pref_clear_database_summary onClick { - val ctrl = ClearDatabaseDialogController() - ctrl.targetController = this@SettingsAdvancedController - ctrl.showDialog(router) + router.pushController(ClearDatabaseController().withFadeTransaction()) } } } @@ -278,24 +275,6 @@ class SettingsAdvancedController : SettingsController() { } } } - - class ClearDatabaseDialogController : DialogController() { - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialAlertDialogBuilder(activity!!) - .setMessage(R.string.clear_database_confirmation) - .setPositiveButton(android.R.string.ok) { _, _ -> - (targetController as? SettingsAdvancedController)?.clearDatabase() - } - .setNegativeButton(android.R.string.cancel, null) - .create() - } - } - - private fun clearDatabase() { - db.deleteMangasNotInLibrary().executeAsBlocking() - db.deleteHistoryNoLastRead().executeAsBlocking() - activity?.toast(R.string.clear_database_completed) - } } private const val CLEAR_CACHE_KEY = "pref_clear_cache_key" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseController.kt new file mode 100644 index 0000000000..2b66018815 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseController.kt @@ -0,0 +1,172 @@ +package eu.kanade.tachiyomi.ui.setting.database + +import android.annotation.SuppressLint +import android.app.Dialog +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 androidx.core.view.forEach +import androidx.core.view.get +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton +import dev.chrisbanes.insetter.applyInsetter +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.Payload +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.ClearDatabaseControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.base.controller.FabController +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.util.system.toast + +class ClearDatabaseController : + NucleusController(), + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnUpdateListener, + FabController { + + private var recycler: RecyclerView? = null + private var adapter: FlexibleAdapter? = null + + private var menu: Menu? = null + + private var actionFab: ExtendedFloatingActionButton? = null + private var actionFabScrollListener: RecyclerView.OnScrollListener? = null + + init { + setHasOptionsMenu(true) + } + + override fun createBinding(inflater: LayoutInflater): ClearDatabaseControllerBinding { + return ClearDatabaseControllerBinding.inflate(inflater) + } + + override fun createPresenter(): ClearDatabasePresenter { + return ClearDatabasePresenter() + } + + override fun getTitle(): String? { + return activity?.getString(R.string.pref_clear_database) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + binding.recycler.applyInsetter { + type(navigationBars = true) { + padding() + } + } + + adapter = FlexibleAdapter(null, this, true) + binding.recycler.adapter = adapter + binding.recycler.layoutManager = LinearLayoutManager(activity) + binding.recycler.setHasFixedSize(true) + adapter?.fastScroller = binding.fastScroller + recycler = binding.recycler + } + + override fun onDestroyView(view: View) { + adapter = null + super.onDestroyView(view) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.generic_selection, menu) + this.menu = menu + menu.forEach { menuItem -> menuItem.isVisible = (adapter?.itemCount ?: 0) > 0 } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val adapter = adapter ?: return false + when (item.itemId) { + R.id.action_select_all -> adapter.selectAll() + R.id.action_select_inverse -> { + val currentSelection = adapter.selectedPositionsAsSet + val invertedSelection = (0..adapter.itemCount) + .filterNot { currentSelection.contains(it) } + currentSelection.clear() + currentSelection.addAll(invertedSelection) + } + } + updateFab() + adapter.notifyItemRangeChanged(0, adapter.itemCount, Payload.SELECTION) + return super.onOptionsItemSelected(item) + } + + override fun onUpdateEmptyView(size: Int) { + if (size > 0) { + binding.emptyView.hide() + } else { + binding.emptyView.show(activity!!.getString(R.string.database_clean)) + } + + menu?.forEach { menuItem -> menuItem.isVisible = size > 0 } + } + + override fun onItemClick(view: View?, position: Int): Boolean { + val adapter = adapter ?: return false + adapter.toggleSelection(position) + adapter.notifyItemChanged(position, Payload.SELECTION) + updateFab() + return true + } + + fun setItems(items: List) { + adapter?.updateDataSet(items) + } + + override fun configureFab(fab: ExtendedFloatingActionButton) { + fab.setIconResource(R.drawable.ic_delete_24dp) + fab.setText(R.string.action_delete) + fab.isVisible = false + fab.setOnClickListener { + val ctrl = ClearDatabaseSourcesDialog() + ctrl.targetController = this + ctrl.showDialog(router) + } + actionFab = fab + } + + private fun updateFab() { + val adapter = adapter ?: return + actionFab?.isVisible = adapter.selectedItemCount > 0 + } + + override fun cleanupFab(fab: ExtendedFloatingActionButton) { + actionFab?.setOnClickListener(null) + actionFabScrollListener?.let { recycler?.removeOnScrollListener(it) } + actionFab = null + } + + class ClearDatabaseSourcesDialog : DialogController() { + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(activity!!) + .setMessage(R.string.clear_database_confirmation) + .setPositiveButton(android.R.string.ok) { _, _ -> + (targetController as? ClearDatabaseController)?.clearDatabaseForSelectedSources() + } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + } + + @SuppressLint("NotifyDataSetChanged") + private fun clearDatabaseForSelectedSources() { + val adapter = adapter ?: return + val selectedSourceIds = adapter.selectedPositions.mapNotNull { position -> + adapter.getItem(position)?.source?.id + } + presenter.clearDatabaseForSourceIds(selectedSourceIds) + actionFab!!.isVisible = false + adapter.clearSelection() + adapter.notifyDataSetChanged() + activity?.toast(R.string.clear_database_completed) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt new file mode 100644 index 0000000000..05dafd73c0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.ui.setting.database + +import android.os.Bundle +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import rx.Observable +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class ClearDatabasePresenter : BasePresenter() { + + private val db = Injekt.get() + + private val sourceManager = Injekt.get() + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + getDatabaseSourcesObservable() + .subscribeOn(Schedulers.io()) + .subscribeLatestCache(ClearDatabaseController::setItems) + } + + fun clearDatabaseForSourceIds(sources: List) { + db.deleteMangasNotInLibraryBySourceIds(sources).executeAsBlocking() + db.deleteHistoryNoLastRead().executeAsBlocking() + } + + private fun getDatabaseSourcesObservable(): Observable> { + return db.getSourceIdsWithNonLibraryManga().asRxObservable() + .map { sourceCounts -> + sourceCounts.map { + val sourceObj = sourceManager.getOrStub(it.source) + ClearDatabaseSourceItem(sourceObj, it.count) + }.sortedBy { it.source.name } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseSourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseSourceItem.kt new file mode 100644 index 0000000000..f599c8e804 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseSourceItem.kt @@ -0,0 +1,55 @@ +package eu.kanade.tachiyomi.ui.setting.database + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.ClearDatabaseSourceItemBinding +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.icon + +data class ClearDatabaseSourceItem(val source: Source, private val mangaCount: Int) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.clear_database_source_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { + return Holder(view, adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter>?, holder: Holder?, position: Int, payloads: MutableList?) { + if (payloads.isNullOrEmpty()) { + holder?.bind(source, mangaCount) + } else { + holder?.updateCheckbox() + } + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { + + private val binding = ClearDatabaseSourceItemBinding.bind(view) + + fun bind(source: Source, count: Int) { + binding.title.text = source.toString() + binding.description.text = itemView.context.getString(R.string.clear_database_source_item_count, count) + + itemView.post { + when { + source.id == LocalSource.ID -> binding.thumbnail.setImageResource(R.mipmap.ic_local_source) + source is SourceManager.StubSource -> binding.thumbnail.setImageDrawable(null) + source.icon() != null -> binding.thumbnail.setImageDrawable(source.icon()) + } + } + } + + fun updateCheckbox() { + binding.checkbox.isChecked = (bindingAdapter as FlexibleAdapter<*>).isSelected(bindingAdapterPosition) + } + } +} diff --git a/app/src/main/res/layout/clear_database_controller.xml b/app/src/main/res/layout/clear_database_controller.xml new file mode 100644 index 0000000000..70027af456 --- /dev/null +++ b/app/src/main/res/layout/clear_database_controller.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/clear_database_source_item.xml b/app/src/main/res/layout/clear_database_source_item.xml new file mode 100644 index 0000000000..823668a53b --- /dev/null +++ b/app/src/main/res/layout/clear_database_source_item.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 57dc9f0f26..a6ff4ad9b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -461,8 +461,10 @@ Clear chapter cache on app close Clear database Delete history for manga that are not saved in your library + %1$d non-library manga in database Are you sure? Read chapters and progress of non-library manga will be lost Entries deleted + Database clean Refresh library manga covers Refresh tracking Updates status, score and last chapter read from the tracking services