Procházet zdrojové kódy

Add a new screen to help migrating manga from sources

inorichi před 7 roky
rodič
revize
a75457ad88
25 změnil soubory, kde provedl 842 přidání a 62 odebrání
  1. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt
  2. 33 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFavoritePutResolver.kt
  3. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  4. 71 0
      app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/SlicedHolder.kt
  5. 4 4
      app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt
  6. 8 54
      app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceHolder.kt
  7. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchController.kt
  8. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchPresenter.kt
  9. 4 0
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt
  10. 17 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaAdapter.kt
  11. 36 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaHolder.kt
  12. 37 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaItem.kt
  13. 135 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt
  14. 140 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt
  15. 108 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchController.kt
  16. 17 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchPresenter.kt
  17. 50 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/SelectionHeader.kt
  18. 42 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt
  19. 43 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt
  20. 41 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceItem.kt
  21. 10 0
      app/src/main/java/eu/kanade/tachiyomi/ui/migration/ViewState.kt
  22. 6 0
      app/src/main/res/layout/migration_controller.xml
  23. 5 0
      app/src/main/res/menu/library.xml
  24. 11 0
      app/src/main/res/menu/migration.xml
  25. 9 1
      app/src/main/res/values/strings.xml

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt

@@ -7,6 +7,7 @@ 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.resolvers.LibraryMangaGetResolver
+import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver
 import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
 import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
 import eu.kanade.tachiyomi.data.database.tables.CategoryTable
@@ -74,6 +75,11 @@ interface MangaQueries : DbProvider {
             .withPutResolver(MangaLastUpdatedPutResolver())
             .prepare()
 
+    fun updateMangaFavorite(manga: Manga) = db.put()
+            .`object`(manga)
+            .withPutResolver(MangaFavoritePutResolver())
+            .prepare()
+
     fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
 
     fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()

+ 33 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFavoritePutResolver.kt

@@ -0,0 +1,33 @@
+package eu.kanade.tachiyomi.data.database.resolvers
+
+import android.content.ContentValues
+import com.pushtorefresh.storio.sqlite.StorIOSQLite
+import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
+import com.pushtorefresh.storio.sqlite.operations.put.PutResult
+import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
+import eu.kanade.tachiyomi.data.database.inTransactionReturn
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.database.tables.MangaTable
+
+class MangaFavoritePutResolver : PutResolver<Manga>() {
+
+    override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
+        val updateQuery = mapToUpdateQuery(manga)
+        val contentValues = mapToContentValues(manga)
+
+        val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
+        PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
+    }
+
+    fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
+            .table(MangaTable.TABLE)
+            .where("${MangaTable.COL_ID} = ?")
+            .whereArgs(manga.id)
+            .build()
+
+    fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
+        put(MangaTable.COL_FAVORITE, manga.favorite)
+    }
+
+}
+

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

@@ -165,4 +165,10 @@ class PreferencesHelper(val context: Context) {
 
     fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
 
+    fun migrateChapters() = rxPrefs.getBoolean("migrate_chapters", true)
+
+    fun migrateTracks() = rxPrefs.getBoolean("migrate_tracks", true)
+
+    fun migrateCategories() = rxPrefs.getBoolean("migrate_categories", true)
+
 }

+ 71 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/SlicedHolder.kt

@@ -0,0 +1,71 @@
+package eu.kanade.tachiyomi.ui.base.holder
+
+import android.os.Build
+import android.view.View
+import android.view.ViewGroup
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.IFlexible
+import eu.davidea.flexibleadapter.items.ISectionable
+import eu.kanade.tachiyomi.util.dpToPx
+import io.github.mthli.slice.Slice
+
+interface SlicedHolder {
+
+    val slice: Slice
+
+    val adapter: FlexibleAdapter<IFlexible<*>>
+
+    val viewToSlice: View
+
+    fun setCardEdges(item: ISectionable<*, *>) {
+        // Position of this item in its header. Defaults to 0 when header is null.
+        var position = 0
+
+        // Number of items in the header of this item. Defaults to 1 when header is null.
+        var count = 1
+
+        if (item.header != null) {
+            val sectionItems = adapter.getSectionItems(item.header)
+            position = sectionItems.indexOf(item)
+            count = sectionItems.size
+        }
+
+        when {
+            // Only one item in the card
+            count == 1 -> applySlice(2f, false, false, true, true)
+            // First item of the card
+            position == 0 -> applySlice(2f, false, true, true, false)
+            // Last item of the card
+            position == count - 1 -> applySlice(2f, true, false, false, true)
+            // Middle item
+            else -> applySlice(0f, false, false, false, false)
+        }
+    }
+
+    private fun applySlice(radius: Float, topRect: Boolean, bottomRect: Boolean,
+                           topShadow: Boolean, bottomShadow: Boolean) {
+        val margin = margin
+
+        slice.setRadius(radius)
+        slice.showLeftTopRect(topRect)
+        slice.showRightTopRect(topRect)
+        slice.showLeftBottomRect(bottomRect)
+        slice.showRightBottomRect(bottomRect)
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+            slice.showTopEdgeShadow(topShadow)
+            slice.showBottomEdgeShadow(bottomShadow)
+        }
+        setMargins(margin, if (topShadow) margin else 0, margin, if (bottomShadow) margin else 0)
+    }
+
+    private fun setMargins(left: Int, top: Int, right: Int, bottom: Int) {
+        if (viewToSlice.layoutParams is ViewGroup.MarginLayoutParams) {
+            val p = viewToSlice.layoutParams as ViewGroup.MarginLayoutParams
+            p.setMargins(left, top, right, bottom)
+        }
+    }
+
+    val margin
+        get() = 8.dpToPx
+
+}

+ 4 - 4
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt

@@ -18,17 +18,17 @@ class SourceDividerItemDecoration(context: Context) : RecyclerView.ItemDecoratio
     }
 
     override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
-        val left = parent.paddingLeft + SourceHolder.margin
-        val right = parent.width - parent.paddingRight - SourceHolder.margin
-
         val childCount = parent.childCount
         for (i in 0 until childCount - 1) {
             val child = parent.getChildAt(i)
-            if (parent.getChildViewHolder(child) is SourceHolder &&
+            val holder = parent.getChildViewHolder(child)
+            if (holder is SourceHolder &&
                     parent.getChildViewHolder(parent.getChildAt(i + 1)) is SourceHolder) {
                 val params = child.layoutParams as RecyclerView.LayoutParams
                 val top = child.bottom + params.bottomMargin
                 val bottom = top + divider.intrinsicHeight
+                val left = parent.paddingLeft + holder.margin
+                val right = parent.paddingRight + holder.margin
 
                 divider.setBounds(left, top, right, bottom)
                 divider.draw(c)

+ 8 - 54
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceHolder.kt

@@ -6,6 +6,7 @@ import android.view.ViewGroup
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.source.online.LoginSource
 import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
+import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder
 import eu.kanade.tachiyomi.util.dpToPx
 import eu.kanade.tachiyomi.util.getRound
 import eu.kanade.tachiyomi.util.gone
@@ -13,12 +14,17 @@ import eu.kanade.tachiyomi.util.visible
 import io.github.mthli.slice.Slice
 import kotlinx.android.synthetic.main.catalogue_main_controller_card_item.*
 
-class SourceHolder(view: View, adapter: CatalogueAdapter) : BaseFlexibleViewHolder(view, adapter) {
+class SourceHolder(view: View, override val adapter: CatalogueAdapter) :
+        BaseFlexibleViewHolder(view, adapter),
+        SlicedHolder {
 
-    private val slice = Slice(card).apply {
+    override val slice = Slice(card).apply {
         setColor(adapter.cardBackground)
     }
 
+    override val viewToSlice: View
+        get() = card
+
     init {
         source_browse.setOnClickListener {
             adapter.browseClickListener.onBrowseClick(adapterPosition)
@@ -50,56 +56,4 @@ class SourceHolder(view: View, adapter: CatalogueAdapter) : BaseFlexibleViewHold
             source_latest.visible()
         }
     }
-
-    private fun setCardEdges(item: SourceItem) {
-        // Position of this item in its header. Defaults to 0 when header is null.
-        var position = 0
-
-        // Number of items in the header of this item. Defaults to 1 when header is null.
-        var count = 1
-
-        if (item.header != null) {
-            val sectionItems = mAdapter.getSectionItems(item.header)
-            position = sectionItems.indexOf(item)
-            count = sectionItems.size
-        }
-
-        when {
-            // Only one item in the card
-            count == 1 -> applySlice(2f, false, false, true, true)
-            // First item of the card
-            position == 0 -> applySlice(2f, false, true, true, false)
-            // Last item of the card
-            position == count - 1 -> applySlice(2f, true, false, false, true)
-            // Middle item
-            else -> applySlice(0f, false, false, false, false)
-        }
-    }
-
-    private fun applySlice(radius: Float, topRect: Boolean, bottomRect: Boolean,
-                           topShadow: Boolean, bottomShadow: Boolean) {
-
-        slice.setRadius(radius)
-        slice.showLeftTopRect(topRect)
-        slice.showRightTopRect(topRect)
-        slice.showLeftBottomRect(bottomRect)
-        slice.showRightBottomRect(bottomRect)
-        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-            slice.showTopEdgeShadow(topShadow)
-            slice.showBottomEdgeShadow(bottomShadow)
-        }
-        setMargins(margin, if (topShadow) margin else 0, margin, if (bottomShadow) margin else 0)
-    }
-
-    private fun setMargins(left: Int, top: Int, right: Int, bottom: Int) {
-        val v = card
-        if (v.layoutParams is ViewGroup.MarginLayoutParams) {
-            val p = v.layoutParams as ViewGroup.MarginLayoutParams
-            p.setMargins(left, top, right, bottom)
-        }
-    }
-
-    companion object {
-        val margin = 8.dpToPx
-    }
 }

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchController.kt

@@ -18,7 +18,7 @@ import kotlinx.android.synthetic.main.catalogue_global_search_controller.*
  * This controller should only handle UI actions, IO actions should be done by [CatalogueSearchPresenter]
  * [CatalogueSearchCardAdapter.OnMangaClickListener] called when manga is clicked in global search
  */
-class CatalogueSearchController(private val initialQuery: String? = null) :
+open class CatalogueSearchController(protected val initialQuery: String? = null) :
         NucleusController<CatalogueSearchPresenter>(),
         CatalogueSearchCardAdapter.OnMangaClickListener {
 

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchPresenter.kt

@@ -30,7 +30,7 @@ import uy.kohesive.injekt.api.get
  * @param db manages the database calls.
  * @param preferencesHelper manages the preference calls.
  */
-class CatalogueSearchPresenter(
+open class CatalogueSearchPresenter(
         val initialQuery: String? = "",
         val sourceManager: SourceManager = Injekt.get(),
         val db: DatabaseHelper = Injekt.get(),
@@ -86,7 +86,7 @@ class CatalogueSearchPresenter(
      *
      * @return list containing enabled sources.
      */
-    private fun getEnabledSources(): List<CatalogueSource> {
+    protected open fun getEnabledSources(): List<CatalogueSource> {
         val languages = preferencesHelper.enabledLanguages().getOrDefault()
         val hiddenCatalogues = preferencesHelper.hiddenCatalogues().getOrDefault()
 

+ 4 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt

@@ -32,6 +32,7 @@ import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.ui.category.CategoryController
 import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.ui.manga.MangaController
+import eu.kanade.tachiyomi.ui.migration.MigrationController
 import eu.kanade.tachiyomi.util.inflate
 import eu.kanade.tachiyomi.util.toast
 import eu.kanade.tachiyomi.widget.DrawerSwipeCloseListener
@@ -360,6 +361,9 @@ class LibraryController(
             R.id.action_edit_categories -> {
                 router.pushController(CategoryController().withFadeTransaction())
             }
+            R.id.action_source_migration -> {
+                router.pushController(MigrationController().withFadeTransaction())
+            }
             else -> return super.onOptionsItemSelected(item)
         }
 

+ 17 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaAdapter.kt

@@ -0,0 +1,17 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.IFlexible
+
+class MangaAdapter(controller: MigrationController) :
+        FlexibleAdapter<IFlexible<*>>(null, controller) {
+
+    private var items: List<IFlexible<*>>? = null
+
+    override fun updateDataSet(items: MutableList<IFlexible<*>>?) {
+        if (this.items !== items) {
+            this.items = items
+            super.updateDataSet(items)
+        }
+    }
+}

+ 36 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaHolder.kt

@@ -0,0 +1,36 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import android.view.View
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.kanade.tachiyomi.data.glide.GlideApp
+import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
+import kotlinx.android.synthetic.main.catalogue_list_item.*
+
+class MangaHolder(
+        private val view: View,
+        private val adapter: FlexibleAdapter<*>
+) : BaseFlexibleViewHolder(view, adapter) {
+
+    fun bind(item: MangaItem) {
+        // Update the title of the manga.
+        title.text = item.manga.title
+
+        // Create thumbnail onclick to simulate long click
+        thumbnail.setOnClickListener {
+            // Simulate long click on this view to enter selection mode
+            onLongClick(itemView)
+        }
+
+        // Update the cover.
+        GlideApp.with(itemView.context).clear(thumbnail)
+        GlideApp.with(itemView.context)
+                .load(item.manga)
+                .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
+                .centerCrop()
+                .circleCrop()
+                .dontAnimate()
+                .into(thumbnail)
+    }
+
+}

+ 37 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaItem.kt

@@ -0,0 +1,37 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import android.view.View
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Manga
+
+class MangaItem(val manga: Manga) : AbstractFlexibleItem<MangaHolder>() {
+
+    override fun getLayoutRes(): Int {
+        return R.layout.catalogue_list_item
+    }
+
+    override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): MangaHolder {
+        return MangaHolder(view, adapter)
+    }
+
+    override fun bindViewHolder(adapter: FlexibleAdapter<*>,
+                                holder: MangaHolder,
+                                position: Int,
+                                payloads: List<Any?>?) {
+
+        holder.bind(this)
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (other is MangaItem) {
+            return manga.id == other.manga.id
+        }
+        return false
+    }
+
+    override fun hashCode(): Int {
+        return manga.id!!.hashCode()
+    }
+}

+ 135 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt

@@ -0,0 +1,135 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import android.app.Dialog
+import android.os.Bundle
+import android.support.v7.widget.LinearLayoutManager
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.afollestad.materialdialogs.MaterialDialog
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.ui.base.controller.DialogController
+import eu.kanade.tachiyomi.ui.base.controller.NucleusController
+import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
+import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
+import kotlinx.android.synthetic.main.migration_controller.*
+
+class MigrationController : NucleusController<MigrationPresenter>(),
+        FlexibleAdapter.OnItemClickListener,
+        SourceAdapter.OnSelectClickListener {
+
+    private var adapter: FlexibleAdapter<IFlexible<*>>? = null
+
+    private var title: String? = null
+        set(value) {
+            field = value
+            setTitle()
+        }
+
+    override fun createPresenter(): MigrationPresenter {
+        return MigrationPresenter()
+    }
+
+    override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
+        return inflater.inflate(R.layout.migration_controller, container, false)
+    }
+
+    override fun onViewCreated(view: View) {
+        super.onViewCreated(view)
+
+        adapter = FlexibleAdapter(null, this)
+        migration_recycler.layoutManager = LinearLayoutManager(view.context)
+        migration_recycler.adapter = adapter
+    }
+
+    override fun onDestroyView(view: View) {
+        adapter = null
+        super.onDestroyView(view)
+    }
+
+    override fun getTitle(): String? {
+        return title
+    }
+
+    override fun handleBack(): Boolean {
+        return if (presenter.state.selectedSource != null) {
+            presenter.deselectSource()
+            true
+        } else {
+            super.handleBack()
+        }
+    }
+
+    fun render(state: ViewState) {
+        if (state.selectedSource == null) {
+            title = resources?.getString(R.string.label_migration)
+            if (adapter !is SourceAdapter) {
+                adapter = SourceAdapter(this)
+                migration_recycler.adapter = adapter
+            }
+            adapter?.updateDataSet(state.sourcesWithManga)
+        } else {
+            title = state.selectedSource.toString()
+            if (adapter !is MangaAdapter) {
+                adapter = MangaAdapter(this)
+                migration_recycler.adapter = adapter
+            }
+            adapter?.updateDataSet(state.mangaForSource)
+        }
+    }
+
+    fun renderIsReplacingManga(state: ViewState) {
+        if (state.isReplacingManga) {
+            if (router.getControllerWithTag(LOADING_DIALOG_TAG) == null) {
+                LoadingController().showDialog(router, LOADING_DIALOG_TAG)
+            }
+        } else {
+            router.popControllerWithTag(LOADING_DIALOG_TAG)
+        }
+    }
+
+    override fun onItemClick(position: Int): Boolean {
+        val item = adapter?.getItem(position) ?: return false
+
+        if (item is MangaItem) {
+            val controller = SearchController(item.manga)
+            controller.targetController = this
+
+            router.pushController(controller.withFadeTransaction())
+        } else if (item is SourceItem) {
+            presenter.setSelectedSource(item.source)
+        }
+        return false
+    }
+
+    override fun onSelectClick(position: Int) {
+        onItemClick(position)
+    }
+
+    fun migrateManga(prevManga: Manga, manga: Manga) {
+        presenter.migrateManga(prevManga, manga, replace = true)
+    }
+
+    fun copyManga(prevManga: Manga, manga: Manga) {
+        presenter.migrateManga(prevManga, manga, replace = false)
+    }
+
+    class LoadingController : DialogController() {
+
+        override fun onCreateDialog(savedViewState: Bundle?): Dialog {
+            return MaterialDialog.Builder(activity!!)
+                    .progress(true, 0)
+                    .content(R.string.migrating)
+                    .cancelable(false)
+                    .build()
+        }
+    }
+
+    companion object {
+        const val LOADING_DIALOG_TAG = "LoadingDialog"
+    }
+
+}

+ 140 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt

@@ -0,0 +1,140 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import android.os.Bundle
+import com.jakewharton.rxrelay.BehaviorRelay
+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.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.preference.getOrDefault
+import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceManager
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
+import eu.kanade.tachiyomi.util.combineLatest
+import eu.kanade.tachiyomi.util.syncChaptersWithSource
+import rx.Observable
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class MigrationPresenter(
+        private val sourceManager: SourceManager = Injekt.get(),
+        private val db: DatabaseHelper = Injekt.get(),
+        private val preferences: PreferencesHelper = Injekt.get()
+) : BasePresenter<MigrationController>() {
+
+    var state = ViewState()
+        private set(value) {
+            field = value
+            stateRelay.call(value)
+        }
+
+    private val stateRelay = BehaviorRelay.create(state)
+
+    override fun onCreate(savedState: Bundle?) {
+        super.onCreate(savedState)
+
+        db.getLibraryMangas()
+                .asRxObservable()
+                .observeOn(AndroidSchedulers.mainThread())
+                .doOnNext { state = state.copy(sourcesWithManga = findSourcesWithManga(it)) }
+                .combineLatest(stateRelay.map { it.selectedSource }
+                        .distinctUntilChanged(),
+                        { library, source -> library to source })
+                .filter { (_, source) -> source != null }
+                .observeOn(Schedulers.io())
+                .map { (library, source) -> libraryToMigrationItem(library, source!!.id) }
+                .observeOn(AndroidSchedulers.mainThread())
+                .doOnNext { state = state.copy(mangaForSource = it) }
+                .subscribe()
+
+        stateRelay
+                // Render the view when any field other than isReplacingManga changes
+                .distinctUntilChanged { t1, t2 -> t1.isReplacingManga != t2.isReplacingManga }
+                .subscribeLatestCache(MigrationController::render)
+
+        stateRelay.distinctUntilChanged { state -> state.isReplacingManga }
+                .subscribeLatestCache(MigrationController::renderIsReplacingManga)
+    }
+
+    fun setSelectedSource(source: Source) {
+        state = state.copy(selectedSource = source, mangaForSource = emptyList())
+    }
+
+    fun deselectSource() {
+        state = state.copy(selectedSource = null, mangaForSource = emptyList())
+    }
+
+    private fun findSourcesWithManga(library: List<Manga>): List<SourceItem> {
+        val header = SelectionHeader()
+        return library.map { it.source }.toSet()
+                .mapNotNull { if (it != LocalSource.ID) sourceManager.get(it) else null }
+                .map { SourceItem(it, header) }
+    }
+
+    private fun libraryToMigrationItem(library: List<Manga>, sourceId: Long): List<MangaItem> {
+        return library.filter { it.source == sourceId }.map(::MangaItem)
+    }
+
+    fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean) {
+        val source = sourceManager.get(manga.source) ?: return
+
+        state = state.copy(isReplacingManga = true)
+
+        Observable.defer { source.fetchChapterList(manga) }
+                .doOnNext { migrateMangaInternal(source, it, prevManga, manga, replace) }
+                .subscribeOn(Schedulers.io())
+                .observeOn(AndroidSchedulers.mainThread())
+                .doOnUnsubscribe { state = state.copy(isReplacingManga = false) }
+                .subscribe()
+    }
+
+    private fun migrateMangaInternal(source: Source, sourceChapters: List<SChapter>,
+                                     prevManga: Manga, manga: Manga, replace: Boolean) {
+
+        db.inTransaction {
+            // Update chapters read
+            if (preferences.migrateChapters().getOrDefault()) {
+                syncChaptersWithSource(db, sourceChapters, manga, source)
+
+                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 (preferences.migrateCategories().getOrDefault()) {
+                val categories = db.getCategoriesForManga(prevManga).executeAsBlocking()
+                val mangaCategories = categories.map { MangaCategory.create(manga, it) }
+                db.setMangaCategories(mangaCategories, listOf(manga))
+            }
+            // Update track
+            if (preferences.migrateTracks().getOrDefault()) {
+                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()
+        }
+    }
+}

+ 108 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchController.kt

@@ -0,0 +1,108 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import android.app.Dialog
+import android.os.Bundle
+import com.afollestad.materialdialogs.MaterialDialog
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.preference.getOrDefault
+import eu.kanade.tachiyomi.ui.base.controller.DialogController
+import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
+import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter
+import uy.kohesive.injekt.injectLazy
+
+class SearchController(
+        private var manga: Manga? = null
+) : CatalogueSearchController(manga?.title) {
+
+    private var newManga: Manga? = null
+
+    override fun createPresenter(): CatalogueSearchPresenter {
+        return SearchPresenter(initialQuery, manga!!)
+    }
+
+    override fun onSaveInstanceState(outState: Bundle) {
+        outState.putSerializable(::manga.name, manga)
+        outState.putSerializable(::newManga.name, newManga)
+        super.onSaveInstanceState(outState)
+    }
+
+    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+        super.onRestoreInstanceState(savedInstanceState)
+        manga = savedInstanceState.getSerializable(::manga.name) as? Manga
+        newManga = savedInstanceState.getSerializable(::newManga.name) as? Manga
+    }
+
+    fun migrateManga() {
+        val target = targetController as? MigrationController ?: return
+        val manga = manga ?: return
+        val newManga = newManga ?: return
+
+        router.popController(this)
+        target.migrateManga(manga, newManga)
+    }
+
+    fun copyManga() {
+        val target = targetController as? MigrationController ?: return
+        val manga = manga ?: return
+        val newManga = newManga ?: return
+
+        router.popController(this)
+        target.copyManga(manga, newManga)
+    }
+
+    override fun onMangaClick(manga: Manga) {
+        newManga = manga
+        val dialog = MigrationDialog()
+        dialog.targetController = this
+        dialog.showDialog(router)
+    }
+
+    class MigrationDialog : DialogController() {
+
+        private val preferences: PreferencesHelper by injectLazy()
+
+        override fun onCreateDialog(savedViewState: Bundle?): Dialog {
+            val optionTitles = arrayOf(
+                    R.string.chapters,
+                    R.string.categories,
+                    R.string.track
+            )
+
+            val optionPrefs = arrayOf(
+                    preferences.migrateChapters(),
+                    preferences.migrateCategories(),
+                    preferences.migrateTracks()
+            )
+
+            val preselected = optionPrefs.mapIndexedNotNull { index, preference ->
+                if (preference.getOrDefault()) index else null
+            }
+
+            return MaterialDialog.Builder(activity!!)
+                    .content(R.string.migration_dialog_what_to_include)
+                    .items(optionTitles.map { resources?.getString(it) })
+                    .alwaysCallMultiChoiceCallback()
+                    .itemsCallbackMultiChoice(preselected.toTypedArray(), { _, positions, _ ->
+                        // Save current settings for the next time
+                        optionPrefs.forEachIndexed { index, preference ->
+                            preference.set(index in positions)
+                        }
+                        true
+                    })
+                    .positiveText(R.string.migrate)
+                    .negativeText(R.string.copy)
+                    .neutralText(android.R.string.cancel)
+                    .onPositive { _, _ ->
+                        (targetController as? SearchController)?.migrateManga()
+                    }
+                    .onNegative { _, _ ->
+                        (targetController as? SearchController)?.copyManga()
+                    }
+                    .build()
+        }
+
+    }
+
+}

+ 17 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchPresenter.kt

@@ -0,0 +1,17 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter
+
+class SearchPresenter(
+        initialQuery: String? = "",
+        private val manga: Manga
+) : CatalogueSearchPresenter(initialQuery) {
+
+    override fun getEnabledSources(): List<CatalogueSource> {
+        // Filter out the source of the selected manga
+        return super.getEnabledSources()
+                .filterNot { it.id == manga.source }
+    }
+}

+ 50 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/SelectionHeader.kt

@@ -0,0 +1,50 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import android.view.View
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.AbstractHeaderItem
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
+import kotlinx.android.synthetic.main.catalogue_main_controller_card.*
+
+/**
+ * Item that contains the selection header.
+ */
+class SelectionHeader : AbstractHeaderItem<SelectionHeader.Holder>() {
+
+    /**
+     * Returns the layout resource of this item.
+     */
+    override fun getLayoutRes(): Int {
+        return R.layout.catalogue_main_controller_card
+    }
+
+    /**
+     * Creates a new view holder for this item.
+     */
+    override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): Holder {
+        return SelectionHeader.Holder(view, adapter)
+    }
+
+    /**
+     * Binds this item to the given view holder.
+     */
+    override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder,
+                                position: Int, payloads: List<Any?>?) {
+        // Intentionally empty
+    }
+
+    class Holder(view: View, adapter: FlexibleAdapter<*>) : BaseFlexibleViewHolder(view, adapter) {
+        init {
+            title.text = "Please select a source to migrate from"
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        return other is SelectionHeader
+    }
+
+    override fun hashCode(): Int {
+        return 0
+    }
+}

+ 42 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt

@@ -0,0 +1,42 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.util.getResourceColor
+
+/**
+ * Adapter that holds the catalogue cards.
+ *
+ * @param controller instance of [MigrationController].
+ */
+class SourceAdapter(val controller: MigrationController) :
+        FlexibleAdapter<IFlexible<*>>(null, controller, true) {
+
+    val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card)
+
+    private var items: List<IFlexible<*>>? = null
+
+    init {
+        setDisplayHeadersAtStartUp(true)
+    }
+
+    /**
+     * Listener for browse item clicks.
+     */
+    val selectClickListener: OnSelectClickListener? = controller
+
+    /**
+     * Listener which should be called when user clicks select.
+     */
+    interface OnSelectClickListener {
+        fun onSelectClick(position: Int)
+    }
+
+    override fun updateDataSet(items: MutableList<IFlexible<*>>?) {
+        if (this.items !== items) {
+            this.items = items
+            super.updateDataSet(items)
+        }
+    }
+}

+ 43 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt

@@ -0,0 +1,43 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import android.view.View
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
+import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder
+import eu.kanade.tachiyomi.util.getRound
+import eu.kanade.tachiyomi.util.gone
+import io.github.mthli.slice.Slice
+import kotlinx.android.synthetic.main.catalogue_main_controller_card_item.*
+
+class SourceHolder(view: View, override val adapter: SourceAdapter) :
+        BaseFlexibleViewHolder(view, adapter),
+        SlicedHolder {
+
+    override val slice = Slice(card).apply {
+        setColor(adapter.cardBackground)
+    }
+
+    override val viewToSlice: View
+        get() = card
+
+    init {
+        source_latest.gone()
+        source_browse.setText(R.string.select)
+        source_browse.setOnClickListener {
+            adapter.selectClickListener?.onSelectClick(adapterPosition)
+        }
+    }
+
+    fun bind(item: SourceItem) {
+        val source = item.source
+        setCardEdges(item)
+
+        // Set source name
+        title.text = source.name
+
+        // Set circle letter image.
+        itemView.post {
+            image.setImageDrawable(image.getRound(source.name.take(1).toUpperCase(),false))
+        }
+    }
+}

+ 41 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceItem.kt

@@ -0,0 +1,41 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import android.view.View
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.AbstractSectionableItem
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.Source
+
+/**
+ * Item that contains source information.
+ *
+ * @param source Instance of [Source] containing source information.
+ * @param header The header for this item.
+ */
+data class SourceItem(val source: Source, val header: SelectionHeader? = null) :
+        AbstractSectionableItem<SourceHolder, SelectionHeader>(header) {
+
+    /**
+     * Returns the layout resource of this item.
+     */
+    override fun getLayoutRes(): Int {
+        return R.layout.catalogue_main_controller_card_item
+    }
+
+    /**
+     * Creates a new view holder for this item.
+     */
+    override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): SourceHolder {
+        return SourceHolder(view, adapter as SourceAdapter)
+    }
+
+    /**
+     * Binds this item to the given view holder.
+     */
+    override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: SourceHolder,
+                                position: Int, payloads: List<Any?>?) {
+
+        holder.bind(this)
+    }
+
+}

+ 10 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/migration/ViewState.kt

@@ -0,0 +1,10 @@
+package eu.kanade.tachiyomi.ui.migration
+
+import eu.kanade.tachiyomi.source.Source
+
+data class ViewState(
+        val selectedSource: Source? = null,
+        val mangaForSource: List<MangaItem> = emptyList(),
+        val sourcesWithManga: List<SourceItem> = emptyList(),
+        val isReplacingManga: Boolean = false
+)

+ 6 - 0
app/src/main/res/layout/migration_controller.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.v7.widget.RecyclerView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/migration_recycler"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"/>

+ 5 - 0
app/src/main/res/menu/library.xml

@@ -27,4 +27,9 @@
         android:title="@string/action_edit_categories"
         app:showAsAction="never"/>
 
+    <item
+        android:id="@+id/action_source_migration"
+        android:title="Source migration"
+        app:showAsAction="never"/>
+
 </menu>

+ 11 - 0
app/src/main/res/menu/migration.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/action_change_source"
+        android:icon="@drawable/ic_filter_list_white_24dp"
+        android:title="@string/action_filter"
+        app:showAsAction="always"/>
+
+</menu>

+ 9 - 1
app/src/main/res/values/strings.xml

@@ -20,7 +20,7 @@
     <string name="label_categories">Categories</string>
     <string name="label_selected">Selected: %1$d</string>
     <string name="label_backup">Backup</string>
-
+    <string name="label_migration">Source migration</string>
 
     <!-- Actions -->
     <string name="action_settings">Settings</string>
@@ -390,6 +390,14 @@
     <!-- Recent manga fragment -->
     <string name="recent_manga_source">%1$s - Ch.%2$s</string>
 
+    <!-- Source migration screen -->
+    <string name="migration_info">Tap to select the source to migrate from</string>
+    <string name="migration_dialog_what_to_include">Select data to include</string>
+    <string name="select">Select</string>
+    <string name="migrate">Migrate</string>
+    <string name="copy">Copy</string>
+    <string name="migrating">Migrating…</string>
+
     <!-- Downloads activity and service -->
     <string name="download_queue_error">An error occurred while downloading chapters. You can try again in the downloads section</string>