diff --git a/app/build.gradle b/app/build.gradle index 8818757d7d..5ecd3a5878 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -243,6 +243,9 @@ dependencies { final coroutines_version = '1.3.2' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + + // Text distance + implementation 'info.debatty:java-string-similarity:1.2.1' } buildscript { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt index 711cefbcb3..815a6baee0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt @@ -1,18 +1,37 @@ package eu.kanade.tachiyomi.data.database -import androidx.sqlite.db.SupportSQLiteOpenHelper import android.content.Context +import androidx.sqlite.db.SupportSQLiteOpenHelper import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite -import eu.kanade.tachiyomi.data.database.mappers.* -import eu.kanade.tachiyomi.data.database.models.* -import eu.kanade.tachiyomi.data.database.queries.* +import eu.kanade.tachiyomi.data.database.mappers.CategoryTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.ChapterTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.HistoryTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.MangaCategoryTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.MangaTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.SearchMetadataTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.TrackTypeMapping +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.History +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.data.database.models.SearchMetadata +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.queries.CategoryQueries +import eu.kanade.tachiyomi.data.database.queries.ChapterQueries +import eu.kanade.tachiyomi.data.database.queries.HistoryQueries +import eu.kanade.tachiyomi.data.database.queries.MangaCategoryQueries +import eu.kanade.tachiyomi.data.database.queries.MangaQueries +import eu.kanade.tachiyomi.data.database.queries.SearchMetadataQueries +import eu.kanade.tachiyomi.data.database.queries.TrackQueries import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory /** * This class provides operations to manage the database through its interfaces. */ open class DatabaseHelper(context: Context) -: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries { +: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, + HistoryQueries, SearchMetadataQueries { private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context) .name(DbOpenCallback.DATABASE_NAME) @@ -26,6 +45,7 @@ open class DatabaseHelper(context: Context) .addTypeMapping(Track::class.java, TrackTypeMapping()) .addTypeMapping(Category::class.java, CategoryTypeMapping()) .addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping()) + .addTypeMapping(SearchMetadata::class.java, SearchMetadataTypeMapping()) .addTypeMapping(History::class.java, HistoryTypeMapping()) .build() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/SearchMetadataTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/SearchMetadataTypeMapping.kt new file mode 100644 index 0000000000..25df012756 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/SearchMetadataTypeMapping.kt @@ -0,0 +1,66 @@ +package eu.kanade.tachiyomi.data.database.mappers + + +import android.content.ContentValues +import android.database.Cursor +import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping +import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver +import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver +import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver +import com.pushtorefresh.storio.sqlite.queries.DeleteQuery +import com.pushtorefresh.storio.sqlite.queries.InsertQuery +import com.pushtorefresh.storio.sqlite.queries.UpdateQuery +import eu.kanade.tachiyomi.data.database.models.SearchMetadata +import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable.COL_EXTRA +import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable.COL_EXTRA_VERSION +import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable.COL_INDEXED_EXTRA +import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable.COL_MANGA_ID +import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable.COL_UPLOADER +import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable.TABLE + +class SearchMetadataTypeMapping : SQLiteTypeMapping( + SearchMetadataPutResolver(), + SearchMetadataGetResolver(), + SearchMetadataDeleteResolver() +) + +class SearchMetadataPutResolver : DefaultPutResolver() { + + override fun mapToInsertQuery(obj: SearchMetadata) = InsertQuery.builder() + .table(TABLE) + .build() + + override fun mapToUpdateQuery(obj: SearchMetadata) = UpdateQuery.builder() + .table(TABLE) + .where("$COL_MANGA_ID = ?") + .whereArgs(obj.mangaId) + .build() + + override fun mapToContentValues(obj: SearchMetadata) = ContentValues(5).apply { + put(COL_MANGA_ID, obj.mangaId) + put(COL_UPLOADER, obj.uploader) + put(COL_EXTRA, obj.extra) + put(COL_INDEXED_EXTRA, obj.indexedExtra) + put(COL_EXTRA_VERSION, obj.extraVersion) + } +} + +class SearchMetadataGetResolver : DefaultGetResolver() { + + override fun mapFromCursor(cursor: Cursor): SearchMetadata = SearchMetadata( + mangaId = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)), + uploader = cursor.getString(cursor.getColumnIndex(COL_UPLOADER)), + extra = cursor.getString(cursor.getColumnIndex(COL_EXTRA)), + indexedExtra = cursor.getString(cursor.getColumnIndex(COL_INDEXED_EXTRA)), + extraVersion = cursor.getInt(cursor.getColumnIndex(COL_EXTRA_VERSION)) + ) +} + +class SearchMetadataDeleteResolver : DefaultDeleteResolver() { + + override fun mapToDeleteQuery(obj: SearchMetadata) = DeleteQuery.builder() + .table(TABLE) + .where("$COL_MANGA_ID = ?") + .whereArgs(obj.mangaId) + .build() +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/SearchMetadata.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/SearchMetadata.kt new file mode 100644 index 0000000000..64b1476378 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/SearchMetadata.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.data.database.models + +data class SearchMetadata( + // Manga ID this gallery is linked to + val mangaId: Long, + + // Gallery uploader + val uploader: String?, + + // Extra data attached to this metadata, in JSON format + val extra: String, + + // Indexed extra data attached to this metadata + val indexedExtra: String?, + + // The version of this metadata's extra. Used to track changes to the 'extra' field's schema + val extraVersion: Int +) { + // Transient information attached to this piece of metadata, useful for caching + var transientCache: Map? = null +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/SearchMetadataQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/SearchMetadataQueries.kt new file mode 100644 index 0000000000..e6b67ea057 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/SearchMetadataQueries.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.data.database.queries + + +import com.pushtorefresh.storio.sqlite.queries.DeleteQuery +import com.pushtorefresh.storio.sqlite.queries.Query +import eu.kanade.tachiyomi.data.database.DbProvider +import eu.kanade.tachiyomi.data.database.models.SearchMetadata +import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable + +interface SearchMetadataQueries : DbProvider { + + fun getSearchMetadataForManga(mangaId: Long) = db.get() + .`object`(SearchMetadata::class.java) + .withQuery(Query.builder() + .table(SearchMetadataTable.TABLE) + .where("${SearchMetadataTable.COL_MANGA_ID} = ?") + .whereArgs(mangaId) + .build()) + .prepare() + + fun getSearchMetadata() = db.get() + .listOfObjects(SearchMetadata::class.java) + .withQuery(Query.builder() + .table(SearchMetadataTable.TABLE) + .build()) + .prepare() + + fun getSearchMetadataByIndexedExtra(extra: String) = db.get() + .listOfObjects(SearchMetadata::class.java) + .withQuery(Query.builder() + .table(SearchMetadataTable.TABLE) + .where("${SearchMetadataTable.COL_INDEXED_EXTRA} = ?") + .whereArgs(extra) + .build()) + .prepare() + + fun insertSearchMetadata(metadata: SearchMetadata) = db.put().`object`(metadata).prepare() + + fun deleteSearchMetadata(metadata: SearchMetadata) = db.delete().`object`(metadata).prepare() + + fun deleteAllSearchMetadata() = db.delete().byQuery(DeleteQuery.builder() + .table(SearchMetadataTable.TABLE) + .build()) + .prepare() +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SearchMetadataTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SearchMetadataTable.kt new file mode 100644 index 0000000000..9bcd2895d0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SearchMetadataTable.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.data.database.tables + + +object SearchMetadataTable { + const val TABLE = "search_metadata" + + const val COL_MANGA_ID = "manga_id" + + const val COL_UPLOADER = "uploader" + + const val COL_EXTRA = "extra" + + const val COL_INDEXED_EXTRA = "indexed_extra" + + const val COL_EXTRA_VERSION = "extra_version" + + // Insane foreign, primary key to avoid touch manga table + val createTableQuery: String + get() = """CREATE TABLE $TABLE( + $COL_MANGA_ID INTEGER NOT NULL PRIMARY KEY, + $COL_UPLOADER TEXT, + $COL_EXTRA TEXT NOT NULL, + $COL_INDEXED_EXTRA TEXT, + $COL_EXTRA_VERSION INT NOT NULL, + FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) + ON DELETE CASCADE + )""" + + val createUploaderIndexQuery: String + get() = "CREATE INDEX ${TABLE}_${COL_UPLOADER}_index ON $TABLE($COL_UPLOADER)" + + val createIndexedExtraIndexQuery: String + get() = "CREATE INDEX ${TABLE}_${COL_INDEXED_EXTRA}_index ON $TABLE($COL_INDEXED_EXTRA)" +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/smartsearch/SmartSearchEngine.kt b/app/src/main/java/eu/kanade/tachiyomi/smartsearch/SmartSearchEngine.kt new file mode 100644 index 0000000000..25added1da --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/smartsearch/SmartSearchEngine.kt @@ -0,0 +1,192 @@ +package eu.kanade.tachiyomi.smartsearch + + +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.ui.smartsearch.SmartSearchPresenter +import eu.kanade.tachiyomi.util.await +import info.debatty.java.stringsimilarity.NormalizedLevenshtein +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.supervisorScope +import rx.schedulers.Schedulers +import uy.kohesive.injekt.injectLazy +import kotlin.coroutines.CoroutineContext + +class SmartSearchEngine(parentContext: CoroutineContext, + val extraSearchParams: String? = null): CoroutineScope { + override val coroutineContext: CoroutineContext = parentContext + Job() + Dispatchers.Default + + private val db: DatabaseHelper by injectLazy() + + private val normalizedLevenshtein = NormalizedLevenshtein() + + suspend fun smartSearch(source: CatalogueSource, title: String): SManga? { + val cleanedTitle = cleanSmartSearchTitle(title) + + val queries = getSmartSearchQueries(cleanedTitle) + + val eligibleManga = supervisorScope { + queries.map { query -> + async(Dispatchers.Default) { + val builtQuery = if(extraSearchParams != null) { + "$query ${extraSearchParams.trim()}" + } else query + + val searchResults = source.fetchSearchManga(1, builtQuery, FilterList()) + .toSingle().await(Schedulers.io()) + + searchResults.mangas.map { + val cleanedMangaTitle = cleanSmartSearchTitle(it.title) + val normalizedDistance = normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle) + SmartSearchPresenter.SearchEntry(it, normalizedDistance) + }.filter { (_, normalizedDistance) -> + normalizedDistance >= MIN_SMART_ELIGIBLE_THRESHOLD + } + } + }.flatMap { it.await() } + } + + return eligibleManga.maxBy { it.dist }?.manga + } + + suspend fun normalSearch(source: CatalogueSource, title: String): SManga? { + val eligibleManga = supervisorScope { + val searchQuery = if(extraSearchParams != null) { + "$title ${extraSearchParams.trim()}" + } else title + val searchResults = source.fetchSearchManga(1, searchQuery, FilterList()).toSingle().await(Schedulers.io()) + + searchResults.mangas.map { + val normalizedDistance = normalizedLevenshtein.similarity(title, it.title) + SmartSearchPresenter.SearchEntry(it, normalizedDistance) + }.filter { (_, normalizedDistance) -> + normalizedDistance >= MIN_NORMAL_ELIGIBLE_THRESHOLD + } + } + + return eligibleManga.maxBy { it.dist }?.manga + } + + private fun getSmartSearchQueries(cleanedTitle: String): List { + val splitCleanedTitle = cleanedTitle.split(" ") + val splitSortedByLargest = splitCleanedTitle.sortedByDescending { it.length } + + if(splitCleanedTitle.isEmpty()) { + return emptyList() + } + + // Search cleaned title + // Search two largest words + // Search largest word + // Search first two words + // Search first word + + val searchQueries = listOf( + listOf(cleanedTitle), + splitSortedByLargest.take(2), + splitSortedByLargest.take(1), + splitCleanedTitle.take(2), + splitCleanedTitle.take(1) + ) + + return searchQueries.map { + it.joinToString(" ").trim() + }.distinct() + } + + private fun cleanSmartSearchTitle(title: String): String { + val preTitle = title.toLowerCase() + + // Remove text in brackets + var cleanedTitle = removeTextInBrackets(preTitle, true) + if(cleanedTitle.length <= 5) { // Title is suspiciously short, try parsing it backwards + cleanedTitle = removeTextInBrackets(preTitle, false) + } + + // Strip non-special characters + cleanedTitle = cleanedTitle.replace(titleRegex, " ") + + // Strip splitters and consecutive spaces + cleanedTitle = cleanedTitle.trim().replace(" - ", " ").replace(consecutiveSpacesRegex, " ").trim() + + return cleanedTitle + } + + private fun removeTextInBrackets(text: String, readForward: Boolean): String { + val bracketPairs = listOf( + '(' to ')', + '[' to ']', + '<' to '>', + '{' to '}' + ) + var openingBracketPairs = bracketPairs.mapIndexed { index, (opening, _) -> + opening to index + }.toMap() + var closingBracketPairs = bracketPairs.mapIndexed { index, (_, closing) -> + closing to index + }.toMap() + + // Reverse pairs if reading backwards + if(!readForward) { + val tmp = openingBracketPairs + openingBracketPairs = closingBracketPairs + closingBracketPairs = tmp + } + + val depthPairs = bracketPairs.map { 0 }.toMutableList() + + val result = StringBuilder() + for(c in if(readForward) text else text.reversed()) { + val openingBracketDepthIndex = openingBracketPairs[c] + if(openingBracketDepthIndex != null) { + depthPairs[openingBracketDepthIndex]++ + } else { + val closingBracketDepthIndex = closingBracketPairs[c] + if(closingBracketDepthIndex != null) { + depthPairs[closingBracketDepthIndex]-- + } else { + if(depthPairs.all { it <= 0 }) { + result.append(c) + } else { + // In brackets, do not append to result + } + } + } + } + + return result.toString() + } + + /** + * Returns a manga from the database for the given manga from network. It creates a new entry + * if the manga is not yet in the database. + * + * @param sManga the manga from the source. + * @return a manga from the database. + */ + suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { + var localManga = db.getManga(sManga.url, sourceId).executeAsBlocking() + if (localManga == null) { + val newManga = Manga.create(sManga.url, sManga.title, sourceId) + newManga.copyFrom(sManga) + val result = db.insertManga(newManga).executeAsBlocking() + newManga.id = result.insertedId() + localManga = newManga + } + return localManga + } + + companion object { + const val MIN_SMART_ELIGIBLE_THRESHOLD = 0.4 + const val MIN_NORMAL_ELIGIBLE_THRESHOLD = 0.4 + + private val titleRegex = Regex("[^a-zA-Z0-9- ]") + private val consecutiveSpacesRegex = Regex(" +") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt index 0097660e91..e8a9b0b02b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.catalogue import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -28,6 +29,7 @@ import eu.kanade.tachiyomi.ui.catalogue.latest.LatestUpdatesController import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController import eu.kanade.tachiyomi.util.RecyclerWindowInsetsListener import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog +import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.catalogue_main_controller.* import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -239,4 +241,7 @@ class CatalogueController : NucleusController(), } class SettingsSourcesFadeChangeHandler : FadeChangeHandler() + + @Parcelize + data class SmartSearchConfig(val origTitle: String, val origMangaId: Long) : Parcelable } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCatalogueController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCatalogueController.kt index fecb253e48..c0e92b17ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCatalogueController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCatalogueController.kt @@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.catalogue.CatalogueController import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.library.HeightTopWindowInsetsListener import eu.kanade.tachiyomi.ui.main.MainActivity @@ -68,6 +69,18 @@ open class BrowseCatalogueController(bundle: Bundle) : FlexibleAdapter.EndlessScrollListener, ChangeMangaCategoriesDialog.Listener { + constructor(source: CatalogueSource, + searchQuery: String? = null, + smartSearchConfig: CatalogueController.SmartSearchConfig? = null) : this(Bundle().apply { + putLong(SOURCE_ID_KEY, source.id) + + if(searchQuery != null) + putString(SEARCH_QUERY_KEY, searchQuery) + + if (smartSearchConfig != null) + putParcelable(SMART_SEARCH_CONFIG_KEY, smartSearchConfig) + }) + constructor(source: CatalogueSource) : this(Bundle().apply { putLong(SOURCE_ID_KEY, source.id) }) @@ -579,6 +592,9 @@ open class BrowseCatalogueController(bundle: Bundle) : protected companion object { const val SOURCE_ID_KEY = "sourceId" + + const val SEARCH_QUERY_KEY = "searchQuery" + const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index 567530ea87..600b2223c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -45,6 +45,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.migration.MigrationController import eu.kanade.tachiyomi.ui.migration.MigrationInterface import eu.kanade.tachiyomi.ui.migration.SearchController +import eu.kanade.tachiyomi.ui.migration.manga.design.MigrationDesignController import eu.kanade.tachiyomi.util.doOnApplyWindowInsets import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.util.marginBottom @@ -467,7 +468,13 @@ class LibraryController( selectAllRelay.call(it) } } - R.id.action_migrate -> startMangaMigration() + R.id.action_migrate -> { + router.pushController( + MigrationDesignController.create( + selectedMangas.mapNotNull { it.id } + ).withFadeTransaction()) + destroyActionModeIfNeeded() + } //startMangaMigration() R.id.action_hide_title -> { val showAll = (selectedMangas.filter { (it as? LibraryManga)?.hide_title == true } ).size == selectedMangas.size diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index f61e3e64cc..218d590a10 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -1,7 +1,6 @@ package eu.kanade.tachiyomi.ui.manga import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.app.Activity import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -27,6 +26,7 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.controller.RxController import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe +import eu.kanade.tachiyomi.ui.catalogue.CatalogueController import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController import eu.kanade.tachiyomi.ui.manga.track.TrackController @@ -40,6 +40,21 @@ import java.util.Date class MangaController : RxController, TabbedController { + constructor(manga: Manga?, + fromCatalogue: Boolean = false, + smartSearchConfig: CatalogueController.SmartSearchConfig? = null, + update: Boolean = false) : super(Bundle().apply { + putLong(MANGA_EXTRA, manga?.id ?: 0) + putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue) + putParcelable(SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig) + putBoolean(UPDATE_EXTRA, update) + }) { + this.manga = manga + if (manga != null) { + source = Injekt.get().getOrStub(manga.source) + } + } + constructor(manga: Manga?, fromCatalogue: Boolean = false, fromExtension: Boolean = false) : super (Bundle() @@ -213,6 +228,10 @@ class MangaController : RxController, TabbedController { } companion object { + + const val UPDATE_EXTRA = "update" + const val SMART_SEARCH_CONFIG_EXTRA = "smartSearchConfig" + const val FROM_CATALOGUE_EXTRA = "from_catalogue" const val MANGA_EXTRA = "manga" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MetadataFetchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MetadataFetchDialog.kt new file mode 100644 index 0000000000..db85772272 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MetadataFetchDialog.kt @@ -0,0 +1,159 @@ +package eu.kanade.tachiyomi.ui.migration + +import android.app.Activity +import android.content.pm.ActivityInfo +import android.text.Html +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.SourceManager +import timber.log.Timber +import uy.kohesive.injekt.injectLazy +import kotlin.concurrent.thread + +class MetadataFetchDialog { + + val db: DatabaseHelper by injectLazy() + + val sourceManager: SourceManager by injectLazy() + + val preferenceHelper: PreferencesHelper by injectLazy() + + fun show(context: Activity) { + //Too lazy to actually deal with orientation changes + context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR + + var running = true + + val progressDialog = MaterialDialog.Builder(context) + .title("Fetching library metadata") + .content("Preparing library") + .progress(false, 0, true) + .negativeText("Stop") + .onNegative { dialog, which -> + running = false + dialog.dismiss() + notifyMigrationStopped(context) + } + .cancelable(false) + .canceledOnTouchOutside(false) + .show() + + thread { + val libraryMangas = db.getLibraryMangas().executeAsBlocking() + //.filter { isLewdSource(it.source) } + .distinctBy { it.id } + + context.runOnUiThread { + progressDialog.maxProgress = libraryMangas.size + } + + val mangaWithMissingMetadata = libraryMangas + .filterIndexed { index, libraryManga -> + if(index % 100 == 0) { + context.runOnUiThread { + progressDialog.setContent("[Stage 1/2] Scanning for missing metadata...") + progressDialog.setProgress(index + 1) + } + } + db.getSearchMetadataForManga(libraryManga.id!!).executeAsBlocking() == null + } + .toList() + + context.runOnUiThread { + progressDialog.maxProgress = mangaWithMissingMetadata.size + } + + //Actual metadata fetch code + for((i, manga) in mangaWithMissingMetadata.withIndex()) { + if(!running) break + context.runOnUiThread { + progressDialog.setContent("[Stage 2/2] Processing: ${manga.title}") + progressDialog.setProgress(i + 1) + } + try { + val source = sourceManager.get(manga.source) + source?.let { + manga.copyFrom(it.fetchMangaDetails(manga).toBlocking().first()) + } + } catch (t: Throwable) { + Timber.e(t, "Could not migrate manga!") + } + } + + context.runOnUiThread { + // Ensure activity still exists before we do anything to the activity + if(!context.isDestroyed) { + progressDialog.dismiss() + + //Enable orientation changes again + context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR + + if (running) displayMigrationComplete(context) + } + } + } + } + + fun askMigration(activity: Activity, explicit: Boolean) { + var extra = "" + db.getLibraryMangas().asRxSingle().subscribe { + /*if(!explicit && it.none { isLewdSource(it.source) }) { + // Do not open dialog on startup if no manga + // Also do not check again + preferenceHelper.migrateLibraryAsked().set(true) + } else {*/ + activity.runOnUiThread { + MaterialDialog.Builder(activity) + .title("Fetch library metadata") + .content(Html.fromHtml("You need to fetch your library's metadata before tag searching in the library will function.

" + + "This process may take a long time depending on your library size and will also use up a significant amount of internet bandwidth but can be stopped and started whenever you wish.

" + + extra + + "This process can be done later if required.")) + .positiveText("Migrate") + .negativeText("Later") + .onPositive { _, _ -> show(activity) } + .onNegative { _, _ -> adviseMigrationLater(activity) } + //.onAny { _, _ -> preferenceHelper.migrateLibraryAsked().set(true) } + .cancelable(false) + .canceledOnTouchOutside(false) + .show() + } + //} + } + + } + + fun adviseMigrationLater(activity: Activity) { + MaterialDialog.Builder(activity) + .title("Metadata fetch canceled") + .content("Library metadata fetch has been canceled.\n\n" + + "You can run this operation later by going to: Settings > Advanced > Migrate library metadata") + .positiveText("Ok") + .cancelable(true) + .canceledOnTouchOutside(true) + .show() + } + + fun notifyMigrationStopped(activity: Activity) { + MaterialDialog.Builder(activity) + .title("Metadata fetch stopped") + .content("Library metadata fetch has been stopped.\n\n" + + "You can continue this operation later by going to: Settings > Advanced > Migrate library metadata") + .positiveText("Ok") + .cancelable(true) + .canceledOnTouchOutside(true) + .show() + } + + fun displayMigrationComplete(activity: Activity) { + MaterialDialog.Builder(activity) + .title("Migration complete") + .content("${activity.getString(R.string.app_name)} is now ready for use!") + .positiveText("Ok") + .cancelable(true) + .canceledOnTouchOutside(true) + .show() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt index 323b905e06..e99663cda3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt @@ -9,17 +9,27 @@ 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.DatabaseHelper 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 eu.kanade.tachiyomi.ui.migration.manga.design.MigrationDesignController import eu.kanade.tachiyomi.util.RecyclerWindowInsetsListener +import eu.kanade.tachiyomi.util.await +import eu.kanade.tachiyomi.util.launchUI import kotlinx.android.synthetic.main.migration_controller.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get class MigrationController : NucleusController(), FlexibleAdapter.OnItemClickListener, SourceAdapter.OnSelectClickListener, + SourceAdapter.OnAutoClickListener, MigrationInterface { private var adapter: FlexibleAdapter>? = null @@ -119,6 +129,19 @@ class MigrationController : NucleusController(), onItemClick(view, position) } + override fun onAutoClick(position: Int) { + val item = adapter?.getItem(position) as? SourceItem ?: return + + launchUI { + val manga = Injekt.get().getFavoriteMangas().asRxSingle().await( + Schedulers.io()) + val sourceMangas = manga.asSequence().filter { it.source == item.source.id }.map { it.id!! }.toList() + withContext(Dispatchers.Main) { + router.pushController(MigrationDesignController.create(sourceMangas).withFadeTransaction()) + } + } + } + override fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean): Manga? { presenter.migrateManga(prevManga, manga, replace) return null diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationFlags.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationFlags.kt index f47fd63d3a..2e9f3aa8d9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationFlags.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationFlags.kt @@ -4,9 +4,9 @@ import eu.kanade.tachiyomi.R object MigrationFlags { - private const val CHAPTERS = 0b001 - private const val CATEGORIES = 0b010 - private const val TRACK = 0b100 + const val CHAPTERS = 0b001 + const val CATEGORIES = 0b010 + const val TRACK = 0b100 private const val CHAPTERS2 = 0x1 private const val CATEGORIES2 = 0x2 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationStatus.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationStatus.kt new file mode 100644 index 0000000000..c360c3f08b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationStatus.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.ui.migration + +class MigrationStatus { + companion object { + val NOT_INITIALIZED = -1 + val COMPLETED = 0 + + //Migration process + val NOTIFY_USER = 1 + val OPEN_BACKUP_MENU = 2 + val PERFORM_BACKUP = 3 + val FINALIZE_MIGRATION = 4 + + val MAX_MIGRATION_STEPS = 2 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt index 86df353f5a..4546bdef7c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt @@ -33,6 +33,18 @@ class SourceAdapter(val controller: MigrationController) : fun onSelectClick(position: Int) } + /** + * Listener for auto item clicks. + */ + val autoClickListener: OnAutoClickListener? = controller + + /** + * Listener which should be called when user clicks select. + */ + interface OnAutoClickListener { + fun onAutoClick(position: Int) + } + override fun updateDataSet(items: MutableList>?) { if (this.items !== items) { this.items = items diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt index fd644e385c..9631c98383 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt @@ -5,7 +5,6 @@ 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.* @@ -21,11 +20,14 @@ class SourceHolder(view: View, override val adapter: SourceAdapter) : get() = card init { - source_latest.gone() + source_latest.text = "Auto" source_browse.setText(R.string.select) source_browse.setOnClickListener { adapter.selectClickListener?.onSelectClick(adapterPosition) } + source_latest.setOnClickListener { + adapter.autoClickListener?.onAutoClick(adapterPosition) + } } fun bind(item: SourceItem) { 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 new file mode 100644 index 0000000000..a824e35440 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationDesignController.kt @@ -0,0 +1,191 @@ +package eu.kanade.tachiyomi.ui.migration.manga.design + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.source.SourceManager +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.MigrationProcedureController +import eu.kanade.tachiyomi.util.gone +import eu.kanade.tachiyomi.util.visible +import exh.ui.migration.manga.process.MigrationProcedureConfig +import kotlinx.android.synthetic.main.migration_design_controller.* +import uy.kohesive.injekt.injectLazy + +class MigrationDesignController(bundle: Bundle? = null) : BaseController(bundle), FlexibleAdapter +.OnItemClickListener { + private val sourceManager: SourceManager by injectLazy() + private val prefs: PreferencesHelper by injectLazy() + + private var adapter: MigrationSourceAdapter? = null + + private val config: LongArray = args.getLongArray(MANGA_IDS_EXTRA) ?: LongArray(0) + + private var showingOptions = false + + override fun getTitle() = "Select target sources" + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.migration_design_controller, container, false) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + val ourAdapter = adapter ?: MigrationSourceAdapter( + getEnabledSources().map { MigrationSourceItem(it, true) }, + this + ) + adapter = ourAdapter + recycler.layoutManager = LinearLayoutManager(view.context) + recycler.setHasFixedSize(true) + recycler.adapter = ourAdapter + ourAdapter.itemTouchHelperCallback = null // Reset adapter touch adapter to fix drag after rotation + ourAdapter.isHandleDragEnabled = true + + migration_mode.setOnClickListener { + prioritize_chapter_count.toggle() + } + + fuzzy_search.setOnClickListener { + use_smart_search.toggle() + } + + copy_manga_desc.setOnClickListener { + copy_manga.toggle() + } + + extra_search_param_desc.setOnClickListener { + extra_search_param.toggle() + } + + prioritize_chapter_count.setOnCheckedChangeListener { _, b -> + updatePrioritizeChapterCount(b) + } + + extra_search_param.setOnCheckedChangeListener { _, b -> + updateOptionsState() + } + + updatePrioritizeChapterCount(prioritize_chapter_count.isChecked) + + updateOptionsState() + + begin_migration_btn.setOnClickListener { + if(!showingOptions) { + showingOptions = true + updateOptionsState() + return@setOnClickListener + } + + var flags = 0 + if(mig_chapters.isChecked) flags = flags or MigrationFlags.CHAPTERS + if(mig_categories.isChecked) flags = flags or MigrationFlags.CATEGORIES + if(mig_categories.isChecked) flags = flags or MigrationFlags.TRACK + + router.replaceTopController( + MigrationProcedureController.create( + MigrationProcedureConfig( + config.toList(), + ourAdapter.items.filter { + it.sourceEnabled + }.map { it.source.id }, + 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 + ) + ).withFadeTransaction()) + } + } + + fun updateOptionsState() { + if (showingOptions) { + begin_migration_btn.text = "Begin migration" + options_group.visible() + if(extra_search_param.isChecked) { + extra_search_param_text.visible() + } else { + extra_search_param_text.gone() + } + } else { + begin_migration_btn.text = "Next step" + options_group.gone() + extra_search_param_text.gone() + } + } + + override fun handleBack(): Boolean { + if(showingOptions) { + showingOptions = false + updateOptionsState() + return true + } + return super.handleBack() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + adapter?.onSaveInstanceState(outState) + } + + // TODO Still incorrect, why is this called before onViewCreated? + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + adapter?.onRestoreInstanceState(savedInstanceState) + } + + private fun updatePrioritizeChapterCount(migrationMode: Boolean) { + migration_mode.text = if(migrationMode) { + "Currently using the source with the most chapters and the above list to break ties (slow with many sources or smart search)" + } else { + "Currently using the first source in the list that has the manga" + } + } + + override fun onItemClick(view: View, position: Int): Boolean { + adapter?.getItem(position)?.let { + it.sourceEnabled = !it.sourceEnabled + } + adapter?.notifyItemChanged(position) + return false + } + + /** + * Returns a list of enabled sources ordered by language and name. + * + * @return list containing enabled sources. + */ + private fun getEnabledSources(): List { + val languages = prefs.enabledLanguages().getOrDefault() + val hiddenCatalogues = prefs.hiddenCatalogues().getOrDefault() + + return sourceManager.getCatalogueSources() + .filterIsInstance() + .filter { it.lang in languages } + .filterNot { it.id.toString() in hiddenCatalogues } + .sortedBy { "(${it.lang}) ${it.name}" } + } + + companion object { + private const val MANGA_IDS_EXTRA = "manga_ids" + + fun create(mangaIds: List): MigrationDesignController { + return MigrationDesignController(Bundle().apply { + putLongArray(MANGA_IDS_EXTRA, mangaIds.toLongArray()) + }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceAdapter.kt new file mode 100644 index 0000000000..6d01c6aa15 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceAdapter.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.ui.migration.manga.design + +import android.os.Bundle +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.source.SourceManager +import uy.kohesive.injekt.injectLazy + +class MigrationSourceAdapter(val items: List, + val controller: MigrationDesignController +): FlexibleAdapter( + items, + controller, + true +) { + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putParcelableArrayList(SELECTED_SOURCES_KEY, ArrayList(currentItems.map { + it.asParcelable() + })) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + val sourceManager:SourceManager by injectLazy() + savedInstanceState.getParcelableArrayList( + SELECTED_SOURCES_KEY + )?.let { + updateDataSet(it.map { MigrationSourceItem.fromParcelable(sourceManager, it) }) + } + + super.onRestoreInstanceState(savedInstanceState) + } + + companion object { + private const val SELECTED_SOURCES_KEY = "selected_sources" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceHolder.kt new file mode 100644 index 0000000000..209291dd3f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceHolder.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.ui.migration.manga.design + +import android.graphics.Paint.STRIKE_THRU_TEXT_FLAG +import android.view.View +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.util.getRound +import kotlinx.android.synthetic.main.migration_source_item.* + +class MigrationSourceHolder(view: View, val adapter: FlexibleAdapter): + BaseFlexibleViewHolder(view, adapter) { + init { + setDragHandleView(reorder) + } + + fun bind(source: HttpSource, sourceEnabled: Boolean) { + // Set capitalized title. + title.text = source.name.capitalize() + + // Update circle letter image. + itemView.post { + image.setImageDrawable(image.getRound(source.name.take(1).toUpperCase(),false)) + } + + if(sourceEnabled) { + title.alpha = 1.0f + image.alpha = 1.0f + title.paintFlags = title.paintFlags and STRIKE_THRU_TEXT_FLAG.inv() + } else { + title.alpha = DISABLED_ALPHA + image.alpha = DISABLED_ALPHA + title.paintFlags = title.paintFlags or STRIKE_THRU_TEXT_FLAG + } + } + + companion object { + private const val DISABLED_ALPHA = 0.3f + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceItem.kt new file mode 100644 index 0000000000..c01b40890e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/design/MigrationSourceItem.kt @@ -0,0 +1,72 @@ +package eu.kanade.tachiyomi.ui.migration.manga.design + +import android.os.Parcelable +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 +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.android.parcel.Parcelize + +class MigrationSourceItem(val source: HttpSource, var sourceEnabled: Boolean): AbstractFlexibleItem() { + override fun getLayoutRes() = R.layout.migration_source_item + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): MigrationSourceHolder { + return MigrationSourceHolder(view, adapter as MigrationSourceAdapter) + } + + /** + * Binds the given view holder with this item. + * + * @param adapter The adapter of this item. + * @param holder The holder to bind. + * @param position The position of this item in the adapter. + * @param payloads List of partial changes. + */ + override fun bindViewHolder(adapter: FlexibleAdapter>, + holder: MigrationSourceHolder, + position: Int, + payloads: List?) { + holder.bind(source, sourceEnabled) + } + + /** + * Returns true if this item is draggable. + */ + override fun isDraggable(): Boolean { + return true + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is MigrationSourceItem) { + return source.id == other.source.id + } + return false + } + + override fun hashCode(): Int { + return source.id.hashCode() + } + + @Parcelize + data class ParcelableSI(val sourceId: Long, val sourceEnabled: Boolean): Parcelable + + fun asParcelable(): ParcelableSI { + return ParcelableSI(source.id, sourceEnabled) + } + + companion object { + fun fromParcelable(sourceManager: SourceManager, si: ParcelableSI): MigrationSourceItem? { + val source = sourceManager.get(si.sourceId) as? HttpSource ?: return null + + return MigrationSourceItem( + source, + si.sourceEnabled + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/DeactivatableViewPager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/DeactivatableViewPager.kt new file mode 100644 index 0000000000..7da7c5a844 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/DeactivatableViewPager.kt @@ -0,0 +1,19 @@ +package eu.kanade.tachiyomi.ui.migration.manga.process + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.viewpager.widget.ViewPager + +class DeactivatableViewPager: ViewPager { + constructor(context: Context): super(context) + constructor(context: Context, attrs: AttributeSet): super(context, attrs) + + override fun onTouchEvent(event: MotionEvent): Boolean { + return !isEnabled || super.onTouchEvent(event) + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + return isEnabled && super.onInterceptTouchEvent(event) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..5f952e7d73 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigratingManga.kt @@ -0,0 +1,34 @@ +package exh.ui.migration.manga.process + +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.util.DeferredField +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlin.coroutines.CoroutineContext + +class MigratingManga(private val db: DatabaseHelper, + private val sourceManager: SourceManager, + val mangaId: Long, + parentContext: CoroutineContext) { + val searchResult = DeferredField() + + // + val progress = ConflatedBroadcastChannel(1 to 0) + + val migrationJob = parentContext + SupervisorJob() + Dispatchers.Default + + @Volatile + private var manga: Manga? = null + suspend fun manga(): Manga? { + if(manga == null) manga = db.getManga(mangaId).executeAsBlocking() + return manga + } + + suspend fun mangaSource(): Source { + return sourceManager.getOrStub(manga()?.source ?: -1) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..17a256883c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureAdapter.kt @@ -0,0 +1,281 @@ +package eu.kanade.tachiyomi.ui.migration.manga.process + +import android.view.View +import android.view.ViewGroup +import androidx.viewpager.widget.PagerAdapter +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.google.gson.Gson +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.database.models.MangaCategory +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.migration.MigrationFlags +import eu.kanade.tachiyomi.util.gone +import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.util.visible +import exh.ui.migration.manga.process.MigratingManga +import kotlinx.android.synthetic.main.migration_manga_card.view.* +import kotlinx.android.synthetic.main.migration_process_item.view.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uy.kohesive.injekt.injectLazy +import java.text.DateFormat +import java.text.DecimalFormat +import java.util.Date +import kotlin.coroutines.CoroutineContext + +class MigrationProcedureAdapter(val controller: MigrationProcedureController, + val migratingManga: List, + override val coroutineContext: CoroutineContext) : PagerAdapter(), CoroutineScope { + private val db: DatabaseHelper by injectLazy() + private val gson: Gson by injectLazy() + private val sourceManager: SourceManager by injectLazy() + + override fun isViewFromObject(p0: View, p1: Any): Boolean { + return p0 == p1 + } + + override fun getCount() = migratingManga.size + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + val item = migratingManga[position] + val view = container.inflate(R.layout.migration_process_item) + container.addView(view) + + view.skip_migration.setOnClickListener { + controller.nextMigration() + } + + val viewTag = ViewTag(coroutineContext) + view.tag = viewTag + view.setupView(viewTag, item) + + view.accept_migration.setOnClickListener { + viewTag.launch(Dispatchers.Main) { + view.migrating_frame.visible() + try { + withContext(Dispatchers.Default) { + performMigration(item) + } + controller.nextMigration() + } catch(e: Exception) { + controller.migrationFailure() + } + view.migrating_frame.gone() + } + } + + return view + } + + suspend fun performMigration(manga: MigratingManga) { + if(!manga.searchResult.initialized) { + return + } + + val toMangaObj = db.getManga(manga.searchResult.get() ?: return).executeAsBlocking() ?: return + + withContext(Dispatchers.IO) { + migrateMangaInternal( + manga.manga() ?: return@withContext, + toMangaObj, + !(controller.config?.copy ?: false) + ) + } + } + + private fun migrateMangaInternal(prevManga: Manga, + manga: Manga, + replace: Boolean) { + val config = controller.config ?: 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() + } + } + + fun View.setupView(tag: ViewTag, migratingManga: MigratingManga) { + tag.launch { + val manga = migratingManga.manga() + val source = migratingManga.mangaSource() + if(manga != null) { + withContext(Dispatchers.Main) { + migration_manga_card_from.loading_group.gone() + migration_manga_card_from.attachManga(tag, manga, source) + migration_manga_card_from.setOnClickListener { + controller.router.pushController(MangaController(manga, true).withFadeTransaction()) + } + } + + tag.launch { + migratingManga.progress.asFlow().collect { (max, progress) -> + withContext(Dispatchers.Main) { + migration_manga_card_to.search_progress.let { progressBar -> + progressBar.max = max + progressBar.progress = progress + } + } + } + } + + val searchResult = migratingManga.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() + migration_manga_card_to.attachManga(tag, searchResult, resultSource) + migration_manga_card_to.setOnClickListener { + controller.router.pushController(MangaController(searchResult, true).withFadeTransaction()) + } + accept_migration.isEnabled = true + accept_migration.alpha = 1.0f + } else { + migration_manga_card_to.search_progress.gone() + migration_manga_card_to.search_status.text = "Found no manga" + } + } + } + } + } + + suspend fun View.attachManga(tag: ViewTag, manga: Manga, source: Source) { + // TODO Duplicated in MangaInfoController + + GlideApp.with(context) + .load(manga) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(manga_cover) + + manga_full_title.text = if (manga.title.isBlank()) { + context.getString(R.string.unknown) + } else { + manga.title + } + + manga_artist.text = if (manga.artist.isNullOrBlank()) { + context.getString(R.string.unknown) + } else { + manga.artist + } + + manga_author.text = if (manga.author.isNullOrBlank()) { + context.getString(R.string.unknown) + } else { + manga.author + } + + manga_source.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() + // } + + /*if (source.id == MERGED_SOURCE_ID) { + manga_source_label.text = "Sources" + } else {*/ + manga_source_label.setText(R.string.manga_info_source_label) + // } + + manga_status.setText(when (manga.status) { + SManga.ONGOING -> R.string.ongoing + SManga.COMPLETED -> R.string.completed + SManga.LICENSED -> R.string.licensed + else -> R.string.unknown + }) + + val mangaChapters = db.getChapters(manga).executeAsBlocking() + manga_chapters.text = mangaChapters.size.toString() + val latestChapter = mangaChapters.maxBy { it.chapter_number }?.chapter_number ?: -1f + val lastUpdate = Date(mangaChapters.maxBy { it.date_upload }?.date_upload ?: 0) + + if (latestChapter > 0f) { + manga_last_chapter.text = DecimalFormat("#.#").format(latestChapter) + } else { + manga_last_chapter.setText(R.string.unknown) + } + + if (lastUpdate.time != 0L) { + manga_last_update.text = DateFormat.getDateInstance(DateFormat.SHORT).format(lastUpdate) + } else { + manga_last_update.setText(R.string.unknown) + } + } + + override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { + val objectAsView = `object` as View + container.removeView(objectAsView) + (objectAsView.tag as? ViewTag)?.destroy() + } + + class ViewTag(parent: CoroutineContext): CoroutineScope { + /** + * The context of this scope. + * Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope. + * Accessing this property in general code is not recommended for any purposes except accessing the [Job] instance for advanced usages. + * + * By convention, should contain an instance of a [job][Job] to enforce structured concurrency. + */ + override val coroutineContext = parent + Job() + Dispatchers.Default + + fun destroy() { + cancel() + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..d11b70bcc6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureConfig.kt @@ -0,0 +1,15 @@ +package exh.ui.migration.manga.process + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class MigrationProcedureConfig( + val mangaIds: List, + val targetSourceIds: List, + val useSourceWithMostChapters: Boolean, + val enableLenientSearch: Boolean, + val migrationFlags: Int, + val copy: Boolean, + val extraSearchParams: String? +): Parcelable \ No newline at end of file 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 new file mode 100644 index 0000000000..e3f14e96d7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcedureController.kt @@ -0,0 +1,253 @@ +package eu.kanade.tachiyomi.ui.migration.manga.process + +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.smartsearch.SmartSearchEngine +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.controller.BaseController +import eu.kanade.tachiyomi.util.await +import eu.kanade.tachiyomi.util.syncChaptersWithSource +import eu.kanade.tachiyomi.util.toast +import exh.ui.migration.manga.process.MigratingManga +import exh.ui.migration.manga.process.MigrationProcedureConfig +import kotlinx.android.synthetic.main.migration_process.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +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 +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext + +// TODO Will probably implode if activity is fully destroyed +class MigrationProcedureController(bundle: Bundle? = null) : BaseController(bundle), CoroutineScope { + + private var titleText = "Migrate manga" + + private var adapter: MigrationProcedureAdapter? = 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: List? = null + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.migration_process, 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 + new + } + + adapter = MigrationProcedureAdapter(this, newMigratingManga, coroutineContext) + + pager.adapter = adapter + pager.isEnabled = false + + if(migrationsJob == null) { + migrationsJob = launch { + runMigrations(newMigratingManga) + } + } + + pager.post { + // pager.currentItem doesn't appear to be valid if we don't do this in a post + updateTitle() + } + } + + fun updateTitle() { + titleText = "Migrate manga (${pager.currentItem + 1}/${adapter?.count ?: 0})" + setTitle() + } + + 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 == + true) { + 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 + } + + companion object { + const val CONFIG_EXTRA = "config_extra" + + fun create(config: MigrationProcedureConfig): MigrationProcedureController { + return MigrationProcedureController(Bundle().apply { + putParcelable(CONFIG_EXTRA, config) + }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/smartsearch/SmartSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/smartsearch/SmartSearchController.kt new file mode 100644 index 0000000000..7c5bb2bf4f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/smartsearch/SmartSearchController.kt @@ -0,0 +1,92 @@ +package eu.kanade.tachiyomi.ui.smartsearch + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.catalogue.CatalogueController +import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCatalogueController +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.util.toast +import kotlinx.android.synthetic.main.main_activity.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uy.kohesive.injekt.injectLazy + +class SmartSearchController(bundle: Bundle? = null) : NucleusController(), CoroutineScope { + override val coroutineContext = Job() + Dispatchers.Main + + private val sourceManager: SourceManager by injectLazy() + + private val source = sourceManager.get(bundle?.getLong(ARG_SOURCE_ID, -1) ?: -1) as? CatalogueSource + private val smartSearchConfig: CatalogueController.SmartSearchConfig? = bundle?.getParcelable( + ARG_SMART_SEARCH_CONFIG + ) + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup) = + inflater.inflate(R.layout.smart_search, container, false)!! + + override fun getTitle() = source?.name ?: "" + + override fun createPresenter() = SmartSearchPresenter(source, smartSearchConfig) + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + appbar.bringToFront() + + if(source == null || smartSearchConfig == null) { + router.popCurrentController() + applicationContext?.toast("Missing data!") + return + } + + // Init presenter now to resolve threading issues + presenter + + launch(Dispatchers.Default) { + for(event in presenter.smartSearchChannel) { + withContext(NonCancellable) { + if (event is SmartSearchPresenter.SearchResults.Found) { + val transaction = MangaController(event.manga, true, smartSearchConfig).withFadeTransaction() + withContext(Dispatchers.Main) { + router.replaceTopController(transaction) + } + } else { + if (event is SmartSearchPresenter.SearchResults.NotFound) { + applicationContext?.toast("Couldn't find the manga in the source!") + } else { + applicationContext?.toast("Error performing automatic search!") + } + + val transaction = BrowseCatalogueController(source, smartSearchConfig.origTitle, smartSearchConfig).withFadeTransaction() + withContext(Dispatchers.Main) { + router.replaceTopController(transaction) + } + } + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + + cancel() + } + + companion object { + const val ARG_SOURCE_ID = "SOURCE_ID" + const val ARG_SMART_SEARCH_CONFIG = "SMART_SEARCH_CONFIG" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/smartsearch/SmartSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/smartsearch/SmartSearchPresenter.kt new file mode 100644 index 0000000000..5f78c6f764 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/smartsearch/SmartSearchPresenter.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.ui.smartsearch + +import android.os.Bundle +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.model.SManga +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.catalogue.CatalogueController +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +class SmartSearchPresenter(private val source: CatalogueSource?, private val config: CatalogueController.SmartSearchConfig?): + BasePresenter(), CoroutineScope { + + override val coroutineContext = Job() + Dispatchers.Main + + val smartSearchChannel = Channel() + + private val smartSearchEngine = SmartSearchEngine(coroutineContext) + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + if(source != null && config != null) { + launch(Dispatchers.Default) { + val result = try { + val resultManga = smartSearchEngine.smartSearch(source, config.origTitle) + if (resultManga != null) { + val localManga = smartSearchEngine.networkToLocalManga(resultManga, source.id) + SearchResults.Found(localManga) + } else { + SearchResults.NotFound + } + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } else { + SearchResults.Error + } + } + + smartSearchChannel.send(result) + } + } + } + + + override fun onDestroy() { + super.onDestroy() + + cancel() + } + + data class SearchEntry(val manga: SManga, val dist: Double) + + sealed class SearchResults { + data class Found(val manga: Manga): SearchResults() + object NotFound: SearchResults() + object Error: SearchResults() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DeferredField.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DeferredField.kt new file mode 100644 index 0000000000..5146787989 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/DeferredField.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.util + + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Field that can be initialized later. Users can suspend while waiting for the field to initialize. + * + * @author nulldev + */ +class DeferredField { + + @Volatile + private var content: T? = null + + @Volatile + var initialized = false + private set + + private val mutex = Mutex(true) + + /** + * Initialize the field + */ + fun initialize(content: T) { + // Fast-path new listeners + this.content = content + initialized = true + + // Notify current listeners + mutex.unlock() + } + + /** + * Will only suspend if !initialized. + */ + suspend fun get(): T { + // Check if field is initialized and return immediately if it is + if (initialized) return content as T + + // Wait for field to initialize + mutex.withLock {} + + // Field is initialized, return value + return content as T + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/RxUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/RxUtil.kt new file mode 100644 index 0000000000..ae17cbbe88 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/RxUtil.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.util + +import kotlinx.coroutines.suspendCancellableCoroutine +import rx.Scheduler +import rx.Single +import rx.Subscription +import kotlin.coroutines.resumeWithException + +suspend fun Single.await(subscribeOn: Scheduler? = null): T { + return suspendCancellableCoroutine { continuation -> + val self = if (subscribeOn != null) subscribeOn(subscribeOn) else this + lateinit var sub: Subscription + sub = self.subscribe({ + continuation.resume(it) { + sub.unsubscribe() + } + }, { + if (!continuation.isCancelled) + continuation.resumeWithException(it) + }) + + continuation.invokeOnCancellation { + sub.unsubscribe() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/migration_design_controller.xml b/app/src/main/res/layout/migration_design_controller.xml new file mode 100644 index 0000000000..9b531d356f --- /dev/null +++ b/app/src/main/res/layout/migration_design_controller.xml @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +