diff --git a/app/src/debug/res/drawable-anydpi/ic_copy.xml b/app/src/debug/res/drawable-anydpi/ic_copy.xml new file mode 100644 index 000000000..b11c7d4ef --- /dev/null +++ b/app/src/debug/res/drawable-anydpi/ic_copy.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/debug/res/drawable-anydpi/ic_done.xml b/app/src/debug/res/drawable-anydpi/ic_done.xml new file mode 100644 index 000000000..28bf05369 --- /dev/null +++ b/app/src/debug/res/drawable-anydpi/ic_done.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/debug/res/drawable-anydpi/ic_done_all.xml b/app/src/debug/res/drawable-anydpi/ic_done_all.xml new file mode 100644 index 000000000..85d9ffd19 --- /dev/null +++ b/app/src/debug/res/drawable-anydpi/ic_done_all.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/debug/res/drawable-hdpi/ic_copy.png b/app/src/debug/res/drawable-hdpi/ic_copy.png new file mode 100644 index 000000000..7821875d3 Binary files /dev/null and b/app/src/debug/res/drawable-hdpi/ic_copy.png differ diff --git a/app/src/debug/res/drawable-hdpi/ic_done.png b/app/src/debug/res/drawable-hdpi/ic_done.png new file mode 100644 index 000000000..f1f39724e Binary files /dev/null and b/app/src/debug/res/drawable-hdpi/ic_done.png differ diff --git a/app/src/debug/res/drawable-hdpi/ic_done_all.png b/app/src/debug/res/drawable-hdpi/ic_done_all.png new file mode 100644 index 000000000..3d0e27aa9 Binary files /dev/null and b/app/src/debug/res/drawable-hdpi/ic_done_all.png differ diff --git a/app/src/debug/res/drawable-mdpi/ic_copy.png b/app/src/debug/res/drawable-mdpi/ic_copy.png new file mode 100644 index 000000000..912309132 Binary files /dev/null and b/app/src/debug/res/drawable-mdpi/ic_copy.png differ diff --git a/app/src/debug/res/drawable-mdpi/ic_done.png b/app/src/debug/res/drawable-mdpi/ic_done.png new file mode 100644 index 000000000..4d1d9bdfd Binary files /dev/null and b/app/src/debug/res/drawable-mdpi/ic_done.png differ diff --git a/app/src/debug/res/drawable-mdpi/ic_done_all.png b/app/src/debug/res/drawable-mdpi/ic_done_all.png new file mode 100644 index 000000000..c0e88e9fe Binary files /dev/null and b/app/src/debug/res/drawable-mdpi/ic_done_all.png differ diff --git a/app/src/debug/res/drawable-xhdpi/ic_copy.png b/app/src/debug/res/drawable-xhdpi/ic_copy.png new file mode 100644 index 000000000..a15eb3da5 Binary files /dev/null and b/app/src/debug/res/drawable-xhdpi/ic_copy.png differ diff --git a/app/src/debug/res/drawable-xhdpi/ic_done.png b/app/src/debug/res/drawable-xhdpi/ic_done.png new file mode 100644 index 000000000..f193eba59 Binary files /dev/null and b/app/src/debug/res/drawable-xhdpi/ic_done.png differ diff --git a/app/src/debug/res/drawable-xhdpi/ic_done_all.png b/app/src/debug/res/drawable-xhdpi/ic_done_all.png new file mode 100644 index 000000000..d6036992c Binary files /dev/null and b/app/src/debug/res/drawable-xhdpi/ic_done_all.png differ diff --git a/app/src/debug/res/drawable-xxhdpi/ic_copy.png b/app/src/debug/res/drawable-xxhdpi/ic_copy.png new file mode 100644 index 000000000..bfc0c2588 Binary files /dev/null and b/app/src/debug/res/drawable-xxhdpi/ic_copy.png differ diff --git a/app/src/debug/res/drawable-xxhdpi/ic_done.png b/app/src/debug/res/drawable-xxhdpi/ic_done.png new file mode 100644 index 000000000..612c0a3fd Binary files /dev/null and b/app/src/debug/res/drawable-xxhdpi/ic_done.png differ diff --git a/app/src/debug/res/drawable-xxhdpi/ic_done_all.png b/app/src/debug/res/drawable-xxhdpi/ic_done_all.png new file mode 100644 index 000000000..da71795bd Binary files /dev/null and b/app/src/debug/res/drawable-xxhdpi/ic_done_all.png differ diff --git a/app/src/debug/res/drawable/ic_migrate_direction.xml b/app/src/debug/res/drawable/ic_migrate_direction.xml new file mode 100644 index 000000000..90e15b6b9 --- /dev/null +++ b/app/src/debug/res/drawable/ic_migrate_direction.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationMangaDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationMangaDialog.kt new file mode 100644 index 000000000..c9c2057b4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationMangaDialog.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.ui.migration + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationListController + +class MigrationMangaDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller { + + var copy = false + var mangaSet = 0 + var mangaSkipped = 0 + constructor(target: T, copy: Boolean, mangaSet: Int, mangaSkipped: Int) : this() { + targetController = target + this.copy = copy + this.mangaSet = mangaSet + this.mangaSkipped = mangaSkipped + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val confirmRes = if (copy) R.string.confirm_copy else R.string.confirm_migration + val confirmString = applicationContext?.getString(confirmRes, mangaSet, ( + if (mangaSkipped > 0) + " " + applicationContext?.getString(R.string.skipping_x, mangaSkipped) ?: "" + else "")) ?: "" + return MaterialDialog.Builder(activity!!) + .content(confirmString) + .positiveText(android.R.string.yes) + .negativeText(android.R.string.no) + .onPositive { _, _ -> + if (copy) + (targetController as? MigrationListController)?.copyMangas() + else + (targetController as? MigrationListController)?.migrateMangas() + }.show() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationDesignController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationDesignController.kt index 52a0a4017..89be89b55 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationDesignController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationDesignController.kt @@ -14,13 +14,11 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.migration.MigrationFlags +import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationListController import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationProcedureConfig -import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationProcedureController import eu.kanade.tachiyomi.util.view.gone import eu.kanade.tachiyomi.util.view.visible import kotlinx.android.synthetic.main.migration_design_controller.begin_migration_btn -import kotlinx.android.synthetic.main.migration_design_controller.copy_manga -import kotlinx.android.synthetic.main.migration_design_controller.copy_manga_desc import kotlinx.android.synthetic.main.migration_design_controller.extra_search_param import kotlinx.android.synthetic.main.migration_design_controller.extra_search_param_desc import kotlinx.android.synthetic.main.migration_design_controller.extra_search_param_text @@ -73,10 +71,6 @@ class MigrationDesignController(bundle: Bundle? = null) : BaseController(bundle) use_smart_search.toggle() } - copy_manga_desc.setOnClickListener { - copy_manga.toggle() - } - extra_search_param_desc.setOnClickListener { extra_search_param.toggle() } @@ -106,7 +100,7 @@ class MigrationDesignController(bundle: Bundle? = null) : BaseController(bundle) if (mig_categories.isChecked) flags = flags or MigrationFlags.TRACK router.replaceTopController( - MigrationProcedureController.create( + MigrationListController.create( MigrationProcedureConfig( config.toList(), ourAdapter.items.filter { @@ -115,7 +109,6 @@ class MigrationDesignController(bundle: Bundle? = null) : BaseController(bundle) useSourceWithMostChapters = prioritize_chapter_count.isChecked, enableLenientSearch = use_smart_search.isChecked, migrationFlags = flags, - copy = copy_manga.isChecked, extraSearchParams = if (extra_search_param.isChecked && extra_search_param_text.text.isNotBlank()) { extra_search_param_text.text.toString() } else null diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigratingManga.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigratingManga.kt index 5303ccc09..37e0c432b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigratingManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigratingManga.kt @@ -33,4 +33,9 @@ class MigratingManga( suspend fun mangaSource(): Source { return sourceManager.getOrStub(manga()?.source ?: -1) } + + fun toModal(): MigrationProcessItem { + // Create the model object. + return MigrationProcessItem(this) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationListController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationListController.kt new file mode 100644 index 000000000..796874d51 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationListController.kt @@ -0,0 +1,386 @@ +package eu.kanade.tachiyomi.ui.migration.manga.process + +import android.content.pm.ActivityInfo +import android.graphics.Color +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.core.graphics.ColorUtils +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.smartsearch.SmartSearchEngine +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.controller.BaseController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.migration.MigrationMangaDialog +import eu.kanade.tachiyomi.ui.migration.SearchController +import eu.kanade.tachiyomi.util.await +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext +import kotlinx.android.synthetic.main.chapters_controller.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import rx.schedulers.Schedulers +import uy.kohesive.injekt.injectLazy + +class MigrationListController(bundle: Bundle? = null) : BaseController(bundle), + MigrationProcessAdapter.MigrationProcessInterface, + CoroutineScope { + + init { + setHasOptionsMenu(true) + } + + private var titleText = "Migrate manga" + + private var adapter: MigrationProcessAdapter? = null + + override val coroutineContext: CoroutineContext = Job() + Dispatchers.Default + + val config: MigrationProcedureConfig? = args.getParcelable(CONFIG_EXTRA) + + private val db: DatabaseHelper by injectLazy() + private val sourceManager: SourceManager by injectLazy() + + private val smartSearchEngine = SmartSearchEngine(coroutineContext, config?.extraSearchParams) + + private var migrationsJob: Job? = null + private var migratingManga: MutableList? = null + private var selectedPosition: Int? = null + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.migration_list_controller, container, false) + } + + override fun getTitle(): String { + return titleText + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + setTitle() + val config = this.config ?: return + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + + val newMigratingManga = migratingManga ?: run { + val new = config.mangaIds.map { + MigratingManga(db, sourceManager, it, coroutineContext) + } + migratingManga = new.toMutableList() + new + } + + adapter = MigrationProcessAdapter(this, view.context) + + recycler.adapter = adapter + recycler.layoutManager = LinearLayoutManager(view.context) + // recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration + // .VERTICAL)) + recycler.setHasFixedSize(true) + recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) + // recycler.isEnabled = false + + adapter?.updateDataSet(newMigratingManga.map { it.toModal() }) + + if (migrationsJob == null) { + migrationsJob = launch { + runMigrations(newMigratingManga) + } + } + } + + /*fun nextMigration() { + adapter?.let { adapter -> + if(pager.currentItem >= adapter.count - 1) { + applicationContext?.toast("All migrations complete!") + router.popCurrentController() + } else { + adapter.migratingManga[pager.currentItem].migrationJob.cancel() + pager.setCurrentItem(pager.currentItem + 1, true) + launch(Dispatchers.Main) { + updateTitle() + } + } + } + }*/ + + fun migrationFailure() { + activity?.let { + MaterialDialog.Builder(it) + .title("Migration failure") + .content("An unknown error occured while migrating this manga!") + .positiveText("Ok") + .show() + } + } + + suspend fun runMigrations(mangas: List) { + val sources = config?.targetSourceIds?.mapNotNull { sourceManager.get(it) as? CatalogueSource } ?: return + + for (manga in mangas) { + if (!manga.searchResult.initialized && manga.migrationJob.isActive) { + val mangaObj = manga.manga() + + if (mangaObj == null) { + manga.searchResult.initialize(null) + continue + } + + val mangaSource = manga.mangaSource() + + val result = try { + CoroutineScope(manga.migrationJob).async { + val validSources = sources.filter { + it.id != mangaSource.id + } + if (config.useSourceWithMostChapters) { + val sourceSemaphore = Semaphore(3) + val processedSources = AtomicInteger() + + validSources.map { source -> + async { + sourceSemaphore.withPermit { + try { + val searchResult = if (config.enableLenientSearch) { + smartSearchEngine.smartSearch(source, mangaObj.title) + } else { + smartSearchEngine.normalSearch(source, mangaObj.title) + } + + if (searchResult != null) { + val localManga = smartSearchEngine.networkToLocalManga(searchResult, source.id) + val chapters = source.fetchChapterList(localManga).toSingle().await( + Schedulers.io()) + withContext(Dispatchers.IO) { + syncChaptersWithSource(db, chapters, localManga, source) + } + manga.progress.send(validSources.size to processedSources.incrementAndGet()) + localManga to chapters.size + } else { + null + } + } catch (e: CancellationException) { + // Ignore cancellations + throw e + } catch (e: Exception) { + null + } + } + } + }.mapNotNull { it.await() }.maxBy { it.second }?.first + } else { + validSources.forEachIndexed { index, source -> + val searchResult = try { + val searchResult = if (config.enableLenientSearch) { + smartSearchEngine.smartSearch(source, mangaObj.title) + } else { + smartSearchEngine.normalSearch(source, mangaObj.title) + } + + if (searchResult != null) { + val localManga = smartSearchEngine.networkToLocalManga(searchResult, source.id) + val chapters = source.fetchChapterList(localManga).toSingle().await( + Schedulers.io()) + withContext(Dispatchers.IO) { + syncChaptersWithSource(db, chapters, localManga, source) + } + localManga + } else null + } catch (e: CancellationException) { + // Ignore cancellations + throw e + } catch (e: Exception) { + null + } + + manga.progress.send(validSources.size to (index + 1)) + + if (searchResult != null) return@async searchResult + } + + null + } + }.await() + } catch (e: CancellationException) { + // Ignore canceled migrations + continue + } + + if (result != null && result.thumbnail_url == null) { + try { + val newManga = sourceManager.getOrStub(result.source) + .fetchMangaDetails(result) + .toSingle() + .await() + result.copyFrom(newManga) + + db.insertManga(result).executeAsBlocking() + } catch (e: CancellationException) { + // Ignore cancellations + throw e + } catch (e: Exception) { + } + } + + manga.searchResult.initialize(result?.id) + } + } + } + + override fun onDestroy() { + super.onDestroy() + + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + + override fun enableButtons() { + activity?.invalidateOptionsMenu() + } + + override fun removeManga(position: Int) { + val ids = config?.mangaIds?.toMutableList() ?: return + ids.removeAt(position) + migratingManga?.removeAt(position) + config.mangaIds = ids + } + + override fun noMigration() { + activity?.toast(R.string.no_migrations) + router.popCurrentController() + } + + override fun onMenuItemClick(position: Int, item: MenuItem) { + + when (item.itemId) { + R.id.action_search_manually -> { + launchUI { + val manga = adapter?.getItem(position) ?: return@launchUI + selectedPosition = position + val searchController = SearchController(manga.manga.manga()) + searchController.targetController = this@MigrationListController + router.pushController(searchController.withFadeTransaction()) + } + } + R.id.action_skip -> adapter?.removeManga(position) + R.id.action_migrate_now -> adapter?.migrateManga(position, false) + R.id.action_copy_now -> adapter?.migrateManga(position, true) + } + } + + fun useMangaForMigration(manga: Manga, source: Source) { + val firstIndex = selectedPosition ?: return + val migratingManga = adapter?.getItem(firstIndex) ?: return + migratingManga.showSpinner() + launchUI { + val result = CoroutineScope(migratingManga.manga.migrationJob).async { + val localManga = smartSearchEngine.networkToLocalManga(manga, source.id) + val chapters = source.fetchChapterList(localManga).toSingle().await( + Schedulers.io() + ) + withContext(Dispatchers.IO) { + syncChaptersWithSource(db, chapters, localManga, source) + } + localManga + }.await() + + try { + val newManga = + sourceManager.getOrStub(result.source).fetchMangaDetails(result).toSingle() + .await() + result.copyFrom(newManga) + + db.insertManga(result).executeAsBlocking() + } catch (e: CancellationException) { + // Ignore cancellations + throw e + } catch (e: Exception) { + } + + migratingManga.manga.searchResult.set(result.id) + adapter?.notifyDataSetChanged() + } + } + + fun migrateMangas() { + launchUI { + adapter?.performMigrations(false) + router.popCurrentController() + } + } + + fun copyMangas() { + launchUI { + adapter?.performMigrations(true) + router.popCurrentController() + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.migration_list, menu) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + // Initialize menu items. + + val allMangasDone = adapter?.allMangasDone() ?: return + + val menuCopy = menu.findItem(R.id.action_copy_manga) + val menuMigrate = menu.findItem(R.id.action_migrate_manga) + + if (adapter?.itemCount == 1) { + menuMigrate.icon = VectorDrawableCompat.create( + resources!!, R.drawable.ic_done, null + ) + } + val translucentWhite = ColorUtils.setAlphaComponent(Color.WHITE, 127) + menuCopy.icon?.setTint(if (allMangasDone) Color.WHITE else translucentWhite) + menuMigrate?.icon?.setTint(if (allMangasDone) Color.WHITE else translucentWhite) + menuCopy.isEnabled = allMangasDone + menuMigrate.isEnabled = allMangasDone + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val itemsCount = adapter?.itemCount ?: 0 + val mangasSkipped = adapter?.mangasSkipped() ?: 0 + when (item.itemId) { + R.id.action_copy_manga -> MigrationMangaDialog(this, true, itemsCount, mangasSkipped) + .showDialog(router) + R.id.action_migrate_manga -> MigrationMangaDialog(this, false, itemsCount, mangasSkipped) + .showDialog(router) + else -> return super.onOptionsItemSelected(item) + } + return true + } + + companion object { + const val CONFIG_EXTRA = "config_extra" + + fun create(config: MigrationProcedureConfig): MigrationListController { + return MigrationListController(Bundle().apply { + putParcelable(CONFIG_EXTRA, config) + }) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureAdapter.kt index a3f395522..0a4b4781c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureAdapter.kt @@ -74,7 +74,7 @@ class MigrationProcedureAdapter( container.addView(view) view.skip_migration.setOnClickListener { - controller.nextMigration() + // controller.nextMigration() } val viewTag = ViewTag(coroutineContext) @@ -100,19 +100,19 @@ class MigrationProcedureAdapter( } suspend fun performMigration(manga: MigratingManga) { - if (!manga.searchResult.initialized) { - return - } + if (!manga.searchResult.initialized) { + return + } - val toMangaObj = db.getManga(manga.searchResult.get() ?: return).executeAsBlocking() ?: return + val toMangaObj = db.getManga(manga.searchResult.get() ?: return).executeAsBlocking() ?: return - withContext(Dispatchers.IO) { - migrateMangaInternal( + withContext(Dispatchers.IO) { + migrateMangaInternal( manga.manga() ?: return@withContext, toMangaObj, - !(controller.config?.copy ?: false) - ) - } + false + ) + } } private fun migrateMangaInternal( @@ -121,7 +121,7 @@ class MigrationProcedureAdapter( replace: Boolean ) { val config = controller.config ?: return - db.inTransaction { + // db.inTransaction { // Update chapters read if (MigrationFlags.hasChapters(controller.config.migrationFlags)) { val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking() @@ -162,7 +162,7 @@ class MigrationProcedureAdapter( // SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title db.updateMangaTitle(manga).executeAsBlocking() - } + // } } fun View.setupView(tag: ViewTag, migratingManga: MigratingManga) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureConfig.kt index 69279858f..d3bf84a89 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureConfig.kt @@ -5,11 +5,10 @@ import kotlinx.android.parcel.Parcelize @Parcelize data class MigrationProcedureConfig( - val mangaIds: List, + var mangaIds: List, val targetSourceIds: List, val useSourceWithMostChapters: Boolean, val enableLenientSearch: Boolean, val migrationFlags: Int, - val copy: Boolean, val extraSearchParams: String? ) : Parcelable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureController.kt index 776b57376..f31797935 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureController.kt @@ -148,8 +148,7 @@ class MigrationProcedureController(bundle: Bundle? = null) : BaseController(bund async { sourceSemaphore.withPermit { try { - val searchResult = if (config?.enableLenientSearch == - true) { + val searchResult = if (config.enableLenientSearch) { smartSearchEngine.smartSearch(source, mangaObj.title) } else { smartSearchEngine.normalSearch(source, mangaObj.title) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessAdapter.kt new file mode 100644 index 000000000..8abf3d959 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessAdapter.kt @@ -0,0 +1,145 @@ +package eu.kanade.tachiyomi.ui.migration.manga.process + +import android.content.Context +import android.view.MenuItem +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.ui.migration.MigrationFlags +import eu.kanade.tachiyomi.util.lang.launchUI +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import uy.kohesive.injekt.injectLazy + +class MigrationProcessAdapter( + val controller: MigrationListController, + context: Context +) : FlexibleAdapter(null, controller, true) { + + private val db: DatabaseHelper by injectLazy() + var items: List = emptyList() + + val menuItemListener: MigrationProcessInterface = controller + + override fun updateDataSet(items: List?) { + this.items = items ?: emptyList() + super.updateDataSet(items) + } + + fun indexOf(item: MigrationProcessItem): Int { + return items.indexOf(item) + } + + interface MigrationProcessInterface { + fun onMenuItemClick(position: Int, item: MenuItem) + fun enableButtons() + fun removeManga(position: Int) + fun noMigration() + } + + fun sourceFinished() { + if (mangasSkipped() == itemCount || itemCount == 0) menuItemListener.noMigration() + if (allMangasDone()) menuItemListener.enableButtons() + } + + fun allMangasDone() = (items.all { it.manga.searchResult.initialized || !it.manga.migrationJob + .isActive } && items.any { it.manga + .searchResult.content != null }) + + fun mangasSkipped() = (items.count { (!it.manga.searchResult.initialized || it.manga + .searchResult.content == null) && !it.manga.migrationJob.isActive }) + + suspend fun performMigrations(copy: Boolean) { + withContext(Dispatchers.IO) { + db.inTransaction { + currentItems.forEach { migratingManga -> + val manga = migratingManga.manga + if (manga.searchResult.initialized) { + val toMangaObj = + db.getManga(manga.searchResult.get() ?: return@forEach).executeAsBlocking() + ?: return@forEach + migrateMangaInternal( + manga.manga() ?: return@forEach, + toMangaObj, + !copy) + } + } + } + } + } + + fun migrateManga(position: Int, copy: Boolean) { + launchUI { + val manga = getItem(position)?.manga ?: return@launchUI + db.inTransaction { + val toMangaObj = db.getManga(manga.searchResult.get() ?: return@launchUI).executeAsBlocking() + ?: return@launchUI + migrateMangaInternal( + manga.manga() ?: return@launchUI, toMangaObj, !copy + ) + } + removeManga(position) + } + } + + fun removeManga(position: Int) { + menuItemListener.removeManga(position) + getItem(position)?.manga?.migrationJob?.cancel() + removeItem(position) + items = currentItems + sourceFinished() + } + + private fun migrateMangaInternal( + prevManga: Manga, + manga: Manga, + replace: Boolean + ) { + if (controller.config == null) return + // db.inTransaction { + // Update chapters read + if (MigrationFlags.hasChapters(controller.config.migrationFlags)) { + val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking() + val maxChapterRead = prevMangaChapters.filter { it.read } + .maxBy { it.chapter_number }?.chapter_number + if (maxChapterRead != null) { + val dbChapters = db.getChapters(manga).executeAsBlocking() + for (chapter in dbChapters) { + if (chapter.isRecognizedNumber && chapter.chapter_number <= maxChapterRead) { + chapter.read = true + } + } + db.insertChapters(dbChapters).executeAsBlocking() + } + } + // Update categories + if (MigrationFlags.hasCategories(controller.config.migrationFlags)) { + val categories = db.getCategoriesForManga(prevManga).executeAsBlocking() + val mangaCategories = categories.map { MangaCategory.create(manga, it) } + db.setMangaCategories(mangaCategories, listOf(manga)) + } + // Update track + if (MigrationFlags.hasTracks(controller.config.migrationFlags)) { + val tracks = db.getTracks(prevManga).executeAsBlocking() + for (track in tracks) { + track.id = null + track.manga_id = manga.id!! + } + db.insertTracks(tracks).executeAsBlocking() + } + // Update favorite status + if (replace) { + prevManga.favorite = false + db.updateMangaFavorite(prevManga).executeAsBlocking() + } + manga.favorite = true + db.updateMangaFavorite(manga).executeAsBlocking() + + // SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title + db.updateMangaTitle(manga).executeAsBlocking() + // } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessHolder.kt new file mode 100644 index 000000000..b678d5467 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessHolder.kt @@ -0,0 +1,173 @@ +package eu.kanade.tachiyomi.ui.migration.manga.process + +import android.view.View +import android.widget.PopupMenu +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.setVectorCompat +import eu.kanade.tachiyomi.util.view.visible +import java.text.DecimalFormat +import kotlinx.android.synthetic.main.migration_new_manga_card.view.* +import kotlinx.android.synthetic.main.migration_new_process_item.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import uy.kohesive.injekt.injectLazy + +class MigrationProcessHolder( + private val view: View, + private val adapter: MigrationProcessAdapter +) : BaseFlexibleViewHolder(view, adapter) { + + private val db: DatabaseHelper by injectLazy() + private val sourceManager: SourceManager by injectLazy() + + init { + // We need to post a Runnable to show the popup to make sure that the PopupMenu is + // correctly positioned. The reason being that the view may change position before the + // PopupMenu is shown. + migration_menu.setOnClickListener { it.post { showPopupMenu(it) } } + skip_manga.setOnClickListener { it.post { adapter.removeManga(adapterPosition) } } + } + + fun bind(item: MigrationProcessItem) { + launchUI { + val manga = item.manga.manga() + val source = item.manga.mangaSource() + + migration_menu.setVectorCompat(R.drawable.ic_more_vert_24dp, view.context.getResourceColor(R.attr.colorOnPrimary)) + skip_manga.setVectorCompat(R.drawable.ic_close_24dp, view.context.getResourceColor(R + .attr.colorOnPrimary)) + migration_menu.gone() + if (manga != null) { + withContext(Dispatchers.Main) { + migration_manga_card_from.loading_group.gone() + attachManga(migration_manga_card_from, manga, source) + migration_manga_card_from.setOnClickListener { + adapter.controller.router.pushController( + MangaController( + manga, + true + ).withFadeTransaction() + ) + } + } + + /*launchUI { + item.manga.progress.asFlow().collect { (max, progress) -> + withContext(Dispatchers.Main) { + migration_manga_card_to.search_progress.let { progressBar -> + progressBar.max = max + progressBar.progress = progress + } + } + } + }*/ + + val searchResult = item.manga.searchResult.get()?.let { + db.getManga(it).executeAsBlocking() + } + val resultSource = searchResult?.source?.let { + sourceManager.get(it) + } + withContext(Dispatchers.Main) { + if (searchResult != null && resultSource != null) { + migration_manga_card_to.loading_group.gone() + attachManga(migration_manga_card_to, searchResult, resultSource) + migration_manga_card_to.setOnClickListener { + adapter.controller.router.pushController( + MangaController( + searchResult, true + ).withFadeTransaction() + ) + } + } else { + migration_manga_card_to.loading_group.gone() + migration_manga_card_to.title.text = "No Alternatives Found" + } + migration_menu.visible() + skip_manga.gone() + adapter.sourceFinished() + } + } + } + } + + fun showSpinner() { + migration_manga_card_to.loading_group.visible() + } + + fun attachManga(view: View, manga: Manga, source: Source) { + view.loading_group.gone() + GlideApp.with(view.context.applicationContext) + .load(manga) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(view.thumbnail) + + view.title.text = if (manga.title.isBlank()) { + view.context.getString(R.string.unknown) + } else { + manga.title + } + + view.gradient.visible() + view.manga_source_label.text = /*if (source.id == MERGED_SOURCE_ID) { + MergedSource.MangaConfig.readFromUrl(gson, manga.url).children.map { + sourceManager.getOrStub(it.source).toString() + }.distinct().joinToString() + } else {*/ + source.toString() + // } + + val mangaChapters = db.getChapters(manga).executeAsBlocking() + view.manga_chapters.visible() + view.manga_chapters.text = mangaChapters.size.toString() + val latestChapter = mangaChapters.maxBy { it.chapter_number }?.chapter_number ?: -1f + + if (latestChapter > 0f) { + view.manga_last_chapter_label.text = view.context.getString(R.string.latest_x, + DecimalFormat("#.#").format(latestChapter)) + } else { + view.manga_last_chapter_label.setText(R.string.unknown) + } + } + + private fun showPopupMenu(view: View) { + val item = adapter.getItem(adapterPosition) ?: return + + // Create a PopupMenu, giving it the clicked view for an anchor + val popup = PopupMenu(view.context, view) + + // Inflate our menu resource into the PopupMenu's Menu + popup.menuInflater.inflate(R.menu.migration_single, popup.menu) + + val mangas = item.manga + + popup.menu.findItem(R.id.action_search_manually).isVisible = true + // Hide download and show delete if the chapter is downloaded + if (mangas.searchResult.content != null) { + popup.menu.findItem(R.id.action_migrate_now).isVisible = true + popup.menu.findItem(R.id.action_copy_now).isVisible = true + } + + // Set a listener so we are notified if a menu item is clicked + popup.setOnMenuItemClickListener { menuItem -> + adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem) + true + } + + // Finally show the PopupMenu + popup.show() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessItem.kt new file mode 100644 index 000000000..2f08a1aff --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessItem.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.ui.migration.manga.process + +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.kanade.tachiyomi.R + +class MigrationProcessItem(val manga: MigratingManga) : + AbstractFlexibleItem() { + + var holder: MigrationProcessHolder? = null + override fun getLayoutRes(): Int { + return R.layout.migration_new_process_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): MigrationProcessHolder { + return MigrationProcessHolder(view, adapter as MigrationProcessAdapter) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: MigrationProcessHolder, + position: Int, + payloads: MutableList? + ) { + + this.holder = holder + holder.bind(this) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is MigrationProcessItem) { + return manga.mangaId == other.manga.mangaId + } + return false + } + + fun showSpinner() { + holder?.showSpinner() + } + + override fun hashCode(): Int { + return manga.mangaId.hashCode() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DeferredField.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DeferredField.kt index 90282c9da..2f5895188 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/DeferredField.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/DeferredField.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.sync.withLock class DeferredField { @Volatile - private var content: T? = null + var content: T? = null @Volatile var initialized = false @@ -31,6 +31,14 @@ class DeferredField { mutex.unlock() } + fun set(content: T) { + mutex.tryLock() + this.content = content + initialized = true + // Notify current listeners + mutex.unlock() + } + /** * Will only suspend if !initialized. */ diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_right_black_24dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_right_black_24dp.xml new file mode 100644 index 000000000..58d1a85f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_right_black_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/migration_design_controller.xml b/app/src/main/res/layout/migration_design_controller.xml index c295339ba..5469f761d 100644 --- a/app/src/main/res/layout/migration_design_controller.xml +++ b/app/src/main/res/layout/migration_design_controller.xml @@ -114,30 +114,6 @@ android:gravity="start|center_vertical" android:text="@string/use_intelligent_search" android:clickable="true" - app:layout_constraintBottom_toTopOf="@+id/copy_manga" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/prioritize_chapter_count" - android:focusable="true" /> - - - - + app:constraint_referenced_ids="migration_mode,use_smart_search,fuzzy_search,action_copy_manga,extra_search_param_desc,mig_tracking,textView,mig_chapters,copy_manga_desc,textView2,prioritize_chapter_count,mig_categories,extra_search_param" /> \ No newline at end of file diff --git a/app/src/main/res/layout/migration_list_controller.xml b/app/src/main/res/layout/migration_list_controller.xml new file mode 100644 index 000000000..7abb2eba7 --- /dev/null +++ b/app/src/main/res/layout/migration_list_controller.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/migration_new_manga_card.xml b/app/src/main/res/layout/migration_new_manga_card.xml new file mode 100644 index 000000000..5e7746779 --- /dev/null +++ b/app/src/main/res/layout/migration_new_manga_card.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/migration_new_process_item.xml b/app/src/main/res/layout/migration_new_process_item.xml new file mode 100644 index 000000000..388891486 --- /dev/null +++ b/app/src/main/res/layout/migration_new_process_item.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/migration_list.xml b/app/src/main/res/menu/migration_list.xml new file mode 100644 index 000000000..b19ff0b7b --- /dev/null +++ b/app/src/main/res/menu/migration_list.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/migration_single.xml b/app/src/main/res/menu/migration_single.xml new file mode 100644 index 000000000..af3ab4772 --- /dev/null +++ b/app/src/main/res/menu/migration_single.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + \ 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 fde13bdb9..7111a164b 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ Drag & Drop Search Global search + Skip manga Select all Select inverse Mark as read @@ -112,6 +113,9 @@ Back Forward Refresh + Search manually + Migrate now + Copy now Loading… @@ -482,6 +486,10 @@ Unread Are you sure you want to delete the selected chapters? Invalid download location + Migrate %1$d%2$s mangas? + Copy %1$d%2$s mangas? + (skipping %1$d) + No manga migrated Tracking @@ -557,6 +565,8 @@ Select Migrate Copy + Migrating… + Latest: %1$s Could not download chapters. You can try again in the downloads section