diff --git a/.gitattributes b/.gitattributes index 1ca443da48..509cf43d10 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,4 @@ *.woff binary *.pyc binary *.swp binary +*.db binary diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7115c3f043..51a1961664 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,6 +49,11 @@ android { abiFilters("armeabi-v7a", "arm64-v8a", "x86") } } + + dataBinding { + isEnabled = true + } + buildTypes { getByName("debug") { applicationIdSuffix = ".debug" @@ -100,6 +105,9 @@ dependencies { implementation(Libs.Android.constraintLayout) implementation(Libs.Android.multiDex) + // Databinding for autocomplete search + kapt(Libs.Android.dataBinding) + implementation(Libs.Google.firebaseAnayltics) implementation(Libs.Google.firebaseCrashltyics) implementation(Libs.Google.firebaseCore) @@ -259,8 +267,15 @@ dependencies { // See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers tasks.withType().all { - kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" - kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi" + kotlinOptions.freeCompilerArgs += listOf( + "-Xopt-in=kotlin.Experimental", + "-Xopt-in=kotlin.RequiresOptIn", + "-Xuse-experimental=kotlin.ExperimentalStdlibApi", + "-Xuse-experimental=kotlinx.coroutines.FlowPreview", + "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xuse-experimental=kotlinx.coroutines.InternalCoroutinesApi", + "-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi" + ) } tasks.preBuild { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 58a803c0d0..93d32e1a1d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -184,6 +184,10 @@ android:name=".data.library.LibraryUpdateService" android:exported="false" /> + + @@ -201,7 +205,7 @@ android:exported="false" /> diff --git a/app/src/main/assets/covers.db b/app/src/main/assets/covers.db new file mode 100644 index 0000000000..99f730441a Binary files /dev/null and b/app/src/main/assets/covers.db differ diff --git a/app/src/main/assets/mangadex.db b/app/src/main/assets/mangadex.db new file mode 100644 index 0000000000..49a455d34b Binary files /dev/null and b/app/src/main/assets/mangadex.db differ diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index eb74162f38..1314cd2381 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -11,7 +11,18 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.MangaDexLoginHelper +import eu.kanade.tachiyomi.source.online.handlers.ApiMangaParser +import eu.kanade.tachiyomi.source.online.handlers.FilterHandler +import eu.kanade.tachiyomi.source.online.handlers.FollowsHandler +import eu.kanade.tachiyomi.source.online.handlers.MangaHandler +import eu.kanade.tachiyomi.source.online.handlers.MangaPlusHandler +import eu.kanade.tachiyomi.source.online.handlers.PageHandler +import eu.kanade.tachiyomi.source.online.handlers.PopularHandler +import eu.kanade.tachiyomi.source.online.handlers.SearchHandler +import eu.kanade.tachiyomi.source.online.handlers.SimilarHandler import eu.kanade.tachiyomi.util.chapter.ChapterFilter +import eu.kanade.tachiyomi.v5.db.V5DbHelper import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -51,6 +62,28 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { Json { ignoreUnknownKeys = true } } + addSingletonFactory { V5DbHelper(app.applicationContext) } + + addSingleton(FilterHandler()) + + addSingleton(FollowsHandler()) + + addSingleton(MangaHandler()) + + addSingleton(ApiMangaParser()) + + addSingleton(PopularHandler()) + + addSingleton(SearchHandler()) + + addSingleton(PageHandler()) + + addSingleton(SimilarHandler()) + + addSingleton(MangaDexLoginHelper()) + + addSingleton(MangaPlusHandler()) + // Asynchronously init expensive components for a faster cold start GlobalScope.launch { get() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index f8de2d9cf9..4abd3e49d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -1,13 +1,19 @@ package eu.kanade.tachiyomi +import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.data.backup.BackupCreatorJob +import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.similar.SimilarUpdateJob import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.updater.UpdaterJob +import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries +import eu.kanade.tachiyomi.v5.job.V5MigrationJob +import eu.kanade.tachiyomi.v5.job.V5MigrationService import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -24,13 +30,11 @@ object Migrations { val oldVersion = preferences.lastVersionCode().getOrDefault() if (oldVersion < BuildConfig.VERSION_CODE) { preferences.lastVersionCode().set(BuildConfig.VERSION_CODE) - if (oldVersion < 38) { if (preferences.automaticUpdates()) { UpdaterJob.setupTask() } } - if (oldVersion < 39) { // Restore jobs after migrating from Evernote's job scheduler to WorkManager. if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) { @@ -40,7 +44,6 @@ object Migrations { if (oldVersion < 53) { LibraryUpdateJob.setupTask() BackupCreatorJob.setupTask() - SimilarUpdateJob.setupTask(true) } if (oldVersion < 95 && oldVersion != 0) { // Force MAL log out due to login flow change @@ -48,7 +51,6 @@ object Migrations { trackManager.myAnimeList.logout() context.toast(R.string.myanimelist_relogin) } - if (oldVersion < 113 && oldVersion != 0) { // Force MAL log out due to login flow change // v67: switched from scraping to WebView @@ -59,7 +61,10 @@ object Migrations { context.toast(R.string.myanimelist_relogin) } } - + if(oldVersion < 114 && oldVersion != 0) { + // Force migrate all manga to the new V5 ids + V5MigrationJob.doWorkNow() + } return true } return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullRestore.kt index 8f8c470577..b0fd6053c7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullRestore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullRestore.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.backup.full import android.content.Context import android.net.Uri +import androidx.core.text.isDigitsOnly import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.data.backup.RestoreHelper import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory @@ -10,7 +11,10 @@ import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.util.system.notificationManager +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries import kotlinx.coroutines.Job import okio.buffer import okio.gzip @@ -44,6 +48,7 @@ class FullRestore(val context: Context, val job: Job?) { private val trackingErrors = mutableListOf() private val db: DatabaseHelper by injectLazy() + internal val dbV5: V5DbHelper by injectLazy() internal val trackManager: TrackManager by injectLazy() suspend fun restoreBackup(uri: Uri) { @@ -108,6 +113,16 @@ class FullRestore(val context: Context, val job: Job?) { var dbManga = backupManager.getMangaFromDatabase(manga) val dbMangaExists = dbManga != null + // If it is an old pre-V5 manga try to find the new id + val oldMangaId = MdUtil.getMangaId(manga.url) + val isNumericId = oldMangaId.isDigitsOnly() + if (isNumericId) { + val newMangaId = V5DbQueries.getNewMangaId(dbV5.idDb, oldMangaId) + if (newMangaId.isNotBlank()) { + manga.url = "/title/${newMangaId}" + } + } + if (dbMangaExists) { backupManager.restoreMangaNoFetch(manga, dbManga!!) } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyRestore.kt index 0bc3f0eb69..df41ca73f6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyRestore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyRestore.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.backup.legacy import android.content.Context import android.net.Uri +import androidx.core.text.isDigitsOnly import com.elvishew.xlog.XLog import com.github.salomonbrys.kotson.fromJson import com.google.gson.JsonArray @@ -20,7 +21,10 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.TrackImpl import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.util.system.notificationManager +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries import kotlinx.coroutines.Job import uy.kohesive.injekt.injectLazy @@ -68,6 +72,11 @@ class LegacyRestore(val context: Context, val job: Job?) { */ private val db: DatabaseHelper by injectLazy() + /** + * pre-V5 mangadex to V5 mangadex utility class + */ + internal val dbV5: V5DbHelper by injectLazy() + /** * Tracking manager */ @@ -158,6 +167,16 @@ class LegacyRestore(val context: Context, val job: Job?) { val dbManga = backupManager.getMangaFromDatabase(manga) val dbMangaExists = dbManga != null + // If it is an old pre-V5 manga try to find the new id + val oldMangaId = MdUtil.getMangaId(manga.url) + val isNumericId = oldMangaId.isDigitsOnly() + if (isNumericId) { + val newMangaId = V5DbQueries.getNewMangaId(dbV5.idDb, oldMangaId) + if (newMangaId != "") { + manga.url = "/title/${newMangaId}" + } + } + if (dbMangaExists) { // Manga in database copy information from manga already in database backupManager.restoreMangaNoFetch(manga, dbManga!!) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt index fa3965c0bb..333c18267c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt @@ -22,7 +22,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { /** * Version of the database. */ - const val DATABASE_VERSION = 25 + const val DATABASE_VERSION = 26 } override fun onCreate(db: SupportSQLiteDatabase) = with(db) { @@ -60,7 +60,6 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { } if (oldVersion < 12) { db.execSQL(SimilarTable.createTableQuery) - db.execSQL(SimilarTable.createMangaIdIndexQuery) } if (oldVersion < 13) { db.execSQL(CategoryTable.addMangaOrder) @@ -101,6 +100,14 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { if (oldVersion < 24) { db.execSQL(CachedMangaTable.createVirtualTableQuery) } + if (oldVersion < 26) { + db.execSQL(ChapterTable.addOldMangaDexChapterId) + db.execSQL(SimilarTable.dropTableQuery) + db.execSQL(SimilarTable.createTableQuery) + db.execSQL(SimilarTable.createMangaIdIndexQuery) + db.execSQL(CachedMangaTable.dropVirtualTableQuery) + db.execSQL(CachedMangaTable.createVirtualTableQuery) + } } override fun onConfigure(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CacheMangaTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CacheMangaTypeMapping.kt index 5bb6d7a8bf..51ca9b3829 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CacheMangaTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CacheMangaTypeMapping.kt @@ -10,8 +10,9 @@ 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.CachedManga -import eu.kanade.tachiyomi.data.database.tables.CachedMangaTable.COL_MANGA_ID import eu.kanade.tachiyomi.data.database.tables.CachedMangaTable.COL_MANGA_TITLE +import eu.kanade.tachiyomi.data.database.tables.CachedMangaTable.COL_MANGA_UUID +import eu.kanade.tachiyomi.data.database.tables.CachedMangaTable.COL_MANGA_RATING import eu.kanade.tachiyomi.data.database.tables.CachedMangaTable.TABLE_FTS class CacheMangaTypeMapping : SQLiteTypeMapping( @@ -28,21 +29,23 @@ class CacheMangaPutResolver : DefaultPutResolver() { override fun mapToUpdateQuery(obj: CachedManga) = UpdateQuery.builder() .table(TABLE_FTS) - .where("$COL_MANGA_ID = ?") - .whereArgs(obj.mangaId) + .where("$COL_MANGA_UUID = ?") + .whereArgs(obj.uuid) .build() - override fun mapToContentValues(obj: CachedManga) = ContentValues(2).apply { - put(COL_MANGA_ID, obj.mangaId) + override fun mapToContentValues(obj: CachedManga) = ContentValues(3).apply { put(COL_MANGA_TITLE, obj.title) + put(COL_MANGA_UUID, obj.uuid) + put(COL_MANGA_RATING, obj.rating) } } class CacheMangaGetResolver : DefaultGetResolver() { override fun mapFromCursor(cursor: Cursor): CachedManga = CachedManga( - mangaId = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)), - title = cursor.getString(cursor.getColumnIndex(COL_MANGA_TITLE)) + title = cursor.getString(cursor.getColumnIndex(COL_MANGA_TITLE)), + uuid = cursor.getString(cursor.getColumnIndex(COL_MANGA_UUID)), + rating = cursor.getString(cursor.getColumnIndex(COL_MANGA_RATING)) ) } @@ -50,7 +53,7 @@ class CacheMangaDeleteResolver : DefaultDeleteResolver() { override fun mapToDeleteQuery(obj: CachedManga) = DeleteQuery.builder() .table(TABLE_FTS) - .where("$COL_MANGA_ID = ?") - .whereArgs(obj.mangaId) + .where("$COL_MANGA_UUID = ?") + .whereArgs(obj.uuid) .build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/ChapterTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/ChapterTypeMapping.kt index bca91b2a34..cc0ba3a2e9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/ChapterTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/ChapterTypeMapping.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database.mappers import android.content.ContentValues import android.database.Cursor +import androidx.core.database.getStringOrNull import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver @@ -23,6 +24,7 @@ import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_LAST_PAGE_READ import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_MANGADEX_CHAPTER_ID import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_MANGA_ID import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_NAME +import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_OLD_MANGADEX_CHAPTER_ID import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_PAGES_LEFT import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_READ import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_SCANLATOR @@ -68,6 +70,7 @@ class ChapterPutResolver : DefaultPutResolver() { put(COL_SOURCE_ORDER, obj.source_order) put(COL_MANGADEX_CHAPTER_ID, obj.mangadex_chapter_id) put(COL_LANGUAGE, obj.language) + put(COL_OLD_MANGADEX_CHAPTER_ID, obj.old_mangadex_id) } } @@ -92,6 +95,7 @@ class ChapterGetResolver : DefaultGetResolver() { source_order = cursor.getInt(cursor.getColumnIndex(COL_SOURCE_ORDER)) mangadex_chapter_id = cursor.getString(cursor.getColumnIndex(COL_MANGADEX_CHAPTER_ID)) language = cursor.getString(cursor.getColumnIndex(COL_LANGUAGE)) + old_mangadex_id = cursor.getStringOrNull(cursor.getColumnIndex(COL_OLD_MANGADEX_CHAPTER_ID)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/SimilarTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/SimilarTypeMapping.kt index 9193553255..88b47b3033 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/SimilarTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/SimilarTypeMapping.kt @@ -13,8 +13,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaSimilar import eu.kanade.tachiyomi.data.database.models.MangaSimilarImpl import eu.kanade.tachiyomi.data.database.tables.SimilarTable.COL_ID import eu.kanade.tachiyomi.data.database.tables.SimilarTable.COL_MANGA_ID -import eu.kanade.tachiyomi.data.database.tables.SimilarTable.COL_MANGA_SIMILAR_MATCHED_IDS -import eu.kanade.tachiyomi.data.database.tables.SimilarTable.COL_MANGA_SIMILAR_MATCHED_TITLES +import eu.kanade.tachiyomi.data.database.tables.SimilarTable.COL_MANGA_DATA import eu.kanade.tachiyomi.data.database.tables.SimilarTable.TABLE class SimilarTypeMapping : SQLiteTypeMapping( @@ -35,11 +34,10 @@ class SimilarPutResolver : DefaultPutResolver() { .whereArgs(obj.id) .build() - override fun mapToContentValues(obj: MangaSimilar) = ContentValues(4).apply { + override fun mapToContentValues(obj: MangaSimilar) = ContentValues(3).apply { put(COL_ID, obj.id) put(COL_MANGA_ID, obj.manga_id) - put(COL_MANGA_SIMILAR_MATCHED_IDS, obj.matched_ids) - put(COL_MANGA_SIMILAR_MATCHED_TITLES, obj.matched_titles) + put(COL_MANGA_DATA, obj.data) } } @@ -47,9 +45,8 @@ class SimilarGetResolver : DefaultGetResolver() { override fun mapFromCursor(cursor: Cursor): MangaSimilar = MangaSimilarImpl().apply { id = cursor.getLong(cursor.getColumnIndex(COL_ID)) - manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)) - matched_ids = cursor.getString(cursor.getColumnIndex(COL_MANGA_SIMILAR_MATCHED_IDS)) - matched_titles = cursor.getString(cursor.getColumnIndex(COL_MANGA_SIMILAR_MATCHED_TITLES)) + manga_id = cursor.getString(cursor.getColumnIndex(COL_MANGA_ID)) + data = cursor.getString(cursor.getColumnIndex(COL_MANGA_DATA)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CachedManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CachedManga.kt index 25edbebf00..791db8f401 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CachedManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CachedManga.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database.models data class CachedManga( // Manga ID this gallery is linked to - val mangaId: Long, val title: String, + val uuid: String, + val rating: String, ) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt index 4e82948d2e..43340202ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt @@ -38,6 +38,8 @@ class ChapterImpl : Chapter { override var mangadex_chapter_id: String = "" + override var old_mangadex_id: String? = null + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || javaClass != other.javaClass) return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSimilar.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSimilar.kt index 2ec5933ca1..8cad1a960b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSimilar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSimilar.kt @@ -13,19 +13,18 @@ interface MangaSimilar : Serializable { var id: Long? /** - * Id of matching manga + * Mangadex id of manga */ - var manga_id: Long? + var manga_id: String /** - * JSONArray.toString() list of ids for this manga - * Example: [3467, 5907, 21052, 2141, 6139, 5602, 3999] + * JSONArray.toString() of our similar manga object */ - var matched_ids: String + var data: String - /** - * JSONArray.toString() list of titles for this manga - * Example: [Title1, Title2, ..., Title10] - */ - var matched_titles: String + companion object { + fun create(): MangaSimilarImpl { + return MangaSimilarImpl() + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSimilarImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSimilarImpl.kt index 53083efc20..47add32354 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSimilarImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSimilarImpl.kt @@ -4,11 +4,9 @@ class MangaSimilarImpl : MangaSimilar { override var id: Long? = null - override var manga_id: Long? = null + override lateinit var manga_id: String - override lateinit var matched_ids: String - - override lateinit var matched_titles: String + override lateinit var data: String override fun equals(other: Any?): Boolean { if (this === other) return true @@ -18,8 +16,7 @@ class MangaSimilarImpl : MangaSimilar { if (id != other.id) return false if (manga_id != other.manga_id) return false - if (matched_ids != other.matched_ids) return false - return matched_titles != other.matched_titles + return data != other.data } override fun hashCode(): Int { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CachedMangaQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CachedMangaQueries.kt index a6853a7000..320ebbeb99 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CachedMangaQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CachedMangaQueries.kt @@ -13,16 +13,29 @@ interface CachedMangaQueries : DbProvider { fun insertCachedManga2(cachedManga: List) = db.inTransaction { val query = RawQuery.builder() - .query("INSERT INTO ${CachedMangaTable.TABLE_FTS} (${CachedMangaTable.COL_MANGA_ID}, ${CachedMangaTable.COL_MANGA_TITLE}) VALUES (?, ?);") + .query("INSERT INTO ${CachedMangaTable.TABLE_FTS} " + + "(${CachedMangaTable.COL_MANGA_TITLE}, ${CachedMangaTable.COL_MANGA_UUID}," + + " ${CachedMangaTable.COL_MANGA_RATING}) VALUES (?, ?, ?);") cachedManga.forEach { db.lowLevel().executeSQL( - query.args(it.mangaId, it.title) + query.args(it.title, it.uuid, it.rating) .build() ) } } + fun insertCachedManga2Single(cachedManga: CachedManga) = db.inTransaction { + val query = RawQuery.builder() + .query("INSERT INTO ${CachedMangaTable.TABLE_FTS} " + + "(${CachedMangaTable.COL_MANGA_TITLE}, ${CachedMangaTable.COL_MANGA_UUID}," + + " ${CachedMangaTable.COL_MANGA_RATING}) VALUES (?, ?, ?);") + db.lowLevel().executeSQL( + query.args(cachedManga.title, cachedManga.uuid, cachedManga.rating) + .build() + ) + } + fun getCachedMangaCount() = db.get().numberOfResults().withQuery( RawQuery.builder() .query("SELECT * FROM ${CachedMangaTable.TABLE_FTS}") @@ -54,4 +67,12 @@ interface CachedMangaQueries : DbProvider { .build() ) .prepare() + + fun optimizeCachedManga() = db.inTransaction { + val query = RawQuery.builder() + .query("INSERT INTO ${CachedMangaTable.TABLE_FTS}(${CachedMangaTable.TABLE_FTS}) VALUES('optimize');") + db.lowLevel().executeSQL( + query.build() + ) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt index ac0d66f806..6781a1ecc6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt @@ -243,7 +243,7 @@ fun searchCachedMangaQuery(query: String, page: Int, limit: Int): String { val queryCleaned = regex.replace(query, "") return """ SELECT * FROM ${CachedMangaTable.TABLE_FTS} - WHERE ${CachedMangaTable.COL_MANGA_TITLE} MATCH "$queryCleaned" + WHERE ${CachedMangaTable.COL_MANGA_TITLE} MATCH '$queryCleaned' LIMIT ${limit+1} OFFSET ${page*limit} """ } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/SimilarQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/SimilarQueries.kt index 19d97f9e98..ca756d1b93 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/SimilarQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/SimilarQueries.kt @@ -8,16 +8,7 @@ import eu.kanade.tachiyomi.data.database.tables.SimilarTable interface SimilarQueries : DbProvider { - fun getAllSimilar() = db.get() - .listOfObjects(MangaSimilar::class.java) - .withQuery( - Query.builder() - .table(SimilarTable.TABLE) - .build() - ) - .prepare() - - fun getSimilar(manga_id: Long) = db.get() + fun getSimilar(manga_id: String) = db.get() .`object`(MangaSimilar::class.java) .withQuery( Query.builder() @@ -30,8 +21,6 @@ interface SimilarQueries : DbProvider { fun insertSimilar(similar: MangaSimilar) = db.put().`object`(similar).prepare() - fun insertSimilar(similarList: List) = db.put().objects(similarList).prepare() - fun deleteAllSimilar() = db.delete() .byQuery( DeleteQuery.builder() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CachedMangaTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CachedMangaTable.kt index f0cc64aba7..4fb117d2db 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CachedMangaTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CachedMangaTable.kt @@ -3,13 +3,20 @@ package eu.kanade.tachiyomi.data.database.tables object CachedMangaTable { const val TABLE_FTS = "cached_manga_fts" - - const val COL_MANGA_ID = "manga_id" const val COL_MANGA_TITLE = "manga_title" + const val COL_MANGA_UUID = "manga_uuid" + + const val COL_MANGA_RATING = "manga_rating" + + val dropVirtualTableQuery: String + get() = + "DROP TABLE IF EXISTS $TABLE_FTS" + val createVirtualTableQuery: String get() = """CREATE VIRTUAL TABLE $TABLE_FTS - USING fts5($COL_MANGA_ID, $COL_MANGA_TITLE)""" + USING fts5($COL_MANGA_TITLE, $COL_MANGA_UUID UNINDEXED, $COL_MANGA_RATING UNINDEXED)""" + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt index 3c18fc8365..53d643bdf3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt @@ -38,6 +38,8 @@ object ChapterTable { const val COL_MANGADEX_CHAPTER_ID = "mangadex_chapter_id" + const val COL_OLD_MANGADEX_CHAPTER_ID = "old_mangadex_chapter_id" + const val COL_LANGUAGE = "language" val createTableQuery: String @@ -60,6 +62,7 @@ object ChapterTable { $COL_DATE_FETCH LONG NOT NULL, $COL_DATE_UPLOAD LONG NOT NULL, $COL_MANGADEX_CHAPTER_ID String TEXT, + $COL_OLD_MANGADEX_CHAPTER_ID String TEXT, $COL_LANGUAGE String TEXT, FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) ON DELETE CASCADE @@ -87,6 +90,9 @@ object ChapterTable { val addMangaDexChapterId: String get() = "ALTER TABLE $TABLE ADD COLUMN $COL_MANGADEX_CHAPTER_ID TEXT DEFAULT ''" + val addOldMangaDexChapterId: String + get() = "ALTER TABLE $TABLE ADD COLUMN $COL_OLD_MANGADEX_CHAPTER_ID TEXT " + val addLanguage: String get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LANGUAGE TEXT DEFAULT ''" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SimilarTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SimilarTable.kt index b2d2638679..e698f4202b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SimilarTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SimilarTable.kt @@ -8,20 +8,21 @@ object SimilarTable { const val COL_MANGA_ID = "manga_id" - const val COL_MANGA_SIMILAR_MATCHED_IDS = "matched_ids" + const val COL_MANGA_DATA = "matched_ids" - const val COL_MANGA_SIMILAR_MATCHED_TITLES = "matched_titles" + val dropTableQuery: String + get() = "DROP TABLE IF EXISTS $TABLE" val createTableQuery: String get() = """CREATE TABLE $TABLE( $COL_ID INTEGER NOT NULL PRIMARY KEY, - $COL_MANGA_ID INTEGER NOT NULL, - $COL_MANGA_SIMILAR_MATCHED_IDS TEXT NOT NULL, - $COL_MANGA_SIMILAR_MATCHED_TITLES TEXT NOT NULL, + $COL_MANGA_ID TEXT NOT NULL, + $COL_MANGA_DATA TEXT NOT NULL, UNIQUE ($COL_ID) ON CONFLICT REPLACE )""" val createMangaIdIndexQuery: String - get() = "CREATE INDEX ${TABLE}_${COL_MANGA_SIMILAR_MATCHED_IDS}_index ON $TABLE($COL_MANGA_SIMILAR_MATCHED_IDS)" + get() = "CREATE INDEX ${SimilarTable.TABLE}_${SimilarTable.COL_MANGA_ID}_index ON ${SimilarTable.TABLE}(${SimilarTable.COL_MANGA_ID})" + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index 2df84c2441..e4aeb72e4d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -80,9 +80,13 @@ class DownloadCache( val fileNames = mangaFiles[manga.id]?.first?.toHashSet() ?: return false val mangadexIds = mangaFiles[manga.id]?.second?.toHashSet() ?: return false + if (!chapter.isMergedChapter() && chapter.mangadex_chapter_id.isNotEmpty() && chapter.mangadex_chapter_id in mangadexIds) { return true } + if (!chapter.isMergedChapter() && chapter.old_mangadex_id != null && chapter.old_mangadex_id in mangadexIds) { + return true + } val validChapterDirNames = provider.getValidChapterDirNames(chapter) return validChapterDirNames.any { it in fileNames @@ -136,6 +140,11 @@ class DownloadCache( } } + fun forceRenewCache() { + renew() + lastRenew = System.currentTimeMillis() + } + /** * Renews the downloads cache. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index d3d02c1f4c..5b1fa14de6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -370,4 +370,9 @@ class DownloadManager(val context: Context) { fun addListener(listener: DownloadQueue.DownloadListener) = queue.addListener(listener) fun removeListener(listener: DownloadQueue.DownloadListener) = queue.removeListener(listener) + + //forceRefresh the cache + fun refreshCache() { + cache.forceRenewCache() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index 514c80efe4..bcd1445039 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.isMergedChapter -import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.util.lang.isUUID import eu.kanade.tachiyomi.util.storage.DiskUtil import uy.kohesive.injekt.injectLazy @@ -112,14 +112,18 @@ class DownloadProvider(private val context: Context) { val mangaDir = findMangaDir(manga, source) ?: return emptyList() val idHashSet = chapters.map { it.mangadex_chapter_id }.toHashSet() + val oldIdHashSet = chapters.mapNotNull { it.old_mangadex_id }.toHashSet() val chapterNameHashSet = chapters.map { it.name }.toHashSet() val scanalatorNameHashSet = chapters.map { getJ2kChapterName(it) }.toHashSet() return mangaDir.listFiles()!!.asList().filter { file -> file.name?.let { fileName -> val mangadexId = fileName.substringAfterLast(" - ", "") - if (mangadexId.isNotEmpty() && mangadexId.isDigitsOnly()) { + //legacy dex id + if (mangadexId.isNotEmpty() && mangadexId.isUUID()) { return@filter idHashSet.contains(mangadexId) + } else if (mangadexId.isNotEmpty() && mangadexId.isDigitsOnly()) { + return@filter oldIdHashSet.contains(mangadexId) } else { if (scanalatorNameHashSet.contains(fileName)) { return@filter true @@ -213,12 +217,16 @@ class DownloadProvider(private val context: Context) { * * @param chapter the chapter to query. */ - fun getChapterDirName(chapter: Chapter): String { + fun getChapterDirName(chapter: Chapter, useNewId: Boolean = true): String { + if (chapter.isMergedChapter()) { return getJ2kChapterName(chapter) } else { - val chapterId = if (chapter.mangadex_chapter_id.isNotBlank()) chapter.mangadex_chapter_id else MdUtil.getChapterId(chapter.url) - return DiskUtil.buildValidFilename(chapter.name + " - " + chapterId) + if (useNewId.not() && chapter.old_mangadex_id == null) { + return "" + } + val chapterId = if (useNewId) chapter.mangadex_chapter_id else chapter.old_mangadex_id + return DiskUtil.buildValidFilename(chapter.name, " - $chapterId") } } @@ -236,11 +244,13 @@ class DownloadProvider(private val context: Context) { */ fun getValidChapterDirNames(chapter: Chapter): List { return listOf( - getChapterDirName(chapter), + getChapterDirName(chapter, true), // chater names from j2k getJ2kChapterName(chapter), + //legacy manga id + getChapterDirName(chapter, false), // Legacy chapter directory name used in v0.8.4 and before DiskUtil.buildValidFilename(chapter.name) - ) + ).filter { it.isNotEmpty() } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 948dc0bab8..2b26383df9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -284,7 +284,7 @@ class Downloader( val mangaDir = provider.getMangaDir(download.manga, sourceManager.getMangadex()) val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX) - val pagesToDownload = if (download.source is MergeSource) 3 else 8 + val pagesToDownload = if (download.source is MergeSource) 3 else 10 val pageListObservable = if (download.pages == null) { // Pull page list from network and add them to download object diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 39f3ba05f7..af62d4f4fa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -19,7 +19,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.similar.SimilarUpdateService +import eu.kanade.tachiyomi.data.similar.MangaCacheUpdateService import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaDetailsController @@ -28,6 +28,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.v5.job.V5MigrationService import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy @@ -72,7 +73,8 @@ class NotificationReceiver : BroadcastReceiver() { ) // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) - ACTION_CANCEL_SIMILAR_UPDATE -> cancelSimilarUpdate(context) + ACTION_CANCEL_CACHE_UPDATE -> cancelCacheUpdate(context) + ACTION_CANCEL_V5_MIGRATION -> cancelV5Migration(context) ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context) // Share backup file ACTION_SHARE_BACKUP -> @@ -199,14 +201,25 @@ class NotificationReceiver : BroadcastReceiver() { } /** - * Method called when user wants to stop a similar manga update + * Method called when user wants to stop a cache manga update * * @param context context of application * @param notificationId id of notification */ - private fun cancelSimilarUpdate(context: Context) { - SimilarUpdateService.stop(context) - Handler().post { dismissNotification(context, Notifications.ID_SIMILAR_PROGRESS) } + private fun cancelCacheUpdate(context: Context) { + MangaCacheUpdateService.stop(context) + Handler().post { dismissNotification(context, Notifications.ID_CACHE_PROGRESS) } + } + + /** + * Method called when user wants to stop a library update + * + * @param context context of application + * @param notificationId id of notification + */ + private fun cancelV5Migration(context: Context) { + V5MigrationService.stop(context) + Handler().post { dismissNotification(context, Notifications.ID_V5_MIGRATION_PROGRESS) } } /** @@ -256,8 +269,11 @@ class NotificationReceiver : BroadcastReceiver() { // Called to cancel library update. private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" - // Called to cancel library update. - private const val ACTION_CANCEL_SIMILAR_UPDATE = "$ID.$NAME.CANCEL_SIMILAR_UPDATE" + // Called to cancel cache update. + private const val ACTION_CANCEL_CACHE_UPDATE = "$ID.$NAME.CANCEL_CACHE_UPDATE" + + // Called to cancel library v5 migration update. + private const val ACTION_CANCEL_V5_MIGRATION = "$ID.$NAME.CANCEL_V5_MIGRATION" // Called to mark as read private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" @@ -531,14 +547,27 @@ class NotificationReceiver : BroadcastReceiver() { } /** - * Returns [PendingIntent] that starts a service which stops the similar update + * Returns [PendingIntent] that starts a service which stops the cache update + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun cancelCacheUpdatePendingBroadcast(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CANCEL_CACHE_UPDATE + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that starts a service which stops the library update * * @param context context of application * @return [PendingIntent] */ - internal fun cancelSimilarUpdatePendingBroadcast(context: Context): PendingIntent { + internal fun cancelV5MigrationUpdatePendingBroadcast(context: Context): PendingIntent { val intent = Intent(context, NotificationReceiver::class.java).apply { - action = ACTION_CANCEL_SIMILAR_UPDATE + action = ACTION_CANCEL_V5_MIGRATION } return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index 803e6132d9..79f08af6f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -46,11 +46,11 @@ object Notifications { const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS" /** - * Notification channel and ids used for backup and restore. + * Notification channel and ids used for cache searching. */ - const val CHANNEL_SIMILAR = "similar_channel" - const val ID_SIMILAR_PROGRESS = -401 - const val ID_SIMILAR_COMPLETE = -402 + const val CHANNEL_CACHE = "cache_channel" + const val ID_CACHE_PROGRESS = -401 + const val ID_CACHE_COMPLETE = -402 /** * Notification channel and ids used for backup and restore. @@ -71,6 +71,13 @@ object Notifications { const val CHANNEL_CRASH_LOGS = "crash_logs_channel" const val ID_CRASH_LOGS = -601 + /** + * Notification channel for migration. + */ + const val CHANNEL_V5_MIGRATION = "v5_migration_channel" + const val ID_V5_MIGRATION_PROGRESS = -901 + const val ID_V5_MIGRATION_ERROR = -902 + /** * Creates the notification channels introduced in Android Oreo. * @@ -146,8 +153,15 @@ object Notifications { setSound(null, null) }, NotificationChannel( - CHANNEL_SIMILAR, - context.getString(R.string.similar), + CHANNEL_V5_MIGRATION, context.getString(R.string.v5_migration_service), + NotificationManager.IMPORTANCE_HIGH + ).apply { + setShowBadge(false) + setSound(null, null) + }, + NotificationChannel( + CHANNEL_CACHE, + context.getString(R.string.cache), NotificationManager.IMPORTANCE_LOW ).apply { setShowBadge(false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 490aedab71..c22c621bea 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -125,7 +125,7 @@ object PreferenceKeys { const val lang = "app_language" - const val langToShow = "mangadex_languages" + const val langToShow = "mangadex_languages_new" const val dateFormat = "app_date_format" @@ -159,15 +159,7 @@ object PreferenceKeys { const val enableDoh = "enable_doh" - const val similarEnabled = "pref_related_show_tab_key" - - const val similarUpdateInterval = "related_update_interval" - - const val similarOnlyOverWifi = "pref_simular_only_over_wifi_key" - - const val showR18 = "show_r18" - - const val imageServer = "image_server" + const val contentRating = "content_rating_options" const val lowQualityCovers = "low_quality_covers" @@ -179,13 +171,21 @@ object PreferenceKeys { const val markChaptersFromMDList = "mdlist_mark_read" - const val showR18Filter = "show_R18_filter" + const val showContentRatingFilter = "show_R18_filter" + + const val enablePort443Only = "use_port_443_only_for_image_server" const val addToLibraryAsPlannedToRead = "add_to_libray_as_planned_to_read" const val createLegacyBackup = "create_legacy_backup" - const val useCacheSource = "use_cache_source" + const val useCacheSource = "use_cache_source_new" + + const val sessionToken = "mangadex_session_token" + + const val refreshToken = "mangadex_refresh_token" + + const val lastRefreshTokenTime = "mangadex_refresh_token_time" fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 271c3b1755..71b16f96dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -8,10 +8,10 @@ import androidx.preference.PreferenceManager import com.f2prateek.rx.preferences.Preference import com.f2prateek.rx.preferences.RxSharedPreferences import com.tfcporciuncula.flow.FlowSharedPreferences +import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.online.MangaDex import java.io.File import java.text.DateFormat import java.text.SimpleDateFormat @@ -44,10 +44,15 @@ class PreferencesHelper(val context: Context) { private val rxPrefs = RxSharedPreferences.create(prefs) private val flowPrefs = FlowSharedPreferences(prefs) + private val defaultFolder = context.getString(R.string.neko_app_name) + when (BuildConfig.DEBUG) { + true -> "_DEBUG" + false -> "" + } + private val defaultDownloadsDir = Uri.fromFile( File( Environment.getExternalStorageDirectory().absolutePath + File.separator + - context.getString(R.string.neko_app_name), + defaultFolder, "downloads" ) ) @@ -55,7 +60,7 @@ class PreferencesHelper(val context: Context) { private val defaultBackupDir = Uri.fromFile( File( Environment.getExternalStorageDirectory().absolutePath + File.separator + - context.getString(R.string.neko_app_name), + defaultFolder, "backup" ) ) @@ -229,7 +234,7 @@ class PreferencesHelper(val context: Context) { fun lang() = prefs.getString(Keys.lang, "") - fun langsToShow() = flowPrefs.getString(Keys.langToShow, "gb") + fun langsToShow() = flowPrefs.getString(Keys.langToShow, "en") fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1) @@ -311,33 +316,53 @@ class PreferencesHelper(val context: Context) { fun shownSimilarTutorial() = flowPrefs.getBoolean("shown_similar_tutorial", false) - fun similarEnabled() = flowPrefs.getBoolean(Keys.similarEnabled, false) - - fun shownSimilarAskDialog() = flowPrefs.getBoolean("shown_similar_ask_dialog", false) - - fun similarOnlyOverWifi() = prefs.getBoolean(Keys.similarOnlyOverWifi, true) - - fun similarUpdateInterval() = rxPrefs.getInteger(Keys.similarUpdateInterval, 3) - fun lowQualityCovers() = prefs.getBoolean(Keys.lowQualityCovers, false) - fun r18() = prefs.getString(Keys.showR18, "0") - - fun imageServer() = prefs.getString(Keys.imageServer, MangaDex.SERVER_PREF_ENTRY_VALUES.first()) - fun dataSaver() = prefs.getBoolean(Keys.dataSaver, false) + fun usePort443Only() = prefs.getBoolean(Keys.enablePort443Only, false) + fun forceLatestCovers() = prefs.getBoolean(Keys.forceLatestCovers, false) fun logLevel() = prefs.getInt(Keys.logLevel, 0) fun markChaptersReadFromMDList() = prefs.getBoolean(Keys.markChaptersFromMDList, false) - fun showR18Filter(): Boolean = prefs.getBoolean(Keys.showR18Filter, true) + fun showContentRatingFilter(): Boolean = prefs.getBoolean(Keys.showContentRatingFilter, true) fun addToLibraryAsPlannedToRead(): Boolean = prefs.getBoolean(Keys.addToLibraryAsPlannedToRead, false) fun createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, true) fun useCacheSource(): Boolean = prefs.getBoolean(Keys.useCacheSource, false) + + fun contentRatingSelections(): MutableSet = prefs.getStringSet(Keys.contentRating, setOf("safe", "suggestive"))!! + + fun sessionToken() = prefs.getString(Keys.sessionToken, "") + + fun setSessionToken(session: String) { + prefs.edit() + .putString(Keys.sessionToken, session) + .apply() + } + + fun refreshToken() = prefs.getString(Keys.refreshToken, "") + + fun setRefreshToken(refresh: String) { + prefs.edit() + .putString(Keys.refreshToken, refresh) + .apply() + } + + fun setTokens(refresh: String, session: String) { + prefs.edit() + .putString(Keys.sessionToken, session) + .putString(Keys.refreshToken, refresh) + .putLong(Keys.lastRefreshTokenTime, System.currentTimeMillis()) + .apply() + } + + fun lastRefreshTime(): Long { + return prefs.getLong(Keys.lastRefreshTokenTime, 0) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateJob.kt new file mode 100644 index 0000000000..ed0210b82b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateJob.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.data.similar + +import android.content.Context +import androidx.work.Data +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters + +class MangaCacheUpdateJob(private val context: Context, workerParams: WorkerParameters) : + Worker(context, workerParams) { + + override fun doWork(): Result { + MangaCacheUpdateService.start(context) + return Result.success() + } + + companion object { + const val TAG = "MangaCacheUpdate" + + fun doWorkNow() { + val work = OneTimeWorkRequestBuilder() + val data = Data.Builder() + work.setInputData(data.build()) + WorkManager.getInstance().enqueue(work.build()) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateService.kt new file mode 100644 index 0000000000..f850d9e8ef --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateService.kt @@ -0,0 +1,267 @@ +package eu.kanade.tachiyomi.data.similar + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.elvishew.xlog.XLog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.CachedManga +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.util.system.customize +import eu.kanade.tachiyomi.util.system.isServiceRunning +import eu.kanade.tachiyomi.util.system.notificationManager +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.TimeUnit + +class MangaCacheUpdateService( + val db: DatabaseHelper = Injekt.get() +) : Service() { + + /** + * Wake lock that will be held until the service is destroyed. + */ + private lateinit var wakeLock: PowerManager.WakeLock + var scope = CoroutineScope(Dispatchers.IO + Job()) + private var job: Job? = null + + /** + * Pending intent of action that cancels the library update + */ + private val cancelIntent by lazy { + NotificationReceiver.cancelCacheUpdatePendingBroadcast(this) + } + + private val progressNotification by lazy { + NotificationCompat.Builder(this, Notifications.CHANNEL_CACHE) + .customize( + this, + getString(R.string.cache_loading_progress_start), + R.drawable.ic_neko_notification, + true + ) + .setAutoCancel(true) + .addAction( + R.drawable.ic_close_24dp, + getString(android.R.string.cancel), + cancelIntent + ) + } + + /** + * Method called when the service is created. It injects dagger dependencies and acquire + * the wake lock. + */ + override fun onCreate() { + super.onCreate() + startForeground(Notifications.ID_CACHE_PROGRESS, progressNotification.build()) + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "SimilarUpdateService:WakeLock" + ) + wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) + } + + /** + * Method called when the service is destroyed. It destroys subscriptions and releases the wake + * lock. + */ + override fun onDestroy() { + job?.cancel() + scope.cancel() + if (wakeLock.isHeld) { + wakeLock.release() + } + super.onDestroy() + } + + /** + * This method needs to be implemented, but it's not used/needed. + */ + override fun onBind(intent: Intent) = null + + /** + * Method called when the service receives an intent. + * + * @param intent the start intent from. + * @param flags the flags of the command. + * @param startId the start id of this command. + * @return the start value of the command. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) return Service.START_NOT_STICKY + + job?.cancel() + val handler = CoroutineExceptionHandler { _, exception -> + XLog.e(exception) + stopSelf(startId) + cancelProgressNotification() + } + job = scope.launch(handler) { + updateCachedManga() + cancelProgressNotification() + } + job?.invokeOnCompletion { stopSelf(startId) } + + return START_REDELIVER_INTENT + } + + private suspend fun updateCachedManga() = withContext(Dispatchers.IO) { + + // Open the connection to the remove csv file + // https://stackoverflow.com/a/38374532/7718197 + XLog.i("CACHE: Starting download!") + val conn = URL(MdUtil.similarCacheMapping).openConnection() + val bufferIn = BufferedReader(InputStreamReader(conn.getInputStream())) + + // Loop through each line + val lines = ArrayList() + var str: String + while (true) { + str = bufferIn.readLine() ?: break + lines.add(str) + } + bufferIn.close() + + // Delete the old search database + kotlin.runCatching { + db.deleteAllCached().executeAsBlocking() + } + + // Insert into our database + XLog.i("CACHE: Beginning cache manga insert") + //db.insertCachedManga2(cachedManga) + val totalManga = lines.size + lines.mapIndexed { index, line -> + + // Return if job was canceled + if (job?.isCancelled == true) { + return@mapIndexed + } + + // Insert into the database + showProgressNotification(index, totalManga) + val strs = line.split(",").toTypedArray() + if(strs.size == 3) { + val regex = Regex("[^A-Za-z0-9 ]") + val manga = CachedManga(regex.replace(strs[1],""), strs[0], strs[2]) + db.insertCachedManga2Single(manga) + } + + } + db.optimizeCachedManga() + showProgressNotification(totalManga, totalManga) + XLog.i("CACHE: Inserted cached manga: ${db.getCachedMangaCount()}") + + // Done! + XLog.i("CACHE: Done with cached manga") + showResultNotification() + + } + + /** + * Shows the notification containing the currently updating manga and the progress. + * + * @param manga the manga that's being updated. + * @param current the current progress. + * @param total the total progress. + */ + private fun showProgressNotification(current: Int, total: Int) { + notificationManager.notify( + Notifications.ID_CACHE_PROGRESS, + progressNotification + .setContentTitle( + getString( + R.string.cache_loading_percent, + current, + total + ) + ) + .setProgress(total, current, false) + .build() + ) + } + + /** + * Shows the notification containing the result of the update done by the service. + * + * @param updates a list of manga with new updates. + */ + private fun showResultNotification(error: Boolean = false, message: String? = null) { + val title = if (error) { + message ?: getString(R.string.cache_loading_complete_error) + } else { + getString( + R.string.cache_loading_complete + ) + } + val result = NotificationCompat.Builder(this, Notifications.CHANNEL_CACHE) + .customize(this, title, R.drawable.ic_neko_notification) + .setAutoCancel(true) + NotificationManagerCompat.from(this) + .notify(Notifications.ID_CACHE_COMPLETE, result.build()) + } + + /** + * Cancels the progress notification. + */ + private fun cancelProgressNotification() { + notificationManager.cancel(Notifications.ID_CACHE_PROGRESS) + } + + companion object { + + /** + * Returns the status of the service. + * + * @param context the application context. + * @return true if the service is running, false otherwise. + */ + fun isRunning(context: Context): Boolean { + return context.isServiceRunning(MangaCacheUpdateService::class.java) + } + + /** + * Starts the service. It will be started only if there isn't another instance already + * running. + * + * @param context the application context. + */ + fun start(context: Context) { + if (!isRunning(context)) { + val intent = Intent(context, MangaCacheUpdateService::class.java) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + context.startService(intent) + } else { + context.startForegroundService(intent) + } + } + } + + /** + * Stops the service. + * + * @param context the application context. + */ + fun stop(context: Context) { + context.stopService(Intent(context, MangaCacheUpdateService::class.java)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarHttpService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarHttpService.kt deleted file mode 100644 index 0f5aa10251..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarHttpService.kt +++ /dev/null @@ -1,52 +0,0 @@ -package eu.kanade.tachiyomi.data.similar - -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import eu.kanade.tachiyomi.network.NetworkHelper -import kotlinx.serialization.json.Json -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.ResponseBody -import retrofit2.Call -import retrofit2.Retrofit -import retrofit2.http.GET -import retrofit2.http.Streaming -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -interface SimilarHttpService { - companion object { - fun create(): SimilarHttpService { - - // unzip interceptor which will add the correct headers - val unzipInterceptor = Interceptor { - val res = it.proceed(it.request()) - res.newBuilder() - .header("Content-Encoding", "gzip") - .header("Content-Type", "application/json") - .build() - } - - // actual builder, which will parse the underlying json file - val contentType = "application/json".toMediaType() - val restAdapter = Retrofit.Builder() - .baseUrl("https://raw.githubusercontent.com") - .addConverterFactory(Json {}.asConverterFactory(contentType)) - .client( - Injekt.get().client - .newBuilder() - .addNetworkInterceptor(unzipInterceptor) - .build() - ) - .build() - return restAdapter.create(SimilarHttpService::class.java) - } - } - - @Streaming - @GET("/goldbattle/MangadexRecomendations/master/output/mangas_compressed.json.gz") - fun getSimilarResults(): Call - - @Streaming - @GET("/goldbattle/MangadexRecomendations/master/output/md2external.json.gz") - fun getCachedManga(): Call -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarUpdateJob.kt deleted file mode 100644 index 63eca76bd2..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarUpdateJob.kt +++ /dev/null @@ -1,92 +0,0 @@ -package eu.kanade.tachiyomi.data.similar - -import android.content.Context -import android.net.Uri -import androidx.work.Constraints -import androidx.work.Data -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.Worker -import androidx.work.WorkerParameters -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit - -class SimilarUpdateJob(private val context: Context, workerParams: WorkerParameters) : - Worker(context, workerParams) { - - override fun doWork(): Result { - val localFile = inputData.getString("localFile") - val cachedManga = inputData.getBoolean("cachedManga", false) - - SimilarUpdateService.start(context, localFile, cachedManga) - return Result.success() - } - - companion object { - const val TAG = "RelatedUpdate" - - fun setupTask(skipInitial: Boolean = false) { - - val preferences = Injekt.get() - val enabled = preferences.similarEnabled().get() - val interval = preferences.similarUpdateInterval().getOrDefault() - if (enabled) { - - // We are enabled, so construct the constraints - val wifiRestriction = if (preferences.similarOnlyOverWifi()) - NetworkType.UNMETERED - else - NetworkType.CONNECTED - val constraints = Constraints.Builder() - .setRequiredNetworkType(wifiRestriction) - .build() - - // If we are not skipping the initial then run it right now - // Note that we won't run it if the constraints are not satisfied - if (!skipInitial) { - WorkManager.getInstance().enqueue(OneTimeWorkRequestBuilder().setConstraints(constraints).build()) - } - - // Finally build the periodic request - val request = PeriodicWorkRequestBuilder( - interval.toLong(), TimeUnit.DAYS, - 1, TimeUnit.HOURS - ) - .addTag(TAG) - .setConstraints(constraints) - .build() - if (interval > 0) { - WorkManager.getInstance().enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) - } else { - WorkManager.getInstance().cancelAllWorkByTag(TAG) - } - } else { - WorkManager.getInstance().cancelAllWorkByTag(TAG) - } - } - - fun doWorkNow(updateCachedManga: Boolean = false) { - val work = OneTimeWorkRequestBuilder() - if (updateCachedManga) { - val data = Data.Builder() - data.putBoolean("cachedManga", true) - work.setInputData(data.build()) - } - WorkManager.getInstance().enqueue(work.build()) - } - - fun doWorkNowLocal(localFile: Uri) { - val data = Data.Builder() - data.putString("localFile", localFile.toString()) - val work = OneTimeWorkRequestBuilder() - work.setInputData(data.build()) - WorkManager.getInstance().enqueue(work.build()) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarUpdateService.kt deleted file mode 100644 index 4712b721e1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/similar/SimilarUpdateService.kt +++ /dev/null @@ -1,445 +0,0 @@ -package eu.kanade.tachiyomi.data.similar - -import android.app.Service -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.PowerManager -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.text.isDigitsOnly -import com.elvishew.xlog.XLog -import com.squareup.moshi.JsonReader -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.CachedManga -import eu.kanade.tachiyomi.data.database.models.MangaSimilarImpl -import eu.kanade.tachiyomi.data.notification.NotificationReceiver -import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.util.system.customize -import eu.kanade.tachiyomi.util.system.isServiceRunning -import eu.kanade.tachiyomi.util.system.notificationManager -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okio.BufferedSource -import okio.buffer -import okio.sink -import okio.source -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.io.File -import java.io.InputStream -import java.util.concurrent.TimeUnit - -class SimilarUpdateService( - val db: DatabaseHelper = Injekt.get() -) : Service() { - - /** - * Wake lock that will be held until the service is destroyed. - */ - private lateinit var wakeLock: PowerManager.WakeLock - - var similarServiceScope = CoroutineScope(Dispatchers.IO + Job()) - - /** - * Subscription where the update is done. - */ - private var job: Job? = null - - private var cachedMangaJob: Job? = null - - /** - * Pending intent of action that cancels the library update - */ - private val cancelIntent by lazy { - NotificationReceiver.cancelSimilarUpdatePendingBroadcast(this) - } - - private val progressNotification by lazy { - NotificationCompat.Builder(this, Notifications.CHANNEL_SIMILAR) - .customize( - this, - getString(R.string.similar_loading_progress_start), - R.drawable.ic_neko_notification, - true - ) - .setAutoCancel(true) - .addAction( - R.drawable.ic_close_24dp, - getString(android.R.string.cancel), - cancelIntent - ) - } - - /** - * Method called when the service is created. It injects dagger dependencies and acquire - * the wake lock. - */ - override fun onCreate() { - super.onCreate() - startForeground(Notifications.ID_SIMILAR_PROGRESS, progressNotification.build()) - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "SimilarUpdateService:WakeLock" - ) - wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) - } - - /** - * Method called when the service is destroyed. It destroys subscriptions and releases the wake - * lock. - */ - override fun onDestroy() { - job?.cancel() - cachedMangaJob?.cancel() - similarServiceScope.cancel() - if (wakeLock.isHeld) { - wakeLock.release() - } - super.onDestroy() - } - - /** - * This method needs to be implemented, but it's not used/needed. - */ - override fun onBind(intent: Intent) = null - - /** - * Method called when the service receives an intent. - * - * @param intent the start intent from. - * @param flags the flags of the command. - * @param startId the start id of this command. - * @return the start value of the command. - */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent == null) return Service.START_NOT_STICKY - - val cachedManga = intent.getBooleanExtra("cachedManga", false) - - if (cachedManga) { - cachedMangaJob?.cancel() - val handler = CoroutineExceptionHandler { _, exception -> - XLog.e(exception) - stopSelf(startId) - cancelProgressNotification() - } - cachedMangaJob = similarServiceScope.launch(handler) { - updateCachedManga() - cancelProgressNotification() - } - cachedMangaJob?.invokeOnCompletion { stopSelf(startId) } - } else { - val localFile = intent.getStringExtra("localFile") - - // Unsubscribe from any previous subscription if needed. - job?.cancel() - val handler = CoroutineExceptionHandler { _, exception -> - XLog.e(exception) - stopSelf(startId) - showResultNotification(true, exception.message) - cancelProgressNotification() - } - job = similarServiceScope.launch(handler) { - updateSimilar(localFile) - } - job?.invokeOnCompletion { stopSelf(startId) } - } - - return START_REDELIVER_INTENT - } - - private suspend fun updateCachedManga() = withContext(Dispatchers.IO) { - val response = SimilarHttpService.create().getCachedManga().execute() - if (!response.isSuccessful) { - throw Exception("Error trying to download cached manga file") - } - val destinationFile = File(filesDir, "neko-cached.json") - val buffer = destinationFile.sink().buffer() - // write json to file - response.body()?.byteStream()?.source()?.use { input -> - buffer.use { output -> - output.writeAll(input) - } - } - val reader = JsonReader.of(destinationFile.source().buffer()) - - kotlin.runCatching { - db.deleteAllCached().executeAsBlocking() - } - - val cachedManga = getCachedManga(reader) - XLog.i("Beginning cache manga insert") - db.insertCachedManga2(cachedManga) - XLog.i("inserted cached manga: ${cachedManga.size}") - XLog.i("Done with cached manga") - - destinationFile.delete() - reader.close() - } - - fun getCachedManga(reader: JsonReader): MutableList { - var processingManga = false - var processingTitle = false - var mangaId: String? = null - var mangaTitle: String? = null - val manga = mutableListOf() - - while (reader.peek() != JsonReader.Token.END_DOCUMENT) { - val nextToken = reader.peek() - if (JsonReader.Token.BEGIN_OBJECT == nextToken) { - reader.beginObject() - } else if (JsonReader.Token.NAME == nextToken) { - val name = reader.nextName() - if (!processingManga && name.isDigitsOnly()) { - processingManga = true - // similar add id - mangaId = name - } else if (name == "title") { - processingTitle = true - } - } else if (JsonReader.Token.STRING == nextToken) { - if (processingTitle) { - mangaTitle = reader.nextString() - processingTitle = false - } else { - reader.nextString() - } - } else if (JsonReader.Token.END_OBJECT == nextToken) { - if (processingManga && mangaId != null && mangaTitle != null) { - manga.add( - CachedManga( - mangaId.toLong(), - mangaTitle, - ) - ) - processingManga = false - processingTitle = false - mangaId = null - mangaTitle = null - } - reader.endObject() - } - } - return manga - } - - /** - * Method that updates the similar database for manga - */ - private suspend fun updateSimilar(localFile: String?) = withContext(Dispatchers.IO) { - - // If we do not have a local file, then we should fetch it from network - // Otherwise try to load it from the local file destination - // NOTE: the path is a URI if we are loading it locally - // NOTE: the URI requires us to use android content resolver to stream it - val listSimilar: List - if (localFile == null) { - val response = SimilarHttpService.create().getSimilarResults().execute() - if (!response.isSuccessful) { - throw Exception("Error trying to download similar file") - } - val destinationFile = File(filesDir, "neko-similar.json") - val buffer = destinationFile.sink().buffer() - // write json to file - response.body()?.byteStream()?.source()?.use { input -> - buffer.use { output -> - output.writeAll(input) - } - } - val reader = JsonReader.of(destinationFile.source().buffer()) - listSimilar = getSimilar(reader) - destinationFile.delete() - reader.close() - } else { - if (localFile.substring(localFile.lastIndexOf(".") + 1) != "json") { - throw Exception("We can only load .json similar database files") - } - val localFileUri = Uri.parse(localFile) - val localFileStream: InputStream? = contentResolver.openInputStream(localFileUri) - if (localFileStream == null) { - throw Exception("Unable to open file from disk") - } - val source: BufferedSource = localFileStream.source().buffer() - val reader = JsonReader.of(source) - listSimilar = getSimilar(reader) - reader.close() - } - - // Loop through each and insert into the databas - val totalManga = listSimilar.size - val dataToInsert = listSimilar.mapIndexed { index, similarFromJson -> - showProgressNotification(index, totalManga) - if (similarFromJson.similarIds.size != similarFromJson.similarTitles.size) { - return@mapIndexed null - } - val similar = MangaSimilarImpl() - similar.id = index.toLong() - similar.manga_id = similarFromJson.id.toLong() - similar.matched_ids = similarFromJson.similarIds.joinToString(MangaSimilarImpl.DELIMITER) - similar.matched_titles = similarFromJson.similarTitles.joinToString(MangaSimilarImpl.DELIMITER) - return@mapIndexed similar - }.filterNotNull() - - // Delete the old similar table, and then insert into the database - showProgressNotification(dataToInsert.size, totalManga) - if (dataToInsert.isNotEmpty()) { - db.deleteAllSimilar().executeAsBlocking() - db.insertSimilar(dataToInsert).executeAsBlocking() - } - showResultNotification(!this.isActive) - cancelProgressNotification() - } - - private fun getSimilar(reader: JsonReader): List { - - var processingManga = false - var processingTitles = false - var mangaId: String? = null - var similarIds = mutableListOf() - var similarTitles = mutableListOf() - var similars = mutableListOf() - - while (reader.peek() != JsonReader.Token.END_DOCUMENT) { - val nextToken = reader.peek() - - if (JsonReader.Token.BEGIN_OBJECT == nextToken) { - reader.beginObject() - } else if (JsonReader.Token.NAME == nextToken) { - val name = reader.nextName() - if (!processingManga && name.isDigitsOnly()) { - processingManga = true - // similar add id - mangaId = name - } else if (name == "m_titles") { - processingTitles = true - } - } else if (JsonReader.Token.BEGIN_ARRAY == nextToken) { - reader.beginArray() - } else if (JsonReader.Token.END_ARRAY == nextToken) { - reader.endArray() - if (processingTitles) { - processingManga = false - processingTitles = false - similars.add(SimilarFromJson(mangaId!!, similarIds.toList(), similarTitles.toList())) - mangaId = null - similarIds = mutableListOf() - similarTitles = mutableListOf() - } - } else if (JsonReader.Token.NUMBER.equals(nextToken)) { - similarIds.add(reader.nextInt().toString()) - } else if (JsonReader.Token.STRING.equals(nextToken)) { - if (processingTitles) { - similarTitles.add(reader.nextString()) - } - } else if (JsonReader.Token.END_OBJECT.equals(nextToken)) { - reader.endObject() - } - } - - return similars - } - - data class SimilarFromJson(val id: String, val similarIds: List, val similarTitles: List) - - /** - * Shows the notification containing the currently updating manga and the progress. - * - * @param manga the manga that's being updated. - * @param current the current progress. - * @param total the total progress. - */ - private fun showProgressNotification(current: Int, total: Int) { - notificationManager.notify( - Notifications.ID_SIMILAR_PROGRESS, - progressNotification - .setContentTitle( - getString( - R.string.similar_loading_percent, - current, - total - ) - ) - .setProgress(total, current, false) - .build() - ) - } - - /** - * Shows the notification containing the result of the update done by the service. - * - * @param updates a list of manga with new updates. - */ - private fun showResultNotification(error: Boolean = false, message: String? = null) { - - val title = if (error) { - message ?: getString(R.string.similar_loading_complete_error) - } else { - getString( - R.string.similar_loading_complete - ) - } - - val result = NotificationCompat.Builder(this, Notifications.CHANNEL_SIMILAR) - .customize(this, title, R.drawable.ic_neko_notification) - .setAutoCancel(true) - NotificationManagerCompat.from(this) - .notify(Notifications.ID_SIMILAR_COMPLETE, result.build()) - } - - /** - * Cancels the progress notification. - */ - private fun cancelProgressNotification() { - notificationManager.cancel(Notifications.ID_SIMILAR_PROGRESS) - } - - companion object { - - /** - * Returns the status of the service. - * - * @param context the application context. - * @return true if the service is running, false otherwise. - */ - fun isRunning(context: Context): Boolean { - return context.isServiceRunning(SimilarUpdateService::class.java) - } - - /** - * Starts the service. It will be started only if there isn't another instance already - * running. - * - * @param context the application context. - * @param localFile URI of the file we want to load locally, or null to get from the network - */ - fun start(context: Context, localFile: String? = null, cachedManga: Boolean = false) { - if (!isRunning(context)) { - val intent = Intent(context, SimilarUpdateService::class.java) - intent.putExtra("localFile", localFile) - intent.putExtra("cachedManga", cachedManga) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - context.startService(intent) - } else { - context.startForegroundService(intent) - } - } - } - - /** - * Stops the service. - * - * @param context the application context. - */ - fun stop(context: Context) { - context.stopService(Intent(context, SimilarUpdateService::class.java)) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index be7cf66434..4a0f562c19 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -6,8 +6,11 @@ import com.elvishew.xlog.XLog import com.google.gson.Gson import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.online.MangaDexLoginHelper +import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.util.log.XLogLevel import okhttp3.Cache +import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -23,6 +26,8 @@ class NetworkHelper(val context: Context) { private val preferences: PreferencesHelper by injectLazy() + private val mangaDexLoginHelper: MangaDexLoginHelper by injectLazy() + private val cacheDir = File(context.cacheDir, "network_cache") private val cacheSize = 5L * 1024 * 1024 // 5 MiB @@ -30,7 +35,7 @@ class NetworkHelper(val context: Context) { val cookieManager = AndroidCookieJar() private val bucket = TokenBuckets.builder().withCapacity(2) - .withFixedIntervalRefillStrategy(2, 1, TimeUnit.SECONDS).build() + .withFixedIntervalRefillStrategy(5, 1, TimeUnit.SECONDS).build() private val rateLimitInterceptor = Interceptor { bucket.consume() @@ -73,7 +78,7 @@ class NetworkHelper(val context: Context) { Gson().fromJson(message, Any::class.java) XLog.tag("||NEKO-NETWORK-JSON").disableStackTrace().json(message) } catch (ex: Exception) { - XLog.tag("||NEKO-NETWORK").nb().disableStackTrace().d(message) + XLog.tag("||NEKO-NETWORK").disableBorder().disableStackTrace().d(message) } } } @@ -86,6 +91,10 @@ class NetworkHelper(val context: Context) { return nonRateLimitedClient.newBuilder().addNetworkInterceptor(rateLimitInterceptor).build() } + private fun buildRateLimitedAuthenticatedClient(): OkHttpClient { + return buildRateLimitedClient().newBuilder().authenticator(TokenAuthenticator(mangaDexLoginHelper)).build() + } + fun buildCloudFlareClient(): OkHttpClient { return nonRateLimitedClient.newBuilder() .addInterceptor(UserAgentInterceptor()) @@ -98,4 +107,11 @@ class NetworkHelper(val context: Context) { val cloudFlareClient = buildCloudFlareClient() val client = buildRateLimitedClient() + + val authClient = buildRateLimitedAuthenticatedClient() + + val headers = Headers.Builder().apply { + add("User-Agent", "Neko " + System.getProperty("http.agent")) + add("Referer", MdUtil.baseUrl) + }.build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/TokenAuthenticator.kt b/app/src/main/java/eu/kanade/tachiyomi/network/TokenAuthenticator.kt new file mode 100644 index 0000000000..fe833b3f8c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/network/TokenAuthenticator.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.network + +import com.elvishew.xlog.XLog +import eu.kanade.tachiyomi.source.online.MangaDexLoginHelper +import eu.kanade.tachiyomi.source.online.utils.MdUtil +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Headers +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route + +class TokenAuthenticator(val loginHelper: MangaDexLoginHelper) : + Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + XLog.i("Detected Auth error ${response.code} on ${response.request.url}") + val token = refreshToken(loginHelper) + return if (token.isEmpty()) { + null + } else { + response.request.newBuilder().header("Authorization", token).build() + } + } + + @Synchronized + fun refreshToken(loginHelper: MangaDexLoginHelper): String { + var validated: Boolean = false + + runBlocking { + val checkToken = loginHelper.isAuthenticated(MdUtil.getAuthHeaders(Headers.Builder().build(), loginHelper.preferences)) + if (checkToken) { + XLog.i("Token is valid, other thread must have refreshed it") + validated = true + } + if (validated.not()) { + XLog.i("Token is invalid trying to refresh") + validated = loginHelper.refreshToken(MdUtil.getAuthHeaders(Headers.Builder().build(), loginHelper.preferences)) + } + + if (validated.not()) { + XLog.i("Did not refresh token, trying to login") + validated = loginHelper.login() + } + } + return when { + validated -> "bearer: ${loginHelper.preferences.sessionToken()!!}" + else -> "" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt index 88bc6e991e..a138d955db 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -89,10 +89,10 @@ interface Source { /** * Returns an observable with all the relatable for a manga. * - * @param page the page number to retrieve. * @param manga the manga to update. + * @param refresh if we should get the latest */ - fun fetchMangaSimilarObservable(manga: Manga): Observable + fun fetchMangaSimilarObservable(manga: Manga, refresh: Boolean): Observable /** * Returns a updated details for a manga and the chapter list diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt index a377c36eaa..452dad40f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt @@ -1,3 +1,3 @@ package eu.kanade.tachiyomi.source.model -data class MangasPage(val mangas: List, val hasNextPage: Boolean) +data class MangasPage(val manga: List, val hasNextPage: Boolean) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt index 2d020c5c98..3074adadae 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt @@ -26,6 +26,8 @@ interface SChapter : Serializable { // chapter id from mangadex var mangadex_chapter_id: String + var old_mangadex_id: String? + fun chapterLog(): String { return "$name - $scanlator" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt index 32ab354b60..8c51173278 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt @@ -21,4 +21,6 @@ class SChapterImpl : SChapter { override var language: String? = null override var mangadex_chapter_id: String = "" + + override var old_mangadex_id: String? = null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt index af772a9fe5..f2a3a7847b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -72,8 +72,9 @@ abstract class HttpSource : Source { * Headers builder for request. */ protected fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", "Tachiyomi " + System.getProperty("http.agent")) + add("User-Agent", "Neko " + System.getProperty("http.agent")) add("X-Requested-With", "XMLHttpRequest") + add("Content-Type", "application/json") add("Referer", MdUtil.baseUrl) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDex.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDex.kt index 50ed2220d9..93d0fd3008 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDex.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDex.kt @@ -1,12 +1,10 @@ package eu.kanade.tachiyomi.source.online -import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST -import eu.kanade.tachiyomi.network.POSTWithCookie import eu.kanade.tachiyomi.network.asObservable import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.await @@ -24,88 +22,58 @@ import eu.kanade.tachiyomi.source.online.handlers.PageHandler import eu.kanade.tachiyomi.source.online.handlers.PopularHandler import eu.kanade.tachiyomi.source.online.handlers.SearchHandler import eu.kanade.tachiyomi.source.online.handlers.SimilarHandler -import eu.kanade.tachiyomi.source.online.handlers.serializers.ApiChapterSerializer import eu.kanade.tachiyomi.source.online.handlers.serializers.ImageReportResult -import eu.kanade.tachiyomi.source.online.handlers.serializers.IsLoggedInSerializer import eu.kanade.tachiyomi.source.online.utils.FollowStatus import eu.kanade.tachiyomi.source.online.utils.MdUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString import okhttp3.CacheControl -import okhttp3.FormBody -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import rx.Observable import timber.log.Timber import uy.kohesive.injekt.injectLazy -import java.io.EOFException -import java.net.URLEncoder import java.util.Date import kotlin.collections.set -open class MangaDex() : HttpSource() { +open class MangaDex : HttpSource() { private val preferences: PreferencesHelper by injectLazy() - private val tokenTracker = hashMapOf() + private val filterHandler: FilterHandler by injectLazy() - private fun clientBuilder(): OkHttpClient = clientBuilder(preferences.r18()!!.toInt()) - - private fun clientBuilder( - r18Toggle: Int, - okHttpClient: OkHttpClient = network.client - ): OkHttpClient = okHttpClient.newBuilder() - .addNetworkInterceptor { chain -> - var originalCookies = chain.request().header("Cookie") ?: "" - val newReq = chain - .request() - .newBuilder() - .header("Cookie", "$originalCookies; ${cookiesHeader(r18Toggle)}") - .build() - chain.proceed(newReq) - }.build() - - private fun cookiesHeader(r18Toggle: Int): String { - val cookies = mutableMapOf() - cookies["mangadex_h_toggle"] = r18Toggle.toString() - return buildCookies(cookies) - } + private val followsHandler: FollowsHandler by injectLazy() - private fun buildCookies(cookies: Map) = - cookies.entries.joinToString(separator = "; ", postfix = ";") { - "${URLEncoder.encode(it.key, "UTF-8")}=${URLEncoder.encode(it.value, "UTF-8")}" - } + private val mangaHandler: MangaHandler by injectLazy() - private fun buildR18Client(filters: FilterList): OkHttpClient { - filters.forEach { filter -> - when (filter) { - is FilterHandler.R18 -> { - return when (filter.state) { - 1 -> clientBuilder(ALL) - 2 -> clientBuilder(ONLY_R18) - 3 -> clientBuilder(NO_R18) - else -> clientBuilder() - } - } - } - } - return clientBuilder() - } + private val popularHandler: PopularHandler by injectLazy() + + private val searchHandler: SearchHandler by injectLazy() + + private val pageHandler: PageHandler by injectLazy() + + private val similarHandler: SimilarHandler by injectLazy() + + private val loginHelper: MangaDexLoginHelper by injectLazy() + + private val mangaPlusHandler: MangaPlusHandler by injectLazy() + + // chapter url where we get the token, last request time + private val tokenTracker = hashMapOf() override suspend fun updateFollowStatus(mangaID: String, followStatus: FollowStatus): Boolean { - return FollowsHandler(clientBuilder(), headers, preferences).updateFollowStatus(mangaID, followStatus) + return followsHandler.updateFollowStatus(mangaID, followStatus) } open fun fetchRandomMangaId(): Observable { - return MangaHandler(clientBuilder(), headers, getLangsToShow()).fetchRandomMangaId() + return mangaHandler.fetchRandomMangaId() } override fun fetchPopularManga(page: Int): Observable { - return PopularHandler(clientBuilder(), headers).fetchPopularManga(page) + return popularHandler.fetchPopularManga(page) } override fun fetchSearchManga( @@ -113,7 +81,7 @@ open class MangaDex() : HttpSource() { query: String, filters: FilterList ): Observable { - return SearchHandler(buildR18Client(filters), headers, getLangsToShow()).fetchSearchManga( + return searchHandler.fetchSearchManga( page, query, filters @@ -121,67 +89,54 @@ open class MangaDex() : HttpSource() { } override fun fetchFollows(): Observable { - return FollowsHandler(clientBuilder(), headers, preferences).fetchFollows() + return followsHandler.fetchFollows() } override fun fetchMangaDetailsObservable(manga: SManga): Observable { - return MangaHandler(clientBuilder(), headers, getLangsToShow(), preferences.forceLatestCovers()).fetchMangaDetailsObservable( - manga - ) + return mangaHandler.fetchMangaDetailsObservable(manga) } override suspend fun fetchMangaDetails(manga: SManga): SManga { - return MangaHandler(clientBuilder(), headers, getLangsToShow(), preferences.forceLatestCovers()).fetchMangaDetails(manga) + return mangaHandler.fetchMangaDetails(manga) } override suspend fun fetchMangaAndChapterDetails(manga: SManga): Pair> { - val pair = MangaHandler(clientBuilder(), headers, getLangsToShow(), preferences.forceLatestCovers()).fetchMangaAndChapterDetails( - manga - ) - return pair + return mangaHandler.fetchMangaAndChapterDetails(manga) } override fun fetchChapterListObservable(manga: SManga): Observable> { - return MangaHandler( - clientBuilder(), - headers, - getLangsToShow(), - ).fetchChapterListObservable(manga) + return mangaHandler.fetchChapterListObservable(manga) } - open suspend fun getMangaIdFromChapterId(urlChapterId: String): Int { - return MangaHandler(clientBuilder(), headers, getLangsToShow()).getMangaIdFromChapterId(urlChapterId) + open suspend fun getMangaIdFromChapterId(urlChapterId: String): String { + return mangaHandler.getMangaIdFromChapterId(urlChapterId) } override suspend fun fetchChapterList(manga: SManga): List { - return MangaHandler(clientBuilder(), headers, getLangsToShow()).fetchChapterList(manga) + return mangaHandler.fetchChapterList(manga) } override fun fetchPageList(chapter: SChapter): Observable> { - val imageServer = preferences.imageServer().takeIf { it in SERVER_PREF_ENTRY_VALUES } - ?: SERVER_PREF_ENTRY_VALUES.first() - val dataSaver = when (preferences.dataSaver()) { - true -> "1" - false -> "0" - } - return PageHandler(clientBuilder(), headers, imageServer, dataSaver).fetchPageList(chapter) + return pageHandler.fetchPageList(chapter) } override fun fetchImage(page: Page): Observable { if (page.imageUrl!!.contains("mangaplus", true)) { - return MangaPlusHandler(nonRateLimitedClient).client.newCall(GET(page.imageUrl!!, headers)) + return mangaPlusHandler.client.newCall(GET(page.imageUrl!!, headers)) .asObservableSuccess() } else { return nonRateLimitedClient.newCallWithProgress(imageRequest(page), page).asObservable().doOnNext { response -> val byteSize = response.peekBody(Long.MAX_VALUE).bytes().size + val duration = response.receivedResponseAtMillis - response.sentRequestAtMillis + val cache = response.header("X-Cache", "") == "HIT" val result = ImageReportResult( - page.imageUrl!!, response.isSuccessful, byteSize + page.imageUrl!!, response.isSuccessful, byteSize, cache, duration ) - val jsonString = MdUtil.jsonParser.encodeToString(ImageReportResult.serializer(), result) + val jsonString = MdUtil.jsonParser.encodeToString(result) - val postResult = clientBuilder().newCall( + val postResult = network.client.newCall( POST( MdUtil.reportUrl, headers, @@ -203,63 +158,55 @@ open class MangaDex() : HttpSource() { } open fun imageRequest(page: Page): Request { - val url = when { - // Legacy - page.url.isEmpty() -> page.imageUrl!! - // Some images are hosted elsewhere - !page.url.startsWith("http") -> baseUrl + page.url.substringBefore(",") + page.imageUrl - // New chapters on MD servers - page.url.startsWith(MdUtil.imageUrl) -> page.url.substringBefore(",") + page.imageUrl - // MD@Home token handling - else -> { - val tokenLifespan = 5 * 60 * 1000 - val data = page.url.split(",") - var tokenedServer = data[0] - if (Date().time - data[2].toLong() > tokenLifespan) { + val data = page.url.split(",") + val mdAtHomeServerUrl = + when (Date().time - data[2].toLong() > MdUtil.mdAtHomeTokenLifespan) { + false -> data[0] + true -> { val tokenRequestUrl = data[1] - val cacheControl = if (Date().time - (tokenTracker[tokenRequestUrl] ?: 0) > tokenLifespan) { - tokenTracker[tokenRequestUrl] = Date().time - CacheControl.FORCE_NETWORK - } else { - CacheControl.FORCE_CACHE - } - val jsonData = client.newCall(GET(tokenRequestUrl, headers, cacheControl)).execute().body!!.string() - val networkApiChapter = MdUtil.jsonParser.decodeFromString(ApiChapterSerializer.serializer(), jsonData) - tokenedServer = networkApiChapter.data.server - XLog.d("new MD@Home token %s", tokenedServer) + val cacheControl = + if (Date().time - ( + tokenTracker[tokenRequestUrl] + ?: 0 + ) > MdUtil.mdAtHomeTokenLifespan + ) { + tokenTracker[tokenRequestUrl] = Date().time + CacheControl.FORCE_NETWORK + } else { + CacheControl.FORCE_CACHE + } + MdUtil.atHomeUrlHostUrl(tokenRequestUrl, client, cacheControl) } - tokenedServer + page.imageUrl } - } - return GET(url, headers) + return GET(mdAtHomeServerUrl + page.imageUrl, headers) } override suspend fun fetchAllFollows(forceHd: Boolean): List { - return FollowsHandler(clientBuilder(), headers, preferences).fetchAllFollows(forceHd) + return followsHandler.fetchAllFollows() } open suspend fun updateReadingProgress(track: Track): Boolean { - return FollowsHandler(clientBuilder(), headers, preferences).updateReadingProgress(track) + return followsHandler.updateReadingProgress(track) } open suspend fun updateRating(track: Track): Boolean { - return FollowsHandler(clientBuilder(), headers, preferences).updateRating(track) + return followsHandler.updateRating(track) } override suspend fun fetchTrackingInfo(url: String): Track { if (!isLogged()) { throw Exception("Not Logged in to MangaDex") } - return FollowsHandler(clientBuilder(), headers, preferences).fetchTrackingInfo(url) + return followsHandler.fetchTrackingInfo(url) } - override fun fetchMangaSimilarObservable(manga: Manga): Observable { - return SimilarHandler(preferences).fetchSimilar(manga) + override fun fetchMangaSimilarObservable(manga: Manga, refresh: Boolean): Observable { + return similarHandler.fetchSimilarObserable(manga, refresh) } override fun isLogged(): Boolean { - val httpUrl = baseUrl.toHttpUrlOrNull()!! - return network.cookieManager.get(httpUrl).any { it.name == REMEMBER_ME } + return preferences.sourcePassword(this).isNullOrBlank().not() && preferences.sessionToken().isNullOrBlank().not() + && preferences.refreshToken().isNullOrBlank().not() } override suspend fun login( @@ -267,101 +214,30 @@ open class MangaDex() : HttpSource() { password: String, twoFactorCode: String ): Boolean { - return withContext(Dispatchers.IO) { - val formBody = FormBody.Builder() - .add("login_username", username) - .add("login_password", password) - .add("no_js", "1") - .add("remember_me", "1") - - twoFactorCode.let { - formBody.add("two_factor", it) - } - - - - runCatching { - clientBuilder().newCall( - POST( - "${MdUtil.apiUrl}${MdUtil.apiLogin}", - headers, - formBody.build() - ) - ).await() - } - val response = clientBuilder().newCall(GET(MdUtil.apiUrl + MdUtil.isLoggedInApi, headers)).await() - val jsonData = response.body!!.string() - val result = MdUtil.jsonParser.decodeFromString(IsLoggedInSerializer.serializer(), jsonData) - return@withContext result.code == 200 - } + return loginHelper.login(username, password) } suspend fun checkIfUp(): Boolean { return withContext(Dispatchers.IO) { - val response = clientBuilder().newCall(GET(MdUtil.apiUrl + MdUtil.apiManga + 1)).await() - response.isSuccessful + true + // val response = network.client.newCall(GET(MdUtil.apiUrl + MdUtil.apiManga + 1)).await() + // response.isSuccessful } } override suspend fun logout(): Logout { return withContext(Dispatchers.IO) { - // https://mangadex.org/ajax/actions.ajax.php?function=logout - val httpUrl = baseUrl.toHttpUrlOrNull()!! - val listOfDexCookies = network.cookieManager.get(httpUrl) - val cookie = listOfDexCookies.find { it.name == REMEMBER_ME } - val token = cookie?.value - if (token.isNullOrEmpty()) { - return@withContext Logout(true) - } - val catch = runCatching { - val result = clientBuilder().newCall( - POSTWithCookie( - "$baseUrl/ajax/actions.ajax.php?function=logout", - REMEMBER_ME, - token, - headers - ) - ).execute() - } - - catch.exceptionOrNull()?.let { - if (!(it is EOFException)) { - XLog.e("error logging out", it) - return@withContext Logout(false, "Unknown error") - } - } - - val response = clientBuilder().newCall(GET(MdUtil.apiUrl + MdUtil.isLoggedInApi, headers)).await() - if (response.code == 502) { - return@withContext Logout(false, "MangaDex appears to be down, unable to logout") - } - - val jsonData = response.body!!.string() - - val result = MdUtil.jsonParser.decodeFromString(IsLoggedInSerializer.serializer(), jsonData) - if (result.code == 403) { - network.cookieManager.remove(httpUrl) - return@withContext Logout(true) - } - return@withContext Logout(false, "Unknown error") + network.client.newCall( + POST( + MdUtil.logoutUrl, + MdUtil.getAuthHeaders(headers, preferences) + ) + ).await() + return@withContext Logout(true) } } - fun getLangsToShow() = preferences.langsToShow().get().split(",") - override fun getFilterList(): FilterList { - return FilterHandler(preferences).getFilterList() - } - - companion object { - - // This number matches to the cookie - private const val NO_R18 = 0 - private const val ALL = 1 - private const val ONLY_R18 = 2 - private const val REMEMBER_ME = "mangadex_rememberme_token" - - val SERVER_PREF_ENTRIES = listOf("Automatic", "NA/EU 1", "NA/EU 2") - val SERVER_PREF_ENTRY_VALUES = listOf("0", "na", "na2") + return filterHandler.getMDFilterList() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexCache.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexCache.kt index e6e04c66ad..c747055205 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexCache.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.online import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.CachedManga import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.download.DownloadManager @@ -16,10 +17,14 @@ import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.isMerged import eu.kanade.tachiyomi.source.model.isMergedChapter +import eu.kanade.tachiyomi.source.online.handlers.ApiMangaParser +import eu.kanade.tachiyomi.source.online.handlers.FilterHandler import eu.kanade.tachiyomi.source.online.handlers.SimilarHandler import eu.kanade.tachiyomi.source.online.handlers.serializers.CacheApiMangaSerializer import eu.kanade.tachiyomi.source.online.utils.FollowStatus import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.CacheControl @@ -31,14 +36,18 @@ import org.isomorphism.util.TokenBuckets import org.json.JSONObject import rx.Observable import uy.kohesive.injekt.injectLazy +import java.util.* import java.util.concurrent.TimeUnit import kotlin.random.Random open class MangaDexCache() : MangaDex() { - private val preferences: PreferencesHelper by injectLazy() private val db: DatabaseHelper by injectLazy() + private val v5DbHelper: V5DbHelper by injectLazy() private val downloadManager: DownloadManager by injectLazy() + private val similarHandler: SimilarHandler by injectLazy() + private val filterHandler: FilterHandler by injectLazy() + val preferences: PreferencesHelper by injectLazy() // Max request of 30 per second, per domain we query private val bucket = TokenBuckets.builder().withCapacity(30) @@ -48,8 +57,6 @@ open class MangaDexCache() : MangaDex() { it.proceed(it.request()) } private val clientLessRateLimits = network.nonRateLimitedClient.newBuilder().addInterceptor(rateLimitInterceptor).build() - private val clientAnilist = clientLessRateLimits.newBuilder().build() - private val clientMyAnimeList = clientLessRateLimits.newBuilder().build() override suspend fun updateFollowStatus(mangaID: String, followStatus: FollowStatus): Boolean { throw Exception("Cache source cannot update follow status") @@ -69,16 +76,27 @@ open class MangaDexCache() : MangaDex() { // Next lets query the next set of manga from the database // NOTE: page id starts from 1, and we request 1 extra entry to see if there is still more + // NOTE: we will also filter out manga that are not in our content rating list the user wants! + // NOTE: (small hack of using the *rating* field to store the content rating....) val limit = 10 return db.getCachedMangaRange(page - 1, limit).asRxObservable().flatMapIterable { it } .map { cacheManga -> SManga.create().apply { - initialized = false - url = "/manga/${cacheManga.mangaId}/" + url = "/manga/${cacheManga.uuid}/" title = MdUtil.cleanString(cacheManga.title) - thumbnail_url = null + thumbnail_url = V5DbQueries.getAltCover(v5DbHelper.dbCovers, cacheManga.uuid) ?: MdUtil.imageUrlCacheNotFound + rating = cacheManga.rating } - }.toList().map { MangasPage(it.take(limit), it.size > limit) } + }.toList().map { + val haveMore = (it.size > limit) + val mangasClean = it.take(limit).filter { manga -> + manga.rating in preferences.contentRatingSelections() + } + mangasClean.forEach { manga -> + manga.rating = null + } + MangasPage(mangasClean, haveMore) + } } override fun fetchSearchManga( @@ -96,16 +114,27 @@ open class MangaDexCache() : MangaDex() { // Next lets query the next set of manga from the database // NOTE: page id starts from 1, and we request 1 extra entry to see if there is still more + // NOTE: we will also filter out manga that are not in our content rating list the user wants! + // NOTE: (small hack of using the *rating* field to store the content rating....) val limit = 10 return db.searchCachedManga(query, page - 1, limit).asRxObservable().flatMapIterable { it } .map { cacheManga -> SManga.create().apply { - initialized = false - url = "/manga/${cacheManga.mangaId}/" + url = "/manga/${cacheManga.uuid}/" title = MdUtil.cleanString(cacheManga.title) - thumbnail_url = null + thumbnail_url = V5DbQueries.getAltCover(v5DbHelper.dbCovers, cacheManga.uuid) ?: MdUtil.imageUrlCacheNotFound + rating = cacheManga.rating } - }.toList().map { MangasPage(it.take(limit), it.size > limit) } + }.toList().map { + val haveMore = (it.size > limit) + val mangasClean = it.take(limit).filter { manga -> + manga.rating in preferences.contentRatingSelections() + } + mangasClean.forEach { manga -> + manga.rating = null + } + MangasPage(mangasClean, haveMore) + } } override fun fetchFollows(): Observable { @@ -116,17 +145,14 @@ open class MangaDexCache() : MangaDex() { return clientLessRateLimits.newCall(apiRequest(manga)) .asObservableSuccess() .map { response -> - parseMangaCacheApi(response.body!!.string()) + parseMangaCacheApi(response) } } override suspend fun fetchMangaDetails(manga: SManga): SManga { return withContext(Dispatchers.IO) { var response = clientLessRateLimits.newCall(apiRequest(manga)).execute() - if (!response.isSuccessful) { - response = clientLessRateLimits.newCall(apiRequest(manga, true)).execute() - } - parseMangaCacheApi(response.body!!.string()) + parseMangaCacheApi(response) } } @@ -151,7 +177,7 @@ open class MangaDexCache() : MangaDex() { return Observable.just(emptyList()) } - override suspend fun getMangaIdFromChapterId(urlChapterId: String): Int { + override suspend fun getMangaIdFromChapterId(urlChapterId: String): String { throw Exception("Cache source cannot convert chapter id to manga id") } @@ -187,8 +213,8 @@ open class MangaDexCache() : MangaDex() { return Track.create(TrackManager.MDLIST) } - override fun fetchMangaSimilarObservable(manga: Manga): Observable { - return SimilarHandler(preferences).fetchSimilar(manga) + override fun fetchMangaSimilarObservable(manga: Manga, refresh: Boolean): Observable { + return similarHandler.fetchSimilarObserable(manga, refresh) } override fun isLogged(): Boolean { @@ -207,57 +233,63 @@ open class MangaDexCache() : MangaDex() { return FilterList(emptyList()) } - private fun apiRequest(manga: SManga, useOtherUrl: Boolean = true): Request { - val mangaId = MdUtil.getMangaId(manga.url).toLong() - val url = when { - useOtherUrl -> MdUtil.apiUrlCache - else -> MdUtil.apiUrlCdnCache - } - return GET(url + mangaId.toString().padStart(5, '0') + ".json", headers, CacheControl.FORCE_NETWORK) + private fun apiRequest(manga: SManga): Request { + val mangaId = MdUtil.getMangaId(manga.url) + return GET(MdUtil.similarCacheMangas + mangaId + ".json", headers, CacheControl.FORCE_NETWORK) } - private fun parseMangaCacheApi(jsonData: String): SManga { + private fun parseMangaCacheApi(response: Response): SManga { + + // Error check http response + if (response.code == 404) { + throw Exception("Manga has not been cached...") + } + if (response.isSuccessful.not() || response.code != 200) { + throw Exception("Error getting cache manga http code: ${response.code}") + } + try { + // Else lets try to parse the response body // Serialize the api response + val jsonData = response.body!!.string() val mangaReturn = SManga.create() val networkApiManga = MdUtil.jsonParser.decodeFromString(CacheApiMangaSerializer.serializer(), jsonData) - mangaReturn.url = "/manga/${networkApiManga.id}" + mangaReturn.url = "/manga/${networkApiManga.data.id}" // Convert from the api format - mangaReturn.title = MdUtil.cleanString(networkApiManga.title) - mangaReturn.description = "NOTE: THIS IS A CACHED MANGA ENTRY\n" + MdUtil.cleanDescription(networkApiManga.description) - mangaReturn.rating = networkApiManga.rating.toString() + mangaReturn.title = MdUtil.cleanString(networkApiManga.data.attributes.title["en"]!!) + mangaReturn.description = "NOTE: THIS IS A CACHED MANGA ENTRY\n" + MdUtil.cleanDescription(networkApiManga.data.attributes.description["en"]!!) + //mangaReturn.rating = networkApiManga.toString() + mangaReturn.thumbnail_url = V5DbQueries.getAltCover(v5DbHelper.dbCovers, networkApiManga.data.id) ?: MdUtil.imageUrlCacheNotFound // Get the external tracking ids for this manga - networkApiManga.external["al"].let { - mangaReturn.anilist_id = it + val networkManga = networkApiManga.data.attributes + networkManga.links?.let { + it["al"]?.let { mangaReturn.anilist_id = it } + it["kt"]?.let { mangaReturn.kitsu_id = it } + it["mal"]?.let { mangaReturn.my_anime_list_id = it } + it["mu"]?.let { mangaReturn.manga_updates_id = it } + it["ap"]?.let { mangaReturn.anime_planet_id = it } } - networkApiManga.external["kt"].let { - mangaReturn.kitsu_id = it + mangaReturn.status = when(networkManga.status) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.PUBLICATION_COMPLETE + "cancelled" -> SManga.CANCELLED + "hiatus" -> SManga.HIATUS + else -> SManga.UNKNOWN } - networkApiManga.external["mal"].let { - mangaReturn.my_anime_list_id = it - } - networkApiManga.external["mu"].let { - mangaReturn.manga_updates_id = it - } - networkApiManga.external["ap"].let { - mangaReturn.anime_planet_id = it - } - mangaReturn.status = SManga.UNKNOWN // List the labels for this manga - val genres = networkApiManga.demographic.toMutableList() - genres += networkApiManga.content - genres += networkApiManga.format - genres += networkApiManga.genre - genres += networkApiManga.theme - if (networkApiManga.is_r18) { - genres.add("Hentai") - } + val tags = filterHandler.getTags() + val genres = ( + listOf(networkManga.publicationDemographic?.capitalize(Locale.US)) + + networkManga.tags?.map { it.id } + ?.map { dexTagId -> tags.firstOrNull { tag -> tag.id == dexTagId } } + ?.map { tag -> tag?.name } + + listOf("Content Rating - " + (networkManga.contentRating?.capitalize(Locale.US) ?: "Unknown") + )) + .filterNotNull() mangaReturn.genre = genres.joinToString(", ") - mangaReturn.thumbnail_url = getThumbnail(mangaReturn.anilist_id, mangaReturn.my_anime_list_id) - return mangaReturn } catch (e: Exception) { XLog.e(e) @@ -265,46 +297,4 @@ open class MangaDexCache() : MangaDex() { } } - fun getThumbnail(anilist_id: String?, my_anime_list_id: String?): String { - // Query graph ql endpoint for our image - // https://stackoverflow.com/a/58923947 - if (anilist_id != null) { - val query = "{\n" + - " Media(id: ${anilist_id}, type: MANGA) {\n" + - " coverImage {\n" + - " extraLarge\n" + - " large\n" + - " }\n" + - " }\n" + - "}\n" - val json = JSONObject() - json.put("query", query) - val requestBody = json.toString().toRequestBody(null) - val request = Request.Builder().url("https://graphql.anilist.co").post(requestBody) - .addHeader("content-type", "application/json").build() - val response = clientAnilist.newCall(request).execute() - if (response.isSuccessful && response.code == 200) { - val data = JSONObject(response.body!!.string()) - return data.getJSONObject("data") - .getJSONObject("Media") - .getJSONObject("coverImage") - .getString("extraLarge") - } - } - - // Query MAL api for an image - // https://jikan.docs.apiary.io/#reference/0/manga - if (my_anime_list_id != null) { - val request = GET("https://api.jikan.moe/v3/manga/${my_anime_list_id}/pictures", headers, CacheControl.FORCE_NETWORK) - val response = clientMyAnimeList.newCall(request).execute() - if (response.isSuccessful && response.code == 200) { - val data = JSONObject(response.body!!.string()) - val pictures = data.getJSONArray("pictures") - if (pictures.length() > 0) { - return pictures.getJSONObject(pictures.length() - 1).getString("large") - } - } - } - return MdUtil.imageUrlCacheNotFound - } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexLoginHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexLoginHelper.kt new file mode 100644 index 0000000000..b8c6d6fead --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexLoginHelper.kt @@ -0,0 +1,112 @@ +package eu.kanade.tachiyomi.source.online + +import com.elvishew.xlog.XLog +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.source.online.handlers.serializers.CheckTokenResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.LoginRequest +import eu.kanade.tachiyomi.source.online.handlers.serializers.LoginResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.RefreshTokenRequest +import eu.kanade.tachiyomi.source.online.utils.MdUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import okhttp3.CacheControl +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import uy.kohesive.injekt.injectLazy +import java.util.concurrent.TimeUnit + +/* + * Copyright (C) 2020 The Neko Manga Open Source Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +class MangaDexLoginHelper { + + val network: NetworkHelper by injectLazy() + val preferences: PreferencesHelper by injectLazy() + + suspend fun isAuthenticated(authHeaders: Headers): Boolean { + val lastRefreshTime = preferences.lastRefreshTime() + XLog.i("last refresh time $lastRefreshTime") + XLog.i("current time ${System.currentTimeMillis()}") + if ((lastRefreshTime + TimeUnit.MINUTES.toMillis(15)) > System.currentTimeMillis()) { + XLog.i("Token was refreshed recently dont hit dex to check") + return true + } + XLog.i("token was not refreshed recently hit dex auth check") + val response = network.client.newCall(GET(MdUtil.checkTokenUrl, authHeaders, CacheControl.FORCE_NETWORK)).await() + val body = MdUtil.jsonParser.decodeFromString(response.body!!.string()) + return body.isAuthenticated + } + + suspend fun refreshToken(authHeaders: Headers): Boolean { + val refreshToken = preferences.refreshToken() + if (refreshToken.isNullOrEmpty()) { + XLog.i("refresh token is null can't refresh token") + return false + } + val result = RefreshTokenRequest(refreshToken) + val jsonString = MdUtil.jsonParser.encodeToString(RefreshTokenRequest.serializer(), result) + val postResult = network.client.newCall( + POST( + MdUtil.refreshTokenUrl, + authHeaders, + jsonString.toRequestBody("application/json".toMediaType()) + ) + ).await() + + val jsonResponse = MdUtil.jsonParser.decodeFromString(postResult.body!!.string()) + preferences.setTokens(jsonResponse.token.refresh, jsonResponse.token.session) + XLog.i("refreshing token sug") + return jsonResponse.result == "ok" + } + + suspend fun login( + username: String, + password: String, + ): Boolean { + return withContext(Dispatchers.IO) { + + val loginRequest = LoginRequest(username, password) + + val jsonString = MdUtil.jsonParser.encodeToString(LoginRequest.serializer(), loginRequest) + + val postResult = network.client.newCall( + POST( + url = MdUtil.loginUrl, + body = jsonString.toRequestBody("application/json".toMediaType()) + ) + ).await() + + if (postResult.code == 200) { + + val loginResponse = MdUtil.jsonParser.decodeFromString(postResult.body!!.string()) + preferences.setRefreshToken(loginResponse.token.refresh) + preferences.setSessionToken(loginResponse.token.session) + preferences.setSourceCredentials(MangaDex(), username, password) + return@withContext true + } + return@withContext false + } + } + + suspend fun login(): Boolean { + val source = MangaDex() + val username = preferences.sourceUsername(source) + val password = preferences.sourcePassword(source) + if (username.isNullOrBlank() || password.isNullOrBlank()) { + XLog.i("No username or password stored, can't login") + return false + } + return login(username, password) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/ReducedHttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/ReducedHttpSource.kt index 975a966414..4fd39a6529 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/ReducedHttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/ReducedHttpSource.kt @@ -89,7 +89,7 @@ abstract class ReducedHttpSource : HttpSource() { TODO("Not yet implemented") } - override fun fetchMangaSimilarObservable(manga: Manga): Observable { + override fun fetchMangaSimilarObservable(manga: Manga, refresh: Boolean): Observable { TODO("Not yet implemented") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiChapterParser.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiChapterParser.kt index 415c583fe8..f6299eb23d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiChapterParser.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiChapterParser.kt @@ -1,28 +1,30 @@ package eu.kanade.tachiyomi.source.online.handlers -import com.github.salomonbrys.kotson.get -import com.github.salomonbrys.kotson.string -import com.google.gson.JsonParser import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.online.handlers.serializers.ApiChapterSerializer +import eu.kanade.tachiyomi.source.online.handlers.serializers.ChapterResponse import eu.kanade.tachiyomi.source.online.utils.MdUtil import okhttp3.Response import java.util.Date class ApiChapterParser { - fun pageListParse(response: Response): List { + fun pageListParse(response: Response, host: String, dataSaver: Boolean): List { val jsonData = response.body!!.string() - val networkApiChapter = MdUtil.jsonParser.decodeFromString(ApiChapterSerializer.serializer(), jsonData) + val networkApiChapter = MdUtil.jsonParser.decodeFromString(ChapterResponse.serializer(), jsonData) val pages = mutableListOf() - val hash = networkApiChapter.data.hash - val pageArray = networkApiChapter.data.pages - val server = networkApiChapter.data.server + val atHomeRequestUrl = response.request.url.toUrl().toString() - pageArray.forEach { - val url = "$hash/$it" - pages.add(Page(pages.size, "$server,${response.request.url},${Date().time}", url)) + val hash = networkApiChapter.data.attributes.hash + val pageArray = if (dataSaver) { + networkApiChapter.data.attributes.dataSaver.map { "/data-saver/$hash/$it" } + } else { + networkApiChapter.data.attributes.data.map { "/data/$hash/$it" } + } + val now = Date().time + pageArray.forEach { imgUrl -> + val mdAtHomeUrl = "$host,$atHomeRequestUrl,$now" + pages.add(Page(pages.size, mdAtHomeUrl, imgUrl)) } return pages @@ -30,8 +32,7 @@ class ApiChapterParser { fun externalParse(response: Response): String { val jsonData = response.body!!.string() - val json = JsonParser.parseString(jsonData).asJsonObject - val external = json.get("data").get("pages").string - return external.substringAfterLast("/") + val networkApiChapter = MdUtil.jsonParser.decodeFromString(ChapterResponse.serializer(), jsonData) + return networkApiChapter.data.attributes.data.first().substringAfterLast("/") } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiMangaParser.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiMangaParser.kt index 3899cb0ffa..e196177902 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiMangaParser.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiMangaParser.kt @@ -1,89 +1,102 @@ package eu.kanade.tachiyomi.source.online.handlers import com.elvishew.xlog.XLog -import eu.kanade.tachiyomi.network.consumeBody +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.online.handlers.serializers.ApiChapterSerializer -import eu.kanade.tachiyomi.source.online.handlers.serializers.ApiMangaSerializer -import eu.kanade.tachiyomi.source.online.handlers.serializers.ChapterSerializer +import eu.kanade.tachiyomi.source.online.handlers.serializers.AuthorResponseList +import eu.kanade.tachiyomi.source.online.handlers.serializers.ChapterResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.MangaResponse import eu.kanade.tachiyomi.source.online.utils.MdLang import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries +import kotlinx.serialization.decodeFromString import okhttp3.Response -import org.jsoup.Jsoup +import uy.kohesive.injekt.injectLazy import java.util.Date +import java.util.Locale import kotlin.math.floor -class ApiMangaParser(val langs: List) { - - fun mangaDetailsParse(response: Response, coverUrls: List): SManga { - return mangaDetailsParse(response.body!!.string(), coverUrls) - } +class ApiMangaParser { + val network: NetworkHelper by injectLazy() + val filterHandler: FilterHandler by injectLazy() + val v5DbHelper: V5DbHelper by injectLazy() /** * Parse the manga details json into manga object */ - fun mangaDetailsParse(jsonData: String, coverUrls: List): SManga { + fun mangaDetailsParse(jsonData: String): SManga { try { val manga = SManga.create() - val networkApiManga = MdUtil.jsonParser.decodeFromString(ApiMangaSerializer.serializer(), jsonData) - val networkManga = networkApiManga.data.manga - manga.title = MdUtil.cleanString(networkManga.title) - - manga.thumbnail_url = - if (coverUrls.isNotEmpty()) { - coverUrls.last() - } else { - networkManga.mainCover - } + val networkApiManga = MdUtil.jsonParser.decodeFromString(MangaResponse.serializer(), jsonData) + val networkManga = networkApiManga.data.attributes + manga.title = MdUtil.cleanString(networkManga.title["en"]!!) + + manga.thumbnail_url = V5DbQueries.getAltCover(v5DbHelper.dbCovers, networkApiManga.data.id) ?: MdUtil.imageUrlCacheNotFound + + manga.description = MdUtil.cleanDescription(networkManga.description["en"]!!) - manga.description = MdUtil.cleanDescription(networkManga.description) - manga.author = MdUtil.cleanString(networkManga.author.joinToString()) - manga.artist = MdUtil.cleanString(networkManga.artist.joinToString()) - manga.lang_flag = networkManga.publication?.language + val authorIds = networkApiManga.relationships.filter { it.type == "author" || it.type == "artist" }.map { it.id }.distinct() + + val authors = runCatching { + val ids = authorIds.joinToString("&ids[]=", "?ids[]=") + val response = network.client.newCall(GET("${MdUtil.authorUrl}$ids")).execute() + val json = MdUtil.jsonParser.decodeFromString(response.body!!.string()) + json.results.map { MdUtil.cleanString(it.data.attributes.name) } + }.getOrNull() ?: emptyList() + + + manga.author = authors.joinToString() + manga.artist = null + manga.lang_flag = networkManga.originalLanguage val lastChapter = networkManga.lastChapter?.toFloatOrNull() lastChapter?.let { manga.last_chapter_number = floor(it).toInt() } - networkManga.rating?.let { + /*networkManga.rating?.let { manga.rating = it.bayesian ?: it.mean manga.users = it.users - } + }*/ + networkManga.links?.let { - it.al?.let { manga.anilist_id = it } - it.kt?.let { manga.kitsu_id = it } - it.mal?.let { manga.my_anime_list_id = it } - it.mu?.let { manga.manga_updates_id = it } - it.ap?.let { manga.anime_planet_id = it } + it["al"]?.let { manga.anilist_id = it } + it["kt"]?.let { manga.kitsu_id = it } + it["mal"]?.let { manga.my_anime_list_id = it } + it["mu"]?.let { manga.manga_updates_id = it } + it["ap"]?.let { manga.anime_planet_id = it } } - val filteredChapters = filterChapterForChecking(networkApiManga) + // val filteredChapters = filterChapterForChecking(networkApiManga) - val tempStatus = parseStatus(networkManga.publication!!.status) + val tempStatus = parseStatus(networkManga.status ?: "") val publishedOrCancelled = tempStatus == SManga.PUBLICATION_COMPLETE || tempStatus == SManga.CANCELLED - if (publishedOrCancelled && isMangaCompleted(networkApiManga, filteredChapters)) { + /*if (publishedOrCancelled && isMangaCompleted(networkApiManga, filteredChapters)) { manga.status = SManga.COMPLETED manga.missing_chapters = null - } else { - manga.status = tempStatus - } - - val genres = networkManga.tags.mapNotNull { FilterHandler.allTypes[it.toString()] } - .toMutableList() - - networkManga.publication?.demographic?.let { demographicInt -> - val demographic = FilterHandler.demographics().firstOrNull { it.id.toInt() == demographicInt } - - if (demographic != null) { - genres.add(0, demographic.name) - } - - } - - if (networkManga.isHentai) { - genres.add("Hentai") - } + } else {*/ + manga.status = tempStatus + //} + + // things that will go with the genre tags but aren't actually genre + val nonGenres = listOf( + networkManga.publicationDemographic, + networkManga.contentRating, + ) + + val tags = filterHandler.getTags() + + val genres = ( + listOf(networkManga.publicationDemographic?.capitalize(Locale.US)) + + networkManga.tags.map { it.id } + .map { dexTagId -> tags.firstOrNull { tag -> tag.id == dexTagId } } + .map { tag -> tag?.name } + + listOf( + "Content Rating - " + (networkManga.contentRating?.capitalize(Locale.US) ?: "Unknown") + )) + .filterNotNull() manga.genre = genres.joinToString(", ") @@ -98,7 +111,7 @@ class ApiMangaParser(val langs: List) { * If chapter title is oneshot or a chapter exists which matches the last chapter in the required language * return manga is complete */ - private fun isMangaCompleted( + /*private fun isMangaCompleted( serializer: ApiMangaSerializer, filteredChapters: List ): Boolean { @@ -120,33 +133,33 @@ class ApiMangaParser(val langs: List) { .filter { it != 0 } .toList().distinctBy { it } return removeOneshots.toList().size == floor(finalChapterNumber.toDouble()).toInt() - } - - private fun filterChapterForChecking(serializer: ApiMangaSerializer): List { - serializer.data.chapters ?: return emptyList() - return serializer.data.chapters.asSequence() - .filter { langs.contains(it.language) } - .filter { - it.chapter?.let { chapterNumber -> - if (chapterNumber.toDoubleOrNull() == null) { - return@filter false - } - return@filter true - } - return@filter false - }.toList() - } - - private fun isOneShot(chapter: ChapterSerializer, finalChapterNumber: String): Boolean { + }*/ + + /* private fun filterChapterForChecking(serializer: ApiMangaSerializer): List { + serializer.data.chapters ?: return emptyList() + return serializer.data.chapters.asSequence() + .filter { langs.contains(it.language) } + .filter { + it.chapter?.let { chapterNumber -> + if (chapterNumber.toDoubleOrNull() == null) { + return@filter false + } + return@filter true + } + return@filter false + }.toList() + }*/ + + /*private fun isOneShot(chapter: ChapterSerializer, finalChapterNumber: String): Boolean { return chapter.title.equals("oneshot", true) || ((chapter.chapter.isNullOrEmpty() || chapter.chapter == "0") && MdUtil.validOneShotFinalChapters.contains(finalChapterNumber)) - } + }*/ - private fun parseStatus(status: Int) = when (status) { - 1 -> SManga.ONGOING - 2 -> SManga.PUBLICATION_COMPLETE - 3 -> SManga.CANCELLED - 4 -> SManga.HIATUS + private fun parseStatus(status: String) = when (status) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.PUBLICATION_COMPLETE + "cancelled" -> SManga.CANCELLED + "hiatus" -> SManga.HIATUS else -> SManga.UNKNOWN } @@ -154,45 +167,22 @@ class ApiMangaParser(val langs: List) { * Parse for the random manga id from the [MdUtil.randMangaPage] response. */ fun randomMangaIdParse(response: Response): String { - val randMangaUrl = Jsoup.parse(response.consumeBody()) - .select("link[rel=canonical]") - .attr("href") - return MdUtil.getMangaId(randMangaUrl) - } - - fun chapterListParse(response: Response): List { - return chapterListParse(response.body!!.string()) + val manga = MdUtil.jsonParser.decodeFromString(MangaResponse.serializer(), response.body!!.string()) + return manga.data.id } - fun chapterListParse(jsonData: String): List { + fun chapterListParse(chapterListResponse: List, groupMap: Map): List { val now = Date().time - val networkApiManga = MdUtil.jsonParser.decodeFromString(ApiMangaSerializer.serializer(), jsonData) - val networkManga = networkApiManga.data.manga - val networkChapters = networkApiManga.data.chapters - val groups = networkApiManga.data.groups.mapNotNull { - if (it.name == null) { - null - } else { - it.id to it.name - } - }.toMap() - - val status = networkManga.publication!!.status - val finalChapterNumber = networkManga.lastChapter - - // Skip chapters that don't match the desired language, or are future releases - - val chapLangs = MdLang.values().filter { langs.contains(it.dexLang) } - - - - return networkChapters.asSequence() - .filter { langs.contains(it.language) && (it.timestamp * 1000) <= now } - .map { mapChapter(it, finalChapterNumber, status, chapLangs, networkChapters.size, groups) }.toList() + return chapterListResponse.asSequence() + .map { + mapChapter(it, groupMap) + }.filter { + it.date_upload <= now + }.toList() } - fun chapterParseForMangaId(response: Response): Int { + fun chapterParseForMangaId(response: Response): String { try { if (response.code != 200) throw Exception("HTTP error ${response.code}") val jsonBody = response.body?.string().orEmpty() @@ -200,8 +190,8 @@ class ApiMangaParser(val langs: List) { throw Exception("Null Response") } - val apiChapter = MdUtil.jsonParser.decodeFromString(ApiChapterSerializer.serializer(), jsonBody) - return apiChapter.data.mangaId + val apiChapter = MdUtil.jsonParser.decodeFromString(ChapterResponse.serializer(), jsonBody) + return apiChapter.relationships.firstOrNull { it.type.equals("manga", true) }?.id ?: throw Exception("Not found") } catch (e: Exception) { XLog.e(e) throw e @@ -209,42 +199,39 @@ class ApiMangaParser(val langs: List) { } private fun mapChapter( - networkChapter: ChapterSerializer, - finalChapterNumber: String?, - status: Int, - chapLangs: List, - totalChapterCount: Int, - groups: Map, + networkChapter: ChapterResponse, + groups: Map, ): SChapter { val chapter = SChapter.create() - chapter.url = MdUtil.oldApiChapter + networkChapter.id + val attributes = networkChapter.data.attributes + chapter.url = MdUtil.chapterSuffix + networkChapter.data.id val chapterName = mutableListOf() // Build chapter name - if (!networkChapter.volume.isNullOrBlank()) { - val vol = "Vol." + networkChapter.volume - chapterName.add(vol) - chapter.vol = vol + if (attributes.volume != null) { + chapterName.add("Vol.${attributes.volume}") + chapter.vol = attributes.volume.toString() } - if (!networkChapter.chapter.isNullOrBlank()) { - val chp = "Ch." + networkChapter.chapter + if (attributes.chapter.isNullOrBlank().not()) { + val chp = "Ch.${attributes.chapter}" chapterName.add(chp) chapter.chapter_txt = chp } - if (!networkChapter.title.isNullOrBlank()) { + + if (attributes.title.isNullOrBlank().not()) { if (chapterName.isNotEmpty()) { chapterName.add("-") } - chapterName.add(networkChapter.title) - chapter.chapter_title = MdUtil.cleanString(networkChapter.title) + chapterName.add(attributes.title!!) + chapter.chapter_title = MdUtil.cleanString(attributes.title) } // if volume, chapter and title is empty its a oneshot if (chapterName.isEmpty()) { chapterName.add("Oneshot") } - if ((status == 2 || status == 3)) { + /*if ((status == 2 || status == 3)) { if (finalChapterNumber != null) { if ((isOneShot(networkChapter, finalChapterNumber) && totalChapterCount == 1) || networkChapter.chapter == finalChapterNumber && finalChapterNumber.toIntOrNull() != 0 @@ -252,20 +239,20 @@ class ApiMangaParser(val langs: List) { chapterName.add("[END]") } } - } + }*/ chapter.name = MdUtil.cleanString(chapterName.joinToString(" ")) // Convert from unix time - chapter.date_upload = networkChapter.timestamp * 1000 - val scanlatorName = mutableSetOf() + chapter.date_upload = MdUtil.parseDate(attributes.publishAt) + + val scanlatorName = networkChapter.relationships.filter { it.type == "scanlation_group" }.mapNotNull { groups[it.id] }.toSet() - networkChapter.groups.mapNotNull { groups.get(it) }.forEach { scanlatorName.add(it) } chapter.scanlator = MdUtil.cleanString(MdUtil.getScanlatorString(scanlatorName)) chapter.mangadex_chapter_id = MdUtil.getChapterId(chapter.url) - chapter.language = chapLangs.firstOrNull { it.dexLang == networkChapter.language }?.name + chapter.language = MdLang.fromIsoCode(attributes.translatedLanguage)?.prettyPrint ?: "" return chapter } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FilterHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FilterHandler.kt index d7324e9842..4dac05a3cc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FilterHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FilterHandler.kt @@ -3,187 +3,263 @@ package eu.kanade.tachiyomi.source.online.handlers import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.online.utils.MdUtil +import okhttp3.HttpUrl +import uy.kohesive.injekt.injectLazy +import java.util.Locale -class FilterHandler(private val preferencesHelper: PreferencesHelper) { - - class TextField(name: String, val key: String) : Filter.Text(name) - class Tag(val id: String, name: String) : Filter.TriState(name) - class Switch(val id: String, name: String) : Filter.CheckBox(name) - class ContentList(contents: List) : Filter.Group("Content", contents) - class FormatList(formats: List) : Filter.Group("Format", formats) - class GenreList(genres: List) : Filter.Group("Genres", genres) - class PublicationStatusList(statuses: List) : Filter.Group("Publication Status", statuses) - class DemographicList(demographics: List) : Filter.Group("Demographic", demographics) - - class R18 : Filter.Select("R18+", arrayOf("Default", "Show all", "Show only", "Show none")) - class ThemeList(themes: List) : Filter.Group("Themes", themes) - class TagInclusionMode : Filter.Select("Tag inclusion", arrayOf("All (and)", "Any (or)"), 0) - class TagExclusionMode : Filter.Select("Tag exclusion", arrayOf("All (and)", "Any (or)"), 1) - - class SortFilter : Filter.Sort( - "Sort", - sortables().map { it.first }.toTypedArray(), - Selection(0, false) - ) +class FilterHandler() { - class OriginalLanguage : Filter.Select("Original Language", sourceLang().map { it.first }.toTypedArray()) + val preferencesHelper: PreferencesHelper by injectLazy() - fun getFilterList(): FilterList { + internal fun getMDFilterList(): FilterList { val filters = mutableListOf( - TextField("Author", "author"), - TextField("Artist", "artist"), - SortFilter(), - DemographicList(demographics()), - PublicationStatusList(publicationStatus()), - OriginalLanguage(), - ContentList(contentType()), - FormatList(formats()), - GenreList(genre()), - ThemeList(themes()), + OriginalLanguageList(getOriginalLanguage()), + DemographicList(getDemographics()), + StatusList(getStatus()), + SortFilter(sortableList.map { it.first }.toTypedArray()), + TagList(getTags()), TagInclusionMode(), TagExclusionMode() - ) + ).toMutableList() + + if (preferencesHelper.showContentRatingFilter()) { + val set = preferencesHelper.contentRatingSelections() + val contentRating = listOf( + ContentRating("Safe").apply { state = set.contains(MdUtil.contentRatingSafe) }, + ContentRating("Suggestive").apply { state = set.contains(MdUtil.contentRatingSuggestive) }, + ContentRating("Erotica").apply { state = set.contains(MdUtil.contentRatingErotica) }, + ContentRating("Pornographic").apply { state = set.contains(MdUtil.contentRatingPornographic) }, + ) - if (preferencesHelper.showR18Filter()) { - filters.add(2, R18()) + filters.add(2, ContentRatingList(contentRating)) } return FilterList(list = filters.toList()) } - companion object { - fun demographics() = listOf( - Switch("1", "Shounen"), - Switch("2", "Shoujo"), - Switch("3", "Seinen"), - Switch("4", "Josei") - ) - - fun publicationStatus() = listOf( - Switch("1", "Ongoing"), - Switch("2", "Completed"), - Switch("3", "Cancelled"), - Switch("4", "Hiatus") - ) - - fun sortables() = listOf( - Triple("Update date", 1, 0), - Triple("Alphabetically", 2, 3), - Triple("Number of comments", 4, 5), - Triple("Rating", 6, 7), - Triple("Views", 8, 9), - Triple("Follows", 10, 11) - ) - - fun sourceLang() = listOf( - Pair("All", "0"), - Pair("Japanese", "2"), - Pair("English", "1"), - Pair("Polish", "3"), - Pair("German", "8"), - Pair("French", "10"), - Pair("Vietnamese", "12"), - Pair("Chinese", "21"), - Pair("Indonesian", "27"), - Pair("Korean", "28"), - Pair("Spanish (LATAM)", "29"), - Pair("Thai", "32"), - Pair("Filipino", "34") - ) - - fun contentType() = listOf( - Tag("9", "Ecchi"), - Tag("32", "Smut"), - Tag("49", "Gore"), - Tag("50", "Sexual Violence") - ).sortedWith(compareBy { it.name }) - - fun formats() = listOf( - Tag("1", "4-koma"), - Tag("4", "Award Winning"), - Tag("7", "Doujinshi"), - Tag("21", "Oneshot"), - Tag("36", "Long Strip"), - Tag("42", "Adaptation"), - Tag("43", "Anthology"), - Tag("44", "Web Comic"), - Tag("45", "Full Color"), - Tag("46", "User Created"), - Tag("47", "Official Colored"), - Tag("48", "Fan Colored") - ).sortedWith(compareBy { it.name }) - - fun genre() = listOf( - Tag("2", "Action"), - Tag("3", "Adventure"), - Tag("5", "Comedy"), - Tag("8", "Drama"), - Tag("10", "Fantasy"), - Tag("13", "Historical"), - Tag("14", "Horror"), - Tag("17", "Mecha"), - Tag("18", "Medical"), - Tag("20", "Mystery"), - Tag("22", "Psychological"), - Tag("23", "Romance"), - Tag("25", "Sci-Fi"), - Tag("28", "Shoujo Ai"), - Tag("30", "Shounen Ai"), - Tag("31", "Slice of Life"), - Tag("33", "Sports"), - Tag("35", "Tragedy"), - Tag("37", "Yaoi"), - Tag("38", "Yuri"), - Tag("41", "Isekai"), - Tag("51", "Crime"), - Tag("52", "Magical Girls"), - Tag("53", "Philosophical"), - Tag("54", "Superhero"), - Tag("55", "Thriller"), - Tag("56", "Wuxia") - ).sortedWith(compareBy { it.name }) - - fun themes() = listOf( - Tag("6", "Cooking"), - Tag("11", "Gyaru"), - Tag("12", "Harem"), - Tag("16", "Martial Arts"), - Tag("19", "Music"), - Tag("24", "School Life"), - Tag("34", "Supernatural"), - Tag("40", "Video Games"), - Tag("57", "Aliens"), - Tag("58", "Animals"), - Tag("59", "Crossdressing"), - Tag("60", "Demons"), - Tag("61", "Delinquents"), - Tag("62", "Genderswap"), - Tag("63", "Ghosts"), - Tag("64", "Monster Girls"), - Tag("65", "Loli"), - Tag("66", "Magic"), - Tag("67", "Military"), - Tag("68", "Monsters"), - Tag("69", "Ninja"), - Tag("70", "Office Workers"), - Tag("71", "Police"), - Tag("72", "Post-Apocalyptic"), - Tag("73", "Reincarnation"), - Tag("74", "Reverse Harem"), - Tag("75", "Samurai"), - Tag("76", "Shota"), - Tag("77", "Survival"), - Tag("78", "Time Travel"), - Tag("79", "Vampires"), - Tag("80", "Traditional Games"), - Tag("81", "Virtual Reality"), - Tag("82", "Zombies"), - Tag("83", "Incest"), - Tag("84", "Mafia"), - Tag("85", "Villainess") - ).sortedWith(compareBy { it.name }) - - val allTypes = (contentType() + formats() + genre() + themes()).map { it.id to it.name }.toMap() + private class Demographic(name: String) : Filter.CheckBox(name) + private class DemographicList(demographics: List) : + Filter.Group("Publication Demographic", demographics) + + private fun getDemographics() = listOf( + Demographic("None"), + Demographic("Shounen"), + Demographic("Shoujo"), + Demographic("Seinen"), + Demographic("Josei") + ) + + private class Status(name: String) : Filter.CheckBox(name) + private class StatusList(status: List) : + Filter.Group("Status", status) + + private fun getStatus() = listOf( + Status("Onging"), + Status("Completed"), + Status("Hiatus"), + Status("Abandoned"), + ) + + private class ContentRating(name: String) : Filter.CheckBox(name) + private class ContentRatingList(contentRating: List) : + Filter.Group("Content Rating", contentRating) + + private class OriginalLanguage(name: String, val isoCode: String) : Filter.CheckBox(name) + private class OriginalLanguageList(originalLanguage: List) : + Filter.Group("Original language", originalLanguage) + + private fun getOriginalLanguage() = listOf( + OriginalLanguage("Japanese (Manga)", "jp"), + OriginalLanguage("Chinese (Manhua)", "cn"), + OriginalLanguage("Korean (Manhwa)", "kr"), + ) + + internal class Tag(val id: String, name: String) : Filter.TriState(name) + private class TagList(tags: List) : Filter.Group("Tags", tags) + + internal fun getTags() = listOf( + Tag("391b0423-d847-456f-aff0-8b0cfc03066b", "Action"), + Tag("f4122d1c-3b44-44d0-9936-ff7502c39ad3", "Adaptation"), + Tag("87cc87cd-a395-47af-b27a-93258283bbc6", "Adventure"), + Tag("e64f6742-c834-471d-8d72-dd51fc02b835", "Aliens"), + Tag("3de8c75d-8ee3-48ff-98ee-e20a65c86451", "Animals"), + Tag("51d83883-4103-437c-b4b1-731cb73d786c", "Anthology"), + Tag("0a39b5a1-b235-4886-a747-1d05d216532d", "Award Winning"), + Tag("5920b825-4181-4a17-beeb-9918b0ff7a30", "Boy Love"), + Tag("4d32cc48-9f00-4cca-9b5a-a839f0764984", "Comedy"), + Tag("ea2bc92d-1c26-4930-9b7c-d5c0dc1b6869", "Cooking"), + Tag("5ca48985-9a9d-4bd8-be29-80dc0303db72", "Crime"), + Tag("489dd859-9b61-4c37-af75-5b18e88daafc", "Crossdressing"), + Tag("da2d50ca-3018-4cc0-ac7a-6b7d472a29ea", "Delinquents"), + Tag("39730448-9a5f-48a2-85b0-a70db87b1233", "Demons"), + Tag("b13b2a48-c720-44a9-9c77-39c9979373fb", "Doujinshi"), + Tag("b9af3a63-f058-46de-a9a0-e0c13906197a", "Drama"), + Tag("fad12b5e-68ba-460e-b933-9ae8318f5b65", "Ecchi"), + Tag("7b2ce280-79ef-4c09-9b58-12b7c23a9b78", "Fan Colored"), + Tag("cdc58593-87dd-415e-bbc0-2ec27bf404cc", "Fantasy"), + Tag("b11fda93-8f1d-4bef-b2ed-8803d3733170", "4-koma"), + Tag("f5ba408b-0e7a-484d-8d49-4e9125ac96de", "Full Color"), + Tag("2bd2e8d0-f146-434a-9b51-fc9ff2c5fe6a", "Genderswap"), + Tag("3bb26d85-09d5-4d2e-880c-c34b974339e9", "Ghosts"), + Tag("a3c67850-4684-404e-9b7f-c69850ee5da6", "Girl Love"), + Tag("b29d6a3d-1569-4e7a-8caf-7557bc92cd5d", "Gore"), + Tag("fad12b5e-68ba-460e-b933-9ae8318f5b65", "Gyaru"), + Tag("aafb99c1-7f60-43fa-b75f-fc9502ce29c7", "Harem"), + Tag("33771934-028e-4cb3-8744-691e866a923e", "Historical"), + Tag("cdad7e68-1419-41dd-bdce-27753074a640", "Horror"), + Tag("5bd0e105-4481-44ca-b6e7-7544da56b1a3", "Incest"), + Tag("ace04997-f6bd-436e-b261-779182193d3d", "Isekai"), + Tag("2d1f5d56-a1e5-4d0d-a961-2193588b08ec", "Loli"), + Tag("3e2b8dae-350e-4ab8-a8ce-016e844b9f0d", "Long Strip"), + Tag("85daba54-a71c-4554-8a28-9901a8b0afad", "Mafia"), + Tag("a1f53773-c69a-4ce5-8cab-fffcd90b1565", "Magic"), + Tag("81c836c9-914a-4eca-981a-560dad663e73", "Magical Girls"), + Tag("799c202e-7daa-44eb-9cf7-8a3c0441531e", "Martial Arts"), + Tag("50880a9d-5440-4732-9afb-8f457127e836", "Mecha"), + Tag("c8cbe35b-1b2b-4a3f-9c37-db84c4514856", "Medical"), + Tag("ac72833b-c4e9-4878-b9db-6c8a4a99444a", "Military"), + Tag("dd1f77c5-dea9-4e2b-97ae-224af09caf99", "Monster Girls"), + Tag("36fd93ea-e8b8-445e-b836-358f02b3d33d", "Monsters"), + Tag("f42fbf9e-188a-447b-9fdc-f19dc1e4d685", "Music"), + Tag("ee968100-4191-4968-93d3-f82d72be7e46", "Mystery"), + Tag("489dd859-9b61-4c37-af75-5b18e88daafc", "Ninja"), + Tag("92d6d951-ca5e-429c-ac78-451071cbf064", "Office Workers"), + Tag("320831a8-4026-470b-94f6-8353740e6f04", "Official Colored"), + Tag("0234a31e-a729-4e28-9d6a-3f87c4966b9e", "Oneshot"), + Tag("b1e97889-25b4-4258-b28b-cd7f4d28ea9b", "Philosophical"), + Tag("df33b754-73a3-4c54-80e6-1a74a8058539", "Police"), + Tag("9467335a-1b83-4497-9231-765337a00b96", "Post-Apocalyptic"), + Tag("3b60b75c-a2d7-4860-ab56-05f391bb889c", "Psychological"), + Tag("0bc90acb-ccc1-44ca-a34a-b9f3a73259d0", "Reincarnation"), + Tag("65761a2a-415e-47f3-bef2-a9dababba7a6", "Reverse Harem"), + Tag("423e2eae-a7a2-4a8b-ac03-a8351462d71d", "Romance"), + Tag("81183756-1453-4c81-aa9e-f6e1b63be016", "Samurai"), + Tag("caaa44eb-cd40-4177-b930-79d3ef2afe87", "School Life"), + Tag("256c8bd9-4904-4360-bf4f-508a76d67183", "Sci-Fi"), + Tag("97893a4c-12af-4dac-b6be-0dffb353568e", "Sexual Violence"), + Tag("ddefd648-5140-4e5f-ba18-4eca4071d19b", "Shota"), + Tag("e5301a23-ebd9-49dd-a0cb-2add944c7fe9", "Slice of Life"), + Tag("69964a64-2f90-4d33-beeb-f3ed2875eb4c", "Sports"), + Tag("7064a261-a137-4d3a-8848-2d385de3a99c", "Superhero"), + Tag("eabc5b4c-6aff-42f3-b657-3e90cbd00b75", "Supernatural"), + Tag("5fff9cde-849c-4d78-aab0-0d52b2ee1d25", "Survival"), + Tag("07251805-a27e-4d59-b488-f0bfbec15168", "Thriller"), + Tag("292e862b-2d17-4062-90a2-0356caa4ae27", "Time Travel"), + Tag("f8f62932-27da-4fe4-8ee1-6779a8c5edba", "Tragedy"), + Tag("31932a7e-5b8e-49a6-9f12-2afa39dc544c", "Traditional Games"), + Tag("891cf039-b895-47f0-9229-bef4c96eccd4", "User Created"), + Tag("d7d1730f-6eb0-4ba6-9437-602cac38664c", "Vampires"), + Tag("9438db5a-7e2a-4ac0-b39e-e0d95a34b8a8", "Video Games"), + Tag("d14322ac-4d6f-4e9b-afd9-629d5f4d8a41", "Villainess"), + Tag("8c86611e-fab7-4986-9dec-d1a2f44acdd5", "Virtual Reality"), + Tag("e197df38-d0e7-43b5-9b09-2842d0c326dd", "Web Comic"), + Tag("acc803a4-c95a-4c22-86fc-eb6b582d82a2", "Wuxia"), + Tag("631ef465-9aba-4afb-b0fc-ea10efe274a8", "Zombies") + ) + + private class TagInclusionMode : + Filter.Select("Included tags mode", arrayOf("And", "Or"), 0) + + private class TagExclusionMode : + Filter.Select("Excluded tags mode", arrayOf("And", "Or"), 1) + + val sortableList = listOf( + Pair("Default (Asc/Desc doesn't matter)", ""), + Pair("Created at", "createdAt"), + Pair("Updated at", "updatedAt"), + ) + + class SortFilter(sortables: Array) : Filter.Sort("Sort", sortables, Selection(0, false)) + + fun addFiltersToUrl(url: HttpUrl.Builder, filters: FilterList): String { + url.apply { + // add filters + filters.forEach { filter -> + when (filter) { + is OriginalLanguageList -> { + filter.state.forEach { lang -> + if (lang.state) { + addQueryParameter( + "originalLanguage[]", + lang.isoCode + ) + } + } + } + is ContentRatingList -> { + filter.state.forEach { rating -> + if (rating.state) { + addQueryParameter( + "contentRating[]", + rating.name.toLowerCase(Locale.US) + ) + } + } + } + is DemographicList -> { + filter.state.forEach { demographic -> + if (demographic.state) { + addQueryParameter( + "publicationDemographic[]", + demographic.name.toLowerCase( + Locale.US + ) + ) + } + } + } + is StatusList -> { + filter.state.forEach { status -> + if (status.state) { + addQueryParameter( + "status[]", + status.name.toLowerCase( + Locale.US + ) + ) + } + } + } + is SortFilter -> { + if (filter.state != null) { + if (filter.state!!.index != 0) { + val query = sortableList[filter.state!!.index].second + val value = when (filter.state!!.ascending) { + true -> "asc" + false -> "desc" + } + addQueryParameter("order[$query]", value) + } + } + } + is TagList -> { + filter.state.forEach { tag -> + if (tag.isIncluded()) { + addQueryParameter("includedTags[]", tag.id) + } else if (tag.isExcluded()) { + addQueryParameter("excludedTags[]", tag.id) + } + } + } + is TagInclusionMode -> { + addQueryParameter( + "includedTagsMode", + filter.values[filter.state].toUpperCase(Locale.US) + ) + } + is TagExclusionMode -> { + addQueryParameter( + "excludedTagsMode", + filter.values[filter.state].toUpperCase(Locale.US) + ) + } + } + } + } + + return url.toString() } } + diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FollowsHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FollowsHandler.kt index 0a7dbe9bfd..ee6dcfa940 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FollowsHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FollowsHandler.kt @@ -5,39 +5,72 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.online.handlers.serializers.FollowPage -import eu.kanade.tachiyomi.source.online.handlers.serializers.FollowsIndividualSerializer -import eu.kanade.tachiyomi.source.online.handlers.serializers.FollowsPageSerializer +import eu.kanade.tachiyomi.source.online.handlers.serializers.GetReadingStatus +import eu.kanade.tachiyomi.source.online.handlers.serializers.MangaListResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.MangaResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.MangaStatusListResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.UpdateReadingStatus import eu.kanade.tachiyomi.source.online.utils.FollowStatus import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.source.online.utils.MdUtil.Companion.baseUrl import eu.kanade.tachiyomi.source.online.utils.MdUtil.Companion.getMangaId +import eu.kanade.tachiyomi.v5.db.V5DbHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import okhttp3.CacheControl -import okhttp3.FormBody -import okhttp3.Headers -import okhttp3.OkHttpClient +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import rx.Observable -import java.io.EOFException -import kotlin.math.floor +import uy.kohesive.injekt.injectLazy +import java.util.Locale -class FollowsHandler(val client: OkHttpClient, val headers: Headers, val preferences: PreferencesHelper) { +class FollowsHandler { + + val preferences: PreferencesHelper by injectLazy() + val network: NetworkHelper by injectLazy() + val v5DbHelper: V5DbHelper by injectLazy() /** - * fetch follows by page + * fetch all follows */ fun fetchFollows(): Observable { - return client.newCall(followsListRequest()) + return network.authClient.newCall(followsListRequest(0)) .asObservable() .map { response -> - followsParseMangaPage(response) + val mangaListResponse = MdUtil.jsonParser.decodeFromString(response.body!!.string()) + val results = mangaListResponse.results.toMutableList() + + var hasMoreResults = mangaListResponse.limit + mangaListResponse.offset < mangaListResponse.total + + var offset = mangaListResponse.offset + val limit = mangaListResponse.limit + + while (hasMoreResults) { + offset += limit + val newResponse = network.authClient.newCall(followsListRequest(offset)).execute() + if (newResponse.code != 200) { + hasMoreResults = false + } else { + val newMangaListResponse = MdUtil.jsonParser.decodeFromString(newResponse.body!!.string()) + hasMoreResults = newMangaListResponse.limit + newMangaListResponse.offset < newMangaListResponse.total + results.addAll(newMangaListResponse.results) + } + } + val readingStatusResponse = network.authClient.newCall(readingStatusRequest()).execute() + val json = MdUtil.jsonParser.decodeFromString(readingStatusResponse.body!!.string()) + + followsParseMangaPage(results, json.statuses) } } @@ -45,29 +78,14 @@ class FollowsHandler(val client: OkHttpClient, val headers: Headers, val prefere * Parse follows api to manga page * used when multiple follows */ - private fun followsParseMangaPage(response: Response, forceHd: Boolean = false): MangasPage { - - var followsPageResult: FollowsPageSerializer? = null - - try { - followsPageResult = MdUtil.jsonParser.decodeFromString(FollowsPageSerializer.serializer(), response.body!!.string()) - } catch (e: Exception) { - XLog.e("error parsing follows", e) - } - val empty = followsPageResult?.data?.isEmpty() - - if (empty == null || empty || followsPageResult?.code != 200) { - return MangasPage(mutableListOf(), false) - } - val lowQualityCovers = if (forceHd) false else preferences.lowQualityCovers() - - val follows = followsPageResult.data!!.map { - followFromElement(it, lowQualityCovers) - } + private fun followsParseMangaPage(response: List, readingStatusMap: Map): MangasPage { val comparator = compareBy { it.follow_status }.thenBy { it.title } - - val result = follows.sortedWith(comparator) + val result = response.map { + MdUtil.createMangaEntry(it, preferences, v5DbHelper).apply { + this.follow_status = FollowStatus.fromDex(readingStatusMap[it.data.id]) + } + }.sortedWith(comparator) return MangasPage(result, false) } @@ -76,100 +94,78 @@ class FollowsHandler(val client: OkHttpClient, val headers: Headers, val prefere * */ - private fun followStatusParse(response: Response): Track { - var followsPageResult: FollowsIndividualSerializer? = null + private fun followStatusParse(response: Response, mangaId: String): Track { - try { - followsPageResult = MdUtil.jsonParser.decodeFromString(FollowsIndividualSerializer.serializer(), response.body!!.string()) - } catch (e: Exception) { - XLog.e("error parsing follows", e) - } + val mangaResponse = MdUtil.jsonParser.decodeFromString(response.body!!.string()) + val followStatus = FollowStatus.fromDex(mangaResponse.status) val track = Track.create(TrackManager.MDLIST) - if (followsPageResult!!.code == 404) { - track.status = FollowStatus.UNFOLLOWED.int - } else { - val follow = followsPageResult.data!! - track.status = follow.followType - if (follow.chapter.isNotBlank()) { + track.status = followStatus.int + track.tracking_url = "$baseUrl/title/$mangaId" + +/* if (follow.chapter.isNotBlank()) { track.last_chapter_read = floor(follow.chapter.toFloat()).toInt() } - track.tracking_url = MdUtil.baseUrl + follow.mangaId.toString() - track.title = follow.mangaTitle - } + + */ return track } /**build Request for follows page * */ - private fun followsListRequest(): Request { - return GET("${MdUtil.apiUrl}${MdUtil.followsAllApi}", headers, CacheControl.FORCE_NETWORK) + private fun followsListRequest(offset: Int): Request { + val tempUrl = MdUtil.userFollowsUrl.toHttpUrlOrNull()!!.newBuilder() + + tempUrl.apply { + addQueryParameter("limit", "100") + addQueryParameter("offset", offset.toString()) + } + return GET(tempUrl.build().toString(), MdUtil.getAuthHeaders(network.headers, preferences), CacheControl.FORCE_NETWORK) } - /** - * Parse result element to manga - */ - private fun followFromElement(result: FollowPage, lowQualityCovers: Boolean): SManga { - val manga = SManga.create() - manga.title = MdUtil.cleanString(result.mangaTitle) - manga.url = "/manga/${result.mangaId}/" - manga.follow_status = FollowStatus.fromInt(result.followType) - manga.thumbnail_url = MdUtil.formThumbUrl(manga.url, lowQualityCovers) - return manga + private fun readingStatusRequest(): Request { + return GET(MdUtil.readingStatusesUrl, MdUtil.getAuthHeaders(network.headers, preferences), CacheControl.FORCE_NETWORK) } /** * Change the status of a manga */ - suspend fun updateFollowStatus(mangaID: String, followStatus: FollowStatus): Boolean { + suspend fun updateFollowStatus(mangaId: String, followStatus: FollowStatus): Boolean { return withContext(Dispatchers.IO) { - val result = kotlin.runCatching { - if (followStatus == FollowStatus.UNFOLLOWED) { - client.newCall( - GET( - "$baseUrl/ajax/actions.ajax.php?function=manga_unfollow&id=$mangaID&type=$mangaID", - headers, - CacheControl.FORCE_NETWORK - ) - ) - .execute() - } else { - - val status = followStatus.int - client.newCall( - GET( - "$baseUrl/ajax/actions.ajax.php?function=manga_follow&id=$mangaID&type=$status", - headers, - CacheControl.FORCE_NETWORK - ) - ) - .execute() - } + val status = when (followStatus == FollowStatus.UNFOLLOWED) { + true -> null + false -> followStatus.name.toLowerCase(Locale.US) } - result.exceptionOrNull()?.let { - if (it is EOFException) { - return@withContext true - } else { - XLog.e("error updating follow status", it) - return@withContext false - } - } - return@withContext result.isSuccess + val jsonString = MdUtil.jsonParser.encodeToString(UpdateReadingStatus(status)) + try { + val postResult = network.authClient.newCall( + POST( + MdUtil.getReadingStatusUrl(mangaId), + MdUtil.getAuthHeaders(network.headers, preferences), + jsonString.toRequestBody("application/json".toMediaType()) + ) + ).await() + postResult.isSuccessful + } catch (e: Exception) { + XLog.e("error posting", e) + false + } } } suspend fun updateReadingProgress(track: Track): Boolean { - return withContext(Dispatchers.IO) { + return true + /*return withContext(Dispatchers.IO) { val mangaID = getMangaId(track.tracking_url) val formBody = FormBody.Builder() .add("volume", "0") .add("chapter", track.last_chapter_read.toString()) XLog.d("chapter to update %s", track.last_chapter_read.toString()) val result = runCatching { - client.newCall( + network.authClient.newCall( POST( "$baseUrl/ajax/actions.ajax.php?function=edit_progress&id=$mangaID", headers, @@ -186,14 +182,15 @@ class FollowsHandler(val client: OkHttpClient, val headers: Headers, val prefere } } return@withContext result.isSuccess - } + }*/ } suspend fun updateRating(track: Track): Boolean { - return withContext(Dispatchers.IO) { + return true + /*return withContext(Dispatchers.IO) { val mangaID = getMangaId(track.tracking_url) val result = runCatching { - client.newCall( + network.authClient.newCall( GET( "$baseUrl/ajax/actions.ajax.php?function=manga_rating&id=$mangaID&rating=${track.score.toInt()}", headers @@ -211,31 +208,51 @@ class FollowsHandler(val client: OkHttpClient, val headers: Headers, val prefere } } return@withContext result.isSuccess - } + }*/ } /** * fetch all manga from all possible pages */ - suspend fun fetchAllFollows(forceHd: Boolean): List { + suspend fun fetchAllFollows(): List { return withContext(Dispatchers.IO) { - val listManga = mutableListOf() - val response = client.newCall(followsListRequest()).execute() - val mangasPage = followsParseMangaPage(response, forceHd) - listManga.addAll(mangasPage.mangas) - listManga + val response = network.authClient.newCall(followsListRequest(0)).await() + val mangaListResponse = MdUtil.jsonParser.decodeFromString(response.body!!.string()) + val results = mangaListResponse.results.toMutableList() + + var hasMoreResults = mangaListResponse.limit + mangaListResponse.offset < mangaListResponse.total + var offset = mangaListResponse.offset + val limit = mangaListResponse.limit + + while (hasMoreResults) { + offset += limit + val newResponse = network.authClient.newCall(followsListRequest(offset)).await() + if (newResponse.code != 200) { + hasMoreResults = false + } else { + val newMangaListResponse = MdUtil.jsonParser.decodeFromString(newResponse.body!!.string()) + results.addAll(newMangaListResponse.results) + hasMoreResults = newMangaListResponse.limit + newMangaListResponse.offset < newMangaListResponse.total + } + } + + val readingStatusResponse = network.authClient.newCall(readingStatusRequest()).execute() + val json = MdUtil.jsonParser.decodeFromString(readingStatusResponse.body!!.string()) + + followsParseMangaPage(results, json.statuses).manga } } suspend fun fetchTrackingInfo(url: String): Track { return withContext(Dispatchers.IO) { + val mangaId = getMangaId(url) val request = GET( - "${MdUtil.apiUrl}${MdUtil.followsMangaApi}" + getMangaId(url), - headers, + MdUtil.getReadingStatusUrl(mangaId), + MdUtil.getAuthHeaders(network.headers, preferences), CacheControl.FORCE_NETWORK ) - val response = client.newCall(request).execute() - val track = followStatusParse(response) + val response = network.authClient.newCall(request).await() + val track = followStatusParse(response, mangaId) track } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaHandler.kt index 1b7a4606a5..621dd4eccc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaHandler.kt @@ -1,27 +1,36 @@ package eu.kanade.tachiyomi.source.online.handlers import com.elvishew.xlog.XLog +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.online.handlers.serializers.ApiCovers +import eu.kanade.tachiyomi.source.online.handlers.serializers.ChapterListResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.ChapterResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.GroupListResponse import eu.kanade.tachiyomi.source.online.utils.MdUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString import okhttp3.CacheControl -import okhttp3.Headers -import okhttp3.OkHttpClient import okhttp3.Request import rx.Observable +import uy.kohesive.injekt.injectLazy -class MangaHandler(val client: OkHttpClient, val headers: Headers, private val langs: List, private val forceLatestCovers: Boolean = false) { +class MangaHandler() { + + val network: NetworkHelper by injectLazy() + val filterHandler: FilterHandler by injectLazy() + val preferencesHelper: PreferencesHelper by injectLazy() + val apiMangaParser: ApiMangaParser by injectLazy() suspend fun fetchMangaAndChapterDetails(manga: SManga): Pair> { return withContext(Dispatchers.IO) { - val response = client.newCall(apiRequest(manga)).execute() - val covers = getCovers(manga, forceLatestCovers) - val parser = ApiMangaParser(langs) + + val response = network.client.newCall(mangaRequest(manga)).await() val jsonData = response.body!!.string() if (response.code != 200) { @@ -33,9 +42,11 @@ class MangaHandler(val client: OkHttpClient, val headers: Headers, private val l } } - val detailsManga = parser.mangaDetailsParse(jsonData, covers) + val detailsManga = apiMangaParser.mangaDetailsParse(jsonData) manga.copyFrom(detailsManga) - val chapterList = parser.chapterListParse(jsonData) + + val chapterList = fetchChapterList(manga) + Pair( manga, chapterList @@ -43,77 +54,142 @@ class MangaHandler(val client: OkHttpClient, val headers: Headers, private val l } } - suspend fun getCovers(manga: SManga, forceLatestCovers: Boolean): List { - if (forceLatestCovers) { - val response = client.newCall(coverRequest(manga)).execute() - val covers = MdUtil.jsonParser.decodeFromString(ApiCovers.serializer(), response.body!!.string()) - return covers.data.map { it.url } - } else { - return emptyList() - } - } - - suspend fun getMangaIdFromChapterId(urlChapterId: String): Int { + suspend fun getMangaIdFromChapterId(urlChapterId: String): String { return withContext(Dispatchers.IO) { - val request = GET(MdUtil.apiUrl + MdUtil.newApiChapter + urlChapterId + MdUtil.apiChapterSuffix, headers, CacheControl.FORCE_NETWORK) - val response = client.newCall(request).execute() - ApiMangaParser(langs).chapterParseForMangaId(response) + val request = GET(MdUtil.chapterUrl + urlChapterId) + val response = network.client.newCall(request).await() + apiMangaParser.chapterParseForMangaId(response) } } suspend fun fetchMangaDetails(manga: SManga): SManga { return withContext(Dispatchers.IO) { - val response = client.newCall(apiRequest(manga)).execute() - val covers = getCovers(manga, forceLatestCovers) - ApiMangaParser(langs).mangaDetailsParse(response, covers).apply { initialized = true } + val response = network.client.newCall(mangaRequest(manga)).await() + apiMangaParser.mangaDetailsParse(response.body!!.string()).apply { initialized = true } } } fun fetchMangaDetailsObservable(manga: SManga): Observable { - return client.newCall(apiRequest(manga)) + return network.client.newCall(mangaRequest(manga)) .asObservableSuccess() .map { response -> - ApiMangaParser(langs).mangaDetailsParse(response, emptyList()).apply { initialized = true } + apiMangaParser.mangaDetailsParse(response.body!!.string()).apply { initialized = true } } } fun fetchChapterListObservable(manga: SManga): Observable> { - return client.newCall(apiRequest(manga)) + val langs = MdUtil.getLangsToShow(preferencesHelper) + return network.client.newCall(mangaFeedRequest(manga, 0, langs)) .asObservableSuccess() .map { response -> - ApiMangaParser(langs).chapterListParse(response) + if (response.code == 204) { + return@map emptyList() + } + + val chapterListResponse = MdUtil.jsonParser.decodeFromString(response.body!!.string()) + val results = chapterListResponse.results.toMutableList() + + var hasMoreResults = chapterListResponse.limit + chapterListResponse.offset < chapterListResponse.total + + var offset = chapterListResponse.offset + val limit = chapterListResponse.limit + + while (hasMoreResults) { + offset += limit + val newResponse = network.client.newCall(mangaFeedRequest(manga, offset, langs)).execute() + val newChapterListResponse = MdUtil.jsonParser.decodeFromString(newResponse.body!!.string()) + results.addAll(newChapterListResponse.results) + hasMoreResults = newChapterListResponse.limit + newChapterListResponse.offset < newChapterListResponse.total + } + val groupIds = results.map { chapter -> chapter.relationships }.flatten().filter { it.type == "scanlation_group" }.map { it.id }.distinct() + + val groupMap = runCatching { + groupIds.chunked(100).mapIndexed { index, ids -> + val newResponse = network.client.newCall(groupIdRequest(ids, 100 * index)).execute() + val groupList = MdUtil.jsonParser.decodeFromString(GroupListResponse.serializer(), newResponse.body!!.string()) + groupList.results.map { group -> Pair(group.data.id, group.data.attributes.name) } + }.flatten().toMap() + }.getOrNull() ?: emptyMap() + + + apiMangaParser.chapterListParse(results, groupMap) } } suspend fun fetchChapterList(manga: SManga): List { return withContext(Dispatchers.IO) { - val response = client.newCall(apiRequest(manga)).execute() - ApiMangaParser(langs).chapterListParse(response) + val langs = MdUtil.getLangsToShow(preferencesHelper) + val response = network.client.newCall(mangaFeedRequest(manga, 0, langs)).await() + if (response.isSuccessful.not()) { + XLog.e("error", response.body!!.string()) + throw Exception("error returned from MangaDex. Http code : ${response.code}") + } + if (response.code == 204) { + return@withContext emptyList() + } + val chapterListResponse = MdUtil.jsonParser.decodeFromString(response.body!!.string()) + val results = chapterListResponse.results.toMutableList() + + var hasMoreResults = chapterListResponse.limit + chapterListResponse.offset < chapterListResponse.total + + var offset = chapterListResponse.offset + val limit = chapterListResponse.limit + + while (hasMoreResults) { + offset += limit + val newResponse = network.client.newCall(mangaFeedRequest(manga, offset, langs)).await() + if (newResponse.code != 200) { + hasMoreResults = false + } else { + val newChapterListResponse = MdUtil.jsonParser.decodeFromString(newResponse.body!!.string()) + results.addAll(newChapterListResponse.results) + hasMoreResults = newChapterListResponse.limit + newChapterListResponse.offset < newChapterListResponse.total + } + } + + val groupMap = getGroupMap(results) + + apiMangaParser.chapterListParse(results, groupMap) } } + private suspend fun getGroupMap(results: List): Map { + val groupIds = results.map { chapter -> chapter.relationships }.flatten().filter { it.type == "scanlation_group" }.map { it.id }.distinct() + val groupMap = runCatching { + groupIds.chunked(100).mapIndexed { index, ids -> + val newResponse = network.client.newCall(groupIdRequest(ids, 100 * index)).await() + val groupList = MdUtil.jsonParser.decodeFromString(newResponse.body!!.string()) + groupList.results.map { group -> Pair(group.data.id, group.data.attributes.name) } + }.flatten().toMap() + }.getOrNull() ?: emptyMap() + + return groupMap + } + fun fetchRandomMangaId(): Observable { - return client.newCall(randomMangaRequest()) + return network.client.newCall(randomMangaRequest()) .asObservableSuccess() .map { response -> - ApiMangaParser(langs).randomMangaIdParse(response) + apiMangaParser.randomMangaIdParse(response) } } private fun randomMangaRequest(): Request { - return GET(MdUtil.baseUrl + MdUtil.randMangaPage) + return GET(MdUtil.randomMangaUrl) } - private fun apiRequest(manga: SManga): Request { - return GET(MdUtil.apiUrl + MdUtil.apiManga + MdUtil.getMangaId(manga.url) + MdUtil.includeChapters, headers, CacheControl.FORCE_NETWORK) + private fun mangaRequest(manga: SManga): Request { + return GET(MdUtil.mangaUrl + "/" + MdUtil.getMangaId(manga.url), network.headers, CacheControl.FORCE_NETWORK) } - private fun coverRequest(manga: SManga): Request { - return GET(MdUtil.apiUrl + MdUtil.apiManga + MdUtil.getMangaId(manga.url) + MdUtil.apiCovers, headers, CacheControl.FORCE_NETWORK) + private fun mangaFeedRequest(manga: SManga, offset: Int, langs: List): Request { + return GET(MdUtil.mangaFeedUrl(MdUtil.getMangaId(manga.url), offset, langs), network.headers, CacheControl.FORCE_NETWORK) } - companion object { + private fun groupIdRequest(id: List, offset: Int): Request { + val urlSuffix = id.joinToString("&ids[]=", "?limit=100&offset=$offset&ids[]=") + return GET(MdUtil.groupUrl + urlSuffix, network.headers) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaPlusHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaPlusHandler.kt index 9205c3caab..ff4a52bc6d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaPlusHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaPlusHandler.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.online.handlers import MangaPlusSerializer import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.source.model.Page import kotlinx.serialization.protobuf.ProtoBuf import okhttp3.Headers @@ -11,9 +12,11 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import uy.kohesive.injekt.injectLazy import java.util.UUID -class MangaPlusHandler(private val currentClient: OkHttpClient) { +class MangaPlusHandler() { + val networkHelper: NetworkHelper by injectLazy() val baseUrl = "https://jumpg-webapi.tokyo-cdn.com/api" val headers = Headers.Builder() .add("Origin", WEB_URL) @@ -21,7 +24,7 @@ class MangaPlusHandler(private val currentClient: OkHttpClient) { .add("User-Agent", USER_AGENT) .add("SESSION-TOKEN", UUID.randomUUID().toString()).build() - val client: OkHttpClient = currentClient.newBuilder() + val client: OkHttpClient = networkHelper.nonRateLimitedClient.newBuilder() .addInterceptor { imageIntercept(it) } .build() diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PageHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PageHandler.kt index 8bef8109b6..a683a41233 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PageHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PageHandler.kt @@ -1,36 +1,48 @@ package eu.kanade.tachiyomi.source.online.handlers +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.online.utils.MdUtil import okhttp3.CacheControl -import okhttp3.Headers -import okhttp3.OkHttpClient import okhttp3.Request import rx.Observable +import uy.kohesive.injekt.injectLazy -class PageHandler(val client: OkHttpClient, val headers: Headers, private val imageServer: String, val dataSaver: String?) { +class PageHandler { + + val network: NetworkHelper by injectLazy() + val preferences: PreferencesHelper by injectLazy() + val mangaPlusHandler: MangaPlusHandler by injectLazy() fun fetchPageList(chapter: SChapter): Observable> { if (chapter.scanlator.equals("MangaPlus")) { - return client.newCall(pageListRequest(chapter)) + return network.client.newCall(pageListRequest(chapter)) .asObservableSuccess() .map { response -> val chapterId = ApiChapterParser().externalParse(response) - MangaPlusHandler(client).fetchPageList(chapterId) + mangaPlusHandler.fetchPageList(chapterId) } } - return client.newCall(pageListRequest(chapter)) + + val atHomeRequestUrl = if (preferences.usePort443Only()) { + "${MdUtil.atHomeUrl}/${chapter.mangadex_chapter_id}?forcePort443=true" + } else { + "${MdUtil.atHomeUrl}/${chapter.mangadex_chapter_id}" + } + + return network.client.newCall(pageListRequest(chapter)) .asObservableSuccess() .map { response -> - ApiChapterParser().pageListParse(response) + val host = MdUtil.atHomeUrlHostUrl(atHomeRequestUrl, network.client, CacheControl.FORCE_NETWORK) + ApiChapterParser().pageListParse(response, host, preferences.dataSaver()) } } private fun pageListRequest(chapter: SChapter): Request { - val chpUrl = chapter.url.replace(MdUtil.oldApiChapter, MdUtil.newApiChapter).substringBefore(MdUtil.apiChapterSuffix) - return GET("${MdUtil.apiUrl}${chpUrl}${MdUtil.apiChapterSuffix}&server=$imageServer&saver=$dataSaver", headers, CacheControl.FORCE_NETWORK) + return GET("${MdUtil.chapterUrl}${chapter.mangadex_chapter_id}", network.headers, CacheControl.FORCE_NETWORK) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PopularHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PopularHandler.kt index 3f1ea44cf8..1723f9784a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PopularHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PopularHandler.kt @@ -1,32 +1,32 @@ package eu.kanade.tachiyomi.source.online.handlers -import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.handlers.serializers.MangaListResponse import eu.kanade.tachiyomi.source.online.utils.MdUtil -import eu.kanade.tachiyomi.util.asJsoup -import eu.kanade.tachiyomi.util.site.setUrlWithoutDomain +import eu.kanade.tachiyomi.v5.db.V5DbHelper import okhttp3.CacheControl -import okhttp3.Headers -import okhttp3.OkHttpClient +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Request import okhttp3.Response -import org.jsoup.nodes.Element import rx.Observable import uy.kohesive.injekt.injectLazy /** * Returns the latest manga from the updates url since it actually respects the users settings */ -class PopularHandler(val client: OkHttpClient, private val headers: Headers) { +class PopularHandler { + private val filterHandler: FilterHandler by injectLazy() private val preferences: PreferencesHelper by injectLazy() + private val network: NetworkHelper by injectLazy() + private val v5DbHelper: V5DbHelper by injectLazy() fun fetchPopularManga(page: Int): Observable { - return client.newCall(popularMangaRequest(page)) + return network.client.newCall(popularMangaRequest(page)) .asObservableSuccess() .map { response -> popularMangaParse(response) @@ -34,43 +34,29 @@ class PopularHandler(val client: OkHttpClient, private val headers: Headers) { } private fun popularMangaRequest(page: Int): Request { - XLog.tag("NEKO-LATEST").d("latest manga parse page $page") - return GET("${MdUtil.baseUrl}/updates/$page/", headers, CacheControl.FORCE_NETWORK) - } - private fun popularMangaParse(response: Response): MangasPage { - val document = response.asJsoup() - if (document.toString().contains("Due to a recent hacking incident")) { - throw Exception("MangaDex appears to be down, or under heavy load") - } + val tempUrl = MdUtil.mangaUrl.toHttpUrlOrNull()!!.newBuilder() - val mangas = document.select(popularMangaSelector).map { element -> - popularMangaFromElement(element) - }.distinctBy { it.url } - - val hasNextPage = popularMangaNextPageSelector.let { selector -> - document.select(selector).first() - } != null - XLog.tag("NEKO-LATEST").d("latest manga parse has next page $hasNextPage") + tempUrl.apply { + addQueryParameter("limit", MdUtil.mangaLimit.toString()) + addQueryParameter("offset", (MdUtil.getMangaListOffset(page))) + } + val finalUrl = filterHandler.addFiltersToUrl(tempUrl, filterHandler.getMDFilterList()) - return MangasPage(mangas, hasNextPage) + return GET(finalUrl, network.headers, CacheControl.FORCE_NETWORK) } - private fun popularMangaFromElement(element: Element): SManga { - val manga = SManga.create() - element.select("a.manga_title").first().let { - val url = MdUtil.modifyMangaUrl(it.attr("href")) - manga.setUrlWithoutDomain(url) - manga.title = it.text().trim() + private fun popularMangaParse(response: Response): MangasPage { + if (response.isSuccessful.not()) { + throw Exception("Error getting search manga http code: ${response.code}") + } + if (response.code == 204) { + return MangasPage(emptyList(), false) } - manga.thumbnail_url = MdUtil.formThumbUrl(manga.url, preferences.lowQualityCovers()) - - return manga - } - - companion object { - const val popularMangaSelector = "tr a.manga_title" - const val popularMangaNextPageSelector = ".pagination li:not(.disabled) span[title*=last page]:not(disabled)" + val mlResponse = MdUtil.jsonParser.decodeFromString(MangaListResponse.serializer(), response.body!!.string()) + val hasMoreResults = mlResponse.limit + mlResponse.offset < mlResponse.total + val mangaList = mlResponse.results.map { MdUtil.createMangaEntry(it, preferences, v5DbHelper) } + return MangasPage(mangaList, hasMoreResults) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SearchHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SearchHandler.kt index 1f7025344d..d2ca913c59 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SearchHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SearchHandler.kt @@ -2,50 +2,41 @@ package eu.kanade.tachiyomi.source.online.handlers import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.handlers.serializers.MangaListResponse import eu.kanade.tachiyomi.source.online.utils.MdUtil -import eu.kanade.tachiyomi.util.asJsoup -import eu.kanade.tachiyomi.util.site.setUrlWithoutDomain +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import kotlinx.serialization.decodeFromString import okhttp3.CacheControl -import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.jsoup.nodes.Element import rx.Observable import uy.kohesive.injekt.injectLazy -class SearchHandler(val client: OkHttpClient, private val headers: Headers, val langs: List) { - +class SearchHandler { + private val network: NetworkHelper by injectLazy() + private val filterHandler: FilterHandler by injectLazy() private val preferences: PreferencesHelper by injectLazy() + private val apiMangaParser: ApiMangaParser by injectLazy() + private val v5DbHelper: V5DbHelper by injectLazy() fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return if (query.startsWith(PREFIX_ID_SEARCH)) { val realQuery = query.removePrefix(PREFIX_ID_SEARCH) - client.newCall(searchMangaByIdRequest(realQuery)) + network.client.newCall(searchMangaByIdRequest(realQuery)) .asObservableSuccess() .map { response -> - val details = ApiMangaParser(langs).mangaDetailsParse(response, emptyList()) - details.url = "/manga/$realQuery/" + val details = apiMangaParser.mangaDetailsParse(response.body!!.string()) + details.url = "/title/$realQuery/" MangasPage(listOf(details), false) } - } else if (query.startsWith(PREFIX_GROUP_SEARCH)) { - val realQuery = query.removePrefix(PREFIX_GROUP_SEARCH) - client.newCall(searchMangaByGroupRequest(realQuery)) - .asObservableSuccess() - .map { response -> - response.asJsoup().select(groupSelector).firstOrNull()?.attr("abs:href") - ?.let { - searchMangaParse(client.newCall(GET("$it/manga/0", headers)).execute()) - } - ?: MangasPage(emptyList(), false) - } } else { - client.newCall(searchMangaRequest(page, query, filters)) + network.client.newCall(searchMangaRequest(page, query, filters)) .asObservableSuccess() .map { response -> searchMangaParse(response) @@ -54,158 +45,44 @@ class SearchHandler(val client: OkHttpClient, private val headers: Headers, val } private fun searchMangaParse(response: Response): MangasPage { - val document = response.asJsoup() - - val mangas = document.select(searchMangaSelector).map { element -> - searchMangaFromElement(element) + if (response.isSuccessful.not()) { + throw Exception("Error getting search manga http code: ${response.code}") } - val hasNextPage = searchMangaNextPageSelector.let { selector -> - document.select(selector).first() - } != null + if (response.code == 204) { + return MangasPage(emptyList(), false) + } - return MangasPage(mangas, hasNextPage) + val mlResponse = MdUtil.jsonParser.decodeFromString(response.body!!.string()) + val hasMoreResults = mlResponse.limit + mlResponse.offset < mlResponse.total + val mangaList = mlResponse.results.map { MdUtil.createMangaEntry(it, preferences, v5DbHelper) } + return MangasPage(mangaList, hasMoreResults) } private fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val tags = mutableListOf() - val statuses = mutableListOf() - val demographics = mutableListOf() + val tempUrl = MdUtil.mangaUrl.toHttpUrlOrNull()!!.newBuilder() - // Do traditional search - val url = "${MdUtil.baseUrl}/?page=search".toHttpUrlOrNull()!!.newBuilder() - .addQueryParameter("p", page.toString()) - .addQueryParameter("title", query.replace(WHITESPACE_REGEX, " ")) - - filters.forEach { filter -> - when (filter) { - is FilterHandler.TextField -> url.addQueryParameter(filter.key, filter.state) - is FilterHandler.DemographicList -> { - filter.state.forEach { demographic -> - if (demographic.state) { - demographics.add(demographic.id) - } - } - } - is FilterHandler.PublicationStatusList -> { - filter.state.forEach { status -> - if (status.state) { - statuses.add(status.id) - } - } - } - is FilterHandler.OriginalLanguage -> { - if (filter.state != 0) { - val number: String = - FilterHandler.sourceLang().first { it -> it.first == filter.values[filter.state] } - .second - url.addQueryParameter("lang_id", number) - } - } - is FilterHandler.TagInclusionMode -> { - url.addQueryParameter("tag_mode_inc", arrayOf("all", "any")[filter.state]) - } - is FilterHandler.TagExclusionMode -> { - url.addQueryParameter("tag_mode_exc", arrayOf("all", "any")[filter.state]) - } - is FilterHandler.ContentList -> { - filter.state.forEach { content -> - if (content.isExcluded()) { - tags.add("-${content.id}") - } else if (content.isIncluded()) { - tags.add(content.id) - } - } - } - is FilterHandler.FormatList -> { - filter.state.forEach { format -> - if (format.isExcluded()) { - tags.add("-${format.id}") - } else if (format.isIncluded()) { - tags.add(format.id) - } - } - } - is FilterHandler.GenreList -> { - filter.state.forEach { genre -> - if (genre.isExcluded()) { - tags.add("-${genre.id}") - } else if (genre.isIncluded()) { - tags.add(genre.id) - } - } - } - is FilterHandler.ThemeList -> { - filter.state.forEach { theme -> - if (theme.isExcluded()) { - tags.add("-${theme.id}") - } else if (theme.isIncluded()) { - tags.add(theme.id) - } - } - } - is FilterHandler.SortFilter -> { - if (filter.state != null) { - val sortables = FilterHandler.sortables() - if (filter.state!!.ascending) { - url.addQueryParameter( - "s", - sortables[filter.state!!.index].second.toString() - ) - } else { - url.addQueryParameter( - "s", - sortables[filter.state!!.index].third.toString() - ) - } - } - } + tempUrl.apply { + addQueryParameter("limit", MdUtil.mangaLimit.toString()) + addQueryParameter("offset", (MdUtil.getMangaListOffset(page))) + val actualQuery = query.replace(WHITESPACE_REGEX, " ") + if (actualQuery.isNotBlank()) { + addQueryParameter("title", actualQuery) } } - // Manually append genres list to avoid commas being encoded - var urlToUse = url.toString() - if (demographics.isNotEmpty()) { - urlToUse += "&demos=" + demographics.joinToString(",") - } - if (statuses.isNotEmpty()) { - urlToUse += "&statuses=" + statuses.joinToString(",") - } - if (tags.isNotEmpty()) { - urlToUse += "&tags=" + tags.joinToString(",") - } - return GET(urlToUse, headers, CacheControl.FORCE_NETWORK) - } - - private fun searchMangaFromElement(element: Element): SManga { - val manga = SManga.create() - element.select("a.manga_title").first().let { - val url = MdUtil.modifyMangaUrl(it.attr("href")) - manga.setUrlWithoutDomain(url) - manga.title = it.text().trim() - } + val finalUrl = filterHandler.addFiltersToUrl(tempUrl, filters) - manga.thumbnail_url = MdUtil.formThumbUrl(manga.url, preferences.lowQualityCovers()) - - return manga + return GET(finalUrl, network.headers, CacheControl.FORCE_NETWORK) } private fun searchMangaByIdRequest(id: String): Request { - return GET(MdUtil.apiUrl + MdUtil.apiManga + id + MdUtil.includeChapters, headers, CacheControl.FORCE_NETWORK) - } - - private fun searchMangaByGroupRequest(group: String): Request { - return GET(MdUtil.groupSearchUrl + group, headers, CacheControl.FORCE_NETWORK) + return GET(MdUtil.mangaUrl + "/" + id, network.headers, CacheControl.FORCE_NETWORK) } companion object { const val PREFIX_ID_SEARCH = "id:" - const val PREFIX_GROUP_SEARCH = "group:" val WHITESPACE_REGEX = "\\s".toRegex() - const val searchMangaNextPageSelector = - ".pagination li:not(.disabled) span[title*=last page]:not(disabled)" - const val searchMangaSelector = "div.manga-entry" - const val groupSelector = ".table > tbody:nth-child(2) > tr:nth-child(1) > td:nth-child(2) > a" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SimilarHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SimilarHandler.kt index 633e1f687b..54b483971a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SimilarHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SimilarHandler.kt @@ -2,44 +2,80 @@ package eu.kanade.tachiyomi.source.online.handlers import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaSimilarImpl -import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.database.models.MangaSimilar +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.handlers.serializers.SimilarMangaResponse import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries +import kotlinx.serialization.decodeFromString +import okhttp3.CacheControl +import okhttp3.Request +import okhttp3.Response import rx.Observable -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy -class SimilarHandler(val preferences: PreferencesHelper) { +class SimilarHandler { + + private val network: NetworkHelper by injectLazy() + private val db: DatabaseHelper by injectLazy() + private val v5DbHelper: V5DbHelper by injectLazy() /** * fetch our similar mangas */ - fun fetchSimilar(manga: Manga): Observable { + fun fetchSimilarObserable(manga: Manga, refresh: Boolean): Observable { + val mangaDb = db.getSimilar(MdUtil.getMangaId(manga.url)).executeAsBlocking() + if (mangaDb != null && !refresh) { + return Observable.just(similarStringToMangasPage(manga, mangaDb.data)) + } + return network.client.newCall(similarMangaRequest(manga)) + .asObservableSuccess() + .map { response -> + similarMangaParse(manga, response) + } + } - // Parse the Mangadex id from the URL - val mangaid = MdUtil.getMangaId(manga.url).toLong() - val lowQualityCovers = preferences.lowQualityCovers() - val cacheResponse = preferences.useCacheSource() + private fun similarMangaRequest(manga: Manga): Request { + val tempUrl = MdUtil.similarBaseApi + MdUtil.getMangaId(manga.url) + ".json" + return GET(tempUrl, network.headers, CacheControl.FORCE_NETWORK) + } - // Get our current database - val db = Injekt.get() - val similarMangaDb = db.getSimilar(mangaid).executeAsBlocking() ?: return Observable.just(MangasPage(mutableListOf(), false)) + private fun similarMangaParse(manga: Manga, response: Response): MangasPage { + // Error check http response + if (response.code == 404) { + return MangasPage(emptyList(), false) + } + if (response.isSuccessful.not() || response.code != 200) { + throw Exception("Error getting search manga http code: ${response.code}") + } + // Get our page of mangas + val bodyData = response.body!!.string() + val mangaPages = similarStringToMangasPage(manga, bodyData) + // Insert into our database and return + val mangaSimilar = MangaSimilar.create().apply { + manga_id = MdUtil.getMangaId(manga.url) + data = bodyData + } + db.insertSimilar(mangaSimilar).executeAsBlocking() + return mangaPages + } - // Check if we have a result - val similarMangaTitles = similarMangaDb.matched_titles.split(MangaSimilarImpl.DELIMITER) - val similarMangaIds = similarMangaDb.matched_ids.split(MangaSimilarImpl.DELIMITER) - val similarMangas = similarMangaIds.mapIndexed { index, similarId -> + private fun similarStringToMangasPage(manga: Manga, data: String): MangasPage { + // TODO: also filter based on the content rating here? + // TODO: also append here the related manga? + val mlResponse = MdUtil.jsonParser.decodeFromString(data) + val mangaList = mlResponse.matches.map { SManga.create().apply { - initialized = false - title = similarMangaTitles[index] - url = "/manga/$similarId/" - thumbnail_url = if(cacheResponse) null else MdUtil.formThumbUrl(url, lowQualityCovers) + url = "/title/" + it.id + title = MdUtil.cleanString(it.title["en"]!!) + thumbnail_url = V5DbQueries.getAltCover(v5DbHelper.dbCovers, it.id) ?: MdUtil.imageUrlCacheNotFound } } - - // Return the matches - return Observable.just(MangasPage(similarMangas, false)) + return MangasPage(mangaList, false) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ApiChapterSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ApiChapterSerializer.kt deleted file mode 100644 index 382368bdd4..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ApiChapterSerializer.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eu.kanade.tachiyomi.source.online.handlers.serializers - -import kotlinx.serialization.Serializable - -@Serializable -data class ApiChapterSerializer( - val data: ChapterPageSerializer -) - -@Serializable -data class ChapterPageSerializer( - val hash: String, - val pages: List, - val server: String, - val mangaId: Int -) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ApiMangaSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ApiMangaSerializer.kt deleted file mode 100644 index 1ef59a7a14..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ApiMangaSerializer.kt +++ /dev/null @@ -1,76 +0,0 @@ -package eu.kanade.tachiyomi.source.online.handlers.serializers - -import kotlinx.serialization.Serializable - -@Serializable -data class ApiMangaSerializer( - val data: DataSerializer, - val status: String -) - -@Serializable -data class DataSerializer( - val manga: MangaSerializer, - val chapters: List, - val groups: List, - - ) - -@Serializable -data class MangaSerializer( - val artist: List, - val author: List, - val mainCover: String, - val description: String, - val tags: List, - val isHentai: Boolean, - val lastChapter: String? = null, - val publication: PublicationSerializer? = null, - val links: LinksSerializer? = null, - val rating: RatingSerializer? = null, - val title: String -) - -@Serializable -data class PublicationSerializer( - val language: String? = null, - val status: Int, - val demographic: Int? - -) - -@Serializable -data class LinksSerializer( - val al: String? = null, - val amz: String? = null, - val ap: String? = null, - val engtl: String? = null, - val kt: String? = null, - val mal: String? = null, - val mu: String? = null, - val raw: String? = null -) - -@Serializable -data class RatingSerializer( - val bayesian: String? = null, - val mean: String? = null, - val users: String? = null -) - -@Serializable -data class ChapterSerializer( - val id: Long, - val volume: String? = null, - val chapter: String? = null, - val title: String? = null, - val language: String, - val groups: List, - val timestamp: Long -) - -@Serializable -data class GroupSerializer( - val id: Long, - val name: String? = null -) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/Auth.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/Auth.kt new file mode 100644 index 0000000000..9863b98539 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/Auth.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.source.online.handlers.serializers + +import kotlinx.serialization.Serializable + +/** + * Login Request object for Dex Api + */ +@Serializable +data class LoginRequest(val username: String, val password: String) + +/** + * Response after login + */ +@Serializable +data class LoginResponse(val result: String, val token: LoginBodyToken) + +/** + * Tokens for the logins + */ +@Serializable +data class LoginBodyToken(val session: String, val refresh: String) + +/** + * Response after logout + */ +@Serializable +data class LogoutResponse(val result: String) + +/** + * Check if session token is valid + */ +@Serializable +data class CheckTokenResponse(val isAuthenticated: Boolean) + +/** + * Request to refresh token + */ +@Serializable +data class RefreshTokenRequest(val token: String) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CacheApiMangaSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CacheApiMangaSerializer.kt index 939927e409..1ca2285878 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CacheApiMangaSerializer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CacheApiMangaSerializer.kt @@ -4,37 +4,50 @@ import kotlinx.serialization.Serializable @Serializable data class CacheApiMangaSerializer( - val id: Long, - val title: String, - val url: String, - val description: String, - val is_r18: Boolean, - val rating: Float, - val demographic: List, - val content: List, - val format: List, - val genre: List, - val theme: List, - val languages: List, - val related: List, - val external: MutableMap, - val last_updated: String, - val matches: List, + val result : String, + val data : CacheApiData, + val relationships : List ) @Serializable -data class CacheRelatedSerializer( - val id: Long, - val title: String, - val type: String, - val r18: Boolean, +data class CacheApiRelationships ( + val id : String, + val type : String ) @Serializable -data class CacheSimilarMatchesSerializer( - val id: Long, - val title: String, - val score: Float, - val r18: Boolean, - val languages: List, +data class CacheApiData ( + val id : String, + val type : String, + val attributes : CacheApiDataAttributes ) + +@Serializable +data class CacheApiDataAttributes( + val title : Map, + val description : Map, + val links : Map? = null, + val originalLanguage : String? = null, + val lastChapter : String? = null, + val publicationDemographic : String? = null, + val status : String? = null, + val contentRating : String? = null, + val tags : List? = null, + val version : Int, + val createdAt : String, + val updatedAt : String +) + +@Serializable +data class CacheApiTags ( + val id : String, + val type : String, + val attributes : CacheApiTagAttributes +) + +@Serializable +data class CacheApiTagAttributes ( + val name : Map, + val version : Int, +) + diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ChapterSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ChapterSerializer.kt new file mode 100644 index 0000000000..38259dabe1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ChapterSerializer.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.source.online.handlers.serializers + +import kotlinx.serialization.Serializable + +@Serializable +data class ChapterListResponse( + val limit: Int, + val offset: Int, + val total: Int, + val results: List +) + +@Serializable +data class ChapterResponse( + val result: String, + val data: NetworkChapter, + val relationships: List +) + +@Serializable +data class NetworkChapter( + val id: String, + val type: String, + val attributes: ChapterAttributes, +) + +@Serializable +data class ChapterAttributes( + val title: String?, + val volume: String?, + val chapter: String?, + val translatedLanguage: String, + val publishAt: String, + val data: List, + val dataSaver: List, + val hash: String, +) + +@Serializable +data class AtHomeResponse( + val baseUrl: String +) + +@Serializable +data class GroupListResponse( + val limit: Int, + val offset: Int, + val total: Int, + val results: List +) + +@Serializable +data class GroupResponse( + val result: String, + val data: GroupData, +) + +@Serializable +data class GroupData( + val id: String, + val attributes: GroupAttributes, +) + +@Serializable +data class GroupAttributes( + val name: String, +) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CoversSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CoversSerializer.kt deleted file mode 100644 index 23419fe723..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CoversSerializer.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eu.kanade.tachiyomi.source.online.handlers.serializers - -import kotlinx.serialization.Serializable - -@Serializable -data class ApiCovers( - val data: List, -) - -@Serializable -data class CoversResult( - val volume: String, - val url: String -) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/FollowsPageSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/FollowsPageSerializer.kt deleted file mode 100644 index 7a03c7f2d7..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/FollowsPageSerializer.kt +++ /dev/null @@ -1,24 +0,0 @@ -package eu.kanade.tachiyomi.source.online.handlers.serializers - -import kotlinx.serialization.Serializable - -@Serializable -data class FollowsPageSerializer( - val code: Int, - val data: List? = null -) - -@Serializable -data class FollowsIndividualSerializer( - val code: Int, - val data: FollowPage? = null -) - -@Serializable -data class FollowPage( - val mangaTitle: String, - val chapter: String, - val followType: Int, - val mangaId: Int, - val volume: String -) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ImageReportSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ImageReportSerializer.kt index 455f273275..895a1b3a66 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ImageReportSerializer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ImageReportSerializer.kt @@ -6,5 +6,7 @@ import kotlinx.serialization.Serializable data class ImageReportResult( val url: String, val success: Boolean, - val bytes: Int? + val bytes: Int?, + val cache: Boolean, + val duration: Long, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/IsLoggedInSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/IsLoggedInSerializer.kt deleted file mode 100644 index bbd24e09f6..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/IsLoggedInSerializer.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eu.kanade.tachiyomi.source.online.handlers.serializers - -import kotlinx.serialization.Serializable - -@Serializable - -data class IsLoggedInSerializer(val code: Int) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/MangaSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/MangaSerializer.kt new file mode 100644 index 0000000000..a1fec7a369 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/MangaSerializer.kt @@ -0,0 +1,85 @@ +package eu.kanade.tachiyomi.source.online.handlers.serializers + +import kotlinx.serialization.Serializable + +@Serializable +data class MangaListResponse( + val limit: Int, + val offset: Int, + val total: Int, + val results: List +) + +@Serializable +data class MangaResponse( + val result: String, + val data: NetworkManga, + val relationships: List +) + +@Serializable +data class NetworkManga(val id: String, val type: String, val attributes: NetworkMangaAttributes) + +@Serializable +data class NetworkMangaAttributes( + val title: Map, + val altTitles: List>, + val description: Map, + val links: Map?, + val originalLanguage: String, + val lastVolume: String?, + val lastChapter: String?, + val contentRating: String?, + val publicationDemographic: String?, + val status: String?, + val year: Int?, + val tags: List, +) + +@Serializable +data class TagsSerializer( + val id: String +) + +@Serializable +data class Relationships( + val id: String, + val type: String, +) + +@Serializable +data class AuthorResponseList( + val results: List, +) + +@Serializable +data class AuthorResponse( + val result: String, + val data: NetworkAuthor, +) + +@Serializable +data class NetworkAuthor( + val id: String, + val attributes: AuthorAttributes, +) + +@Serializable +data class AuthorAttributes( + val name: String, +) + +@Serializable +data class GetReadingStatus( + val status: String? +) + +@Serializable +data class UpdateReadingStatus( + val status: String? +) + +@Serializable +data class MangaStatusListResponse( + val statuses: Map +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/SimilarSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/SimilarSerializer.kt new file mode 100644 index 0000000000..425e45eee6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/SimilarSerializer.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.source.online.handlers.serializers + +import kotlinx.serialization.Serializable + +@Serializable +data class SimilarMangaResponse( + val id : String, + val title: Map, + val contentRating : String, + val matches : List, + val updatedAt : String +) + +@Serializable +data class Matches( + val id : String, + val title : Map, + val contentRating : String, + val score : Double +) + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/FollowStatus.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/FollowStatus.kt index fca2cb629d..5ae65b2e8a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/FollowStatus.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/FollowStatus.kt @@ -1,5 +1,7 @@ package eu.kanade.tachiyomi.source.online.utils +import java.util.Locale + enum class FollowStatus(val int: Int) { UNFOLLOWED(0), READING(1), @@ -10,6 +12,7 @@ enum class FollowStatus(val int: Int) { RE_READING(6); companion object { - fun fromInt(value: Int): FollowStatus = values().find { it.int == value } ?: UNFOLLOWED + fun fromDex(value: String?): FollowStatus = values().firstOrNull { it.name.toLowerCase(Locale.US) == value } ?: UNFOLLOWED + fun fromInt(value: Int): FollowStatus = values().firstOrNull { it.int == value } ?: UNFOLLOWED } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdLang.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdLang.kt index bdf5f488c1..b8634d9a97 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdLang.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdLang.kt @@ -1,45 +1,53 @@ package eu.kanade.tachiyomi.source.online.utils -enum class MdLang(val lang: String, val dexLang: String, val langId: Int) { - English("en", "gb", 1), - Japanese("ja", "jp", 2), - Polish("pl", "pl", 3), - SerboCroatian("sh", "rs", 4), - Dutch("nl", "nl", 5), - Italian("it", "it", 6), - Russian("ru", "ru", 7), - German("de", "de", 8), - Hungarian("hu", "hu", 9), - French("fr", "fr", 10), - Finnish("fi", "fi", 11), - Vietnamese("vi", "vn", 12), - Greek("el", "gr", 13), - Bulgarian("bg", "bg", 14), - Spanish("es", "es", 15), - PortugeseBrazilian("pt-BR", "br", 16), - Portuguese("pt", "pt", 17), - Swedish("sv", "se", 18), - Arabic("ar", "sa", 19), - Danish("da", "dk", 20), - ChineseSimplifed("zh-Hans", "cn", 21), - Bengali("bn", "bd", 22), - Romanian("ro", "ro", 23), - Czech("cs", "cz", 24), - Mongolian("mn", "mn", 25), - Turkish("tr", "tr", 26), - Indonesian("id", "id", 27), - Korean("ko", "kr", 28), - SpanishLTAM("es-419", "mx", 29), - Persian("fa", "ir", 30), - Malay("ms", "my", 31), - Thai("th", "th", 32), - Catalan("ca", "ct", 33), - Filipino("fil", "ph", 34), - ChineseTraditional("zh-Hant", "hk", 35), - Ukrainian("uk", "ua", 36), - Burmese("my", "mm", 37), - Lithuanian("lt", "lt", 38), - Hebrew("he", "il", 39), - Hindi("hi", "in", 40), - Norwegian("no", "no", 42) +enum class MdLang(val lang: String, val prettyPrint: String) { + ENGLISH("en", "English"), + JAPANESE("jp", "Japanese"), + POLISH("pl", "Polish"), + SERBO_CROATIAN("rs", "Serbo-Croatian"), + DUTCH("nl", "Dutch"), + ITALIAN("it", "IT"), + RUSSIAN("ru", "Russian"), + GERMAN("de", "German"), + HUNGARIAN("hu", "Hungarian"), + FRENCH("fr", "French"), + FINNISH("fi", "Finnish"), + VIETNAMESE("vn", "Vietnamese"), + GREEK("gr", "Greek"), + BULGARIAN("bg", "BULGARIN"), + SPANISH_ES("es", "Spanish (Es)"), + PORTUGUESE_BR("br", "Portuguese (Br)"), + PORTUGUESE("pt", "Portuguese (Pt)"), + SWEDISH("se", "Swedish"), + ARABIC("sa", "Arabic"), + DANISH("dk", "Danish"), + CHINESE_SIMPLIFIED("cn", "Chinese (Simp)"), + BENGALI("bd", "Bengali"), + ROMANIAN("ro", "Romanian"), + CZECH("cz", "Czech"), + MONGOLIAN("mn", "Mongolian"), + TURKISH("tr", "Turkish"), + INDONESIAN("id", "Indonesian"), + KOREAN("kr", "Korean"), + SPANISH_LATAM("mx", "Spanish (LATAM)"), + PERSIAN("ir", "Persian"), + MALAY("my", "Malay"), + THAI("th", "Thai"), + CATALAN("ct", "Catalan"), + FILIPINO("ph", "Filipino"), + CHINESE_TRAD("hk", "Chinese (Trad)"), + UKRAINIAN("ua", "Ukrainian"), + BURMESE("mm", "Burmese"), + LINTHUANIAN("lt", "Lithuanian"), + HEBREW("il", "Hebrew"), + HINDI("in", "Hindi"), + NORWEGIAN("no", "Norwegian") + ; + + companion object { + fun fromIsoCode(isoCode: String): MdLang? = + values().firstOrNull { + it.lang == isoCode + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdUtil.kt index e05d86776d..a3b0c46dc2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdUtil.kt @@ -1,9 +1,23 @@ package eu.kanade.tachiyomi.source.online.utils +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.handlers.serializers.AtHomeResponse +import eu.kanade.tachiyomi.source.online.handlers.serializers.MangaResponse +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import okhttp3.CacheControl +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient import org.jsoup.parser.Parser +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone import kotlin.math.floor class MdUtil { @@ -11,24 +25,49 @@ class MdUtil { companion object { const val cdnUrl = "https://mangadex.org" // "https://s0.mangadex.org" const val baseUrl = "https://mangadex.org" - const val randMangaPage = "/manga/" - const val apiUrl = "http://api.mangadex.org.dev.mdcloud.moe" - const val apiUrlCdnCache = "https://cdn.statically.io/gh/goldbattle/MangadexRecomendations/master/output/api/" - const val apiUrlCache = "https://raw.githubusercontent.com/goldbattle/MangadexRecomendations/master/output/api/" + const val apiUrl = "https://api.mangadex.org" const val imageUrlCacheNotFound = "https://cdn.statically.io/img/raw.githubusercontent.com/CarlosEsco/Neko/master/.github/manga_cover_not_found.png" - const val apiManga = "/v2/manga/" - const val includeChapters = "?include=chapters" - const val oldApiChapter = "/api/chapter/" - const val newApiChapter = "/v2/chapter/" - const val apiChapterSuffix = "?mark_read=0" - const val groupSearchUrl = "$baseUrl/groups/0/1/" - const val followsAllApi = "/v2/user/me/followed-manga" - const val isLoggedInApi = "/v2/user/me" - const val followsMangaApi = "/v2/user/me/manga/" + const val atHomeUrl = "$apiUrl/at-home/server" + const val chapterUrl = "$apiUrl/chapter/" + const val chapterSuffix = "/chapter/" + const val checkTokenUrl = "$apiUrl/auth/check" + const val refreshTokenUrl = "$apiUrl/auth/refresh" + const val loginUrl = "$apiUrl/auth/login" + const val logoutUrl = "$apiUrl/auth/logout" + const val groupUrl = "$apiUrl/group" + const val authorUrl = "$apiUrl/author" + const val randomMangaUrl = "$apiUrl/manga/random" + const val mangaUrl = "$apiUrl/manga" + const val userFollowsUrl = "$apiUrl/user/follows/manga" + const val readingStatusesUrl = "$apiUrl/manga/status" + fun getReadingStatusUrl(id: String) = "$apiUrl/manga/$id/status" + + fun mangaFeedUrl(id: String, offset: Int, language: List): String { + return "$mangaUrl/$id/feed".toHttpUrl().newBuilder().apply { + addQueryParameter("limit", "500") + addQueryParameter("offset", offset.toString()) + addQueryParameter("order[volume]", "desc") + addQueryParameter("order[chapter]", "desc") + language.forEach { + addQueryParameter("locales[]", it) + } + }.build().toString() + } + + const val similarCacheMapping = "https://api.similarmanga.com/mapping/mdex2search.csv" + const val similarCacheMangas = "https://api.similarmanga.com/manga/" + const val similarBaseApi = "https://api.similarmanga.com/similar/" + const val apiCovers = "/covers" const val reportUrl = "https://api.mangadex.network/report" - const val imageUrl = "$baseUrl/data" - const val apiLogin = "/auth/login" + + const val mdAtHomeTokenLifespan = 10 * 60 * 1000 + const val mangaLimit = 40 + + /** + * Get the manga offset pages are 1 based, so subtract 1 + */ + fun getMangaListOffset(page: Int): String = (mangaLimit * (page - 1)).toString() val jsonParser = Json { @@ -42,6 +81,11 @@ class MdUtil { private const val scanlatorSeparator = " & " + const val contentRatingSafe = "safe" + const val contentRatingSuggestive = "suggestive" + const val contentRatingErotica = "erotica" + const val contentRatingPornographic = "pornographic" + val validOneShotFinalChapters = listOf("0", "1") val englishDescriptionTags = listOf( @@ -160,20 +204,11 @@ class MdUtil { // Get the ID from the manga url fun getMangaId(url: String): String { - val lastSection = url.trimEnd('/').substringAfterLast("/") - return if (lastSection.toIntOrNull() != null) { - lastSection - } else { - // this occurs if person has manga from before that had the id/name/ - url.trimEnd('/').substringBeforeLast("/").substringAfterLast("/") - } + val id = url.trimEnd('/').substringAfterLast("/") + return id } - fun getChapterId(url: String) = url.substringBeforeLast(apiChapterSuffix).substringAfterLast("/") - - // creates the manga url from the browse for the api - fun modifyMangaUrl(url: String): String = - url.replace("/title/", "/manga/").substringBeforeLast("/") + "/" + fun getChapterId(url: String) = url.substringAfterLast("/") fun cleanString(string: String): String { var cleanedString = string @@ -247,5 +282,30 @@ class MdUtil { } return null } + + fun atHomeUrlHostUrl(requestUrl: String, client: OkHttpClient, cacheControl: CacheControl): String { + val atHomeRequest = GET(requestUrl, cache = cacheControl) + val atHomeResponse = client.newCall(atHomeRequest).execute() + return jsonParser.decodeFromString(atHomeResponse.body!!.string()).baseUrl + } + + val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + + fun parseDate(dateAsString: String): Long = + dateFormatter.parse(dateAsString)?.time ?: 0 + + fun createMangaEntry(json: MangaResponse, preferences: PreferencesHelper, v5DbHelper: V5DbHelper): SManga { + return SManga.create().apply { + url = "/title/" + json.data.id + title = cleanString(json.data.attributes.title["en"]!!) + thumbnail_url = V5DbQueries.getAltCover(v5DbHelper.dbCovers, json.data.id) ?: imageUrlCacheNotFound + //thumbnail_url = formThumbUrl(url, preferences.lowQualityCovers()) + } + } + + fun getLangsToShow(preferences: PreferencesHelper) = preferences.langsToShow().get().split(",") + + fun getAuthHeaders(headers: Headers, preferences: PreferencesHelper) = headers.newBuilder().add("Authorization", "Bearer ${preferences.sessionToken()!!}").build() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/EnableSimilarDialogController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/EnableSimilarDialogController.kt deleted file mode 100644 index 8921428a47..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/EnableSimilarDialogController.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.ui.main - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.similar.SimilarUpdateJob -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class EnableSimilarDialogController : DialogController() { - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - val preferences = Injekt.get() - return MaterialDialog(activity) - .title(text = activity.getString(R.string.similar_ask_to_enable_title)) - .message(R.string.similar_ask_to_enable) - .negativeButton(R.string.similar_ask_to_enable_no, activity.getString(R.string.similar_ask_to_enable_no)) - .positiveButton(R.string.similar_ask_to_enable_yes, activity.getString(R.string.similar_ask_to_enable_yes)) { - preferences.similarEnabled().set(true) - SimilarUpdateJob.setupTask() - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 96470578d9..ff3f46a45c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -289,10 +289,6 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin // Show changelog or similar manga enabling on install prompt // NOTE: we show the similar manga dialog first so it is behind the changelog if (Migrations.upgrade(preferences)) { - if (!preferences.similarEnabled().get() && !preferences.shownSimilarAskDialog().get()) { - EnableSimilarDialogController().showDialog(router) - preferences.shownSimilarAskDialog().set(true) - } if (!BuildConfig.DEBUG) ChangelogDialogController().showDialog(router) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index ede5a63956..45d523c1b9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -567,9 +567,9 @@ class MangaDetailsController : // Inflate our menu resource into the PopupMenu's Menu popup.menuInflater.inflate(R.menu.chapter_single, popup.menu) - if (!item.chapter.isMergedChapter()) { - popup.menu.findItem(R.id.action_view_comments).isVisible = true - } + //if (!item.chapter.isMergedChapter()) { + // popup.menu.findItem(R.id.action_view_comments).isVisible = true + //} popup.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index 7c2a8013c4..11c7e2fd9b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -43,6 +43,8 @@ import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.executeOnIO +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -69,6 +71,7 @@ class MangaDetailsPresenter( private val db: DatabaseHelper = Injekt.get(), val downloadManager: DownloadManager = Injekt.get(), val sourceManager: SourceManager = Injekt.get(), + val v5DbHelper: V5DbHelper = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get() ) : DownloadQueue.DownloadListener, LibraryServiceListener { @@ -468,9 +471,16 @@ class MangaDetailsPresenter( } } + // First if no image, try to get one from our V5 database // If we don't have an image we can try to use the merge source image fallback - if (networkManga.thumbnail_url == null && manga.merge_manga_image_url != null) { - manga.thumbnail_url = manga.merge_manga_image_url + if (networkManga.thumbnail_url == null) { + val cover = V5DbQueries.getAltCover(v5DbHelper.dbCovers, + MdUtil.getMangaId(networkManga.url)) + if(cover != null) { + manga.thumbnail_url = cover + } else if(manga.merge_manga_image_url != null) { + manga.thumbnail_url = manga.merge_manga_image_url + } } db.insertManga(manga).executeOnIO() } @@ -857,8 +867,6 @@ class MangaDetailsPresenter( fun isTracked(): Boolean = loggedServices.any { service -> tracks.any { it.sync_id == service.id } } - fun similarEnabled(): Boolean = preferences.similarEnabled().get() - // Tracking private fun setTrackItems() { trackList = loggedServices.map { service -> @@ -1066,7 +1074,7 @@ class MangaDetailsPresenter( } fun similarToolTip() { - if (similarEnabled() && !preferences.shownSimilarTutorial().get()) { + if (!preferences.shownSimilarTutorial().get()) { scope.launch { withContext(Dispatchers.IO) { delay(1500) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt index 704990f770..a6a290c654 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt @@ -148,11 +148,13 @@ class MangaHeaderHolder( ) else manga_genres_tags.setTags(emptyList()) + if (manga.author == manga.artist || manga.artist.isNullOrBlank()) { manga_author.text = manga.author?.trim() } else { manga_author.text = listOfNotNull(manga.author?.trim(), manga.artist?.trim()).joinToString(", ") } + manga_summary.text = if (manga.description.isNullOrBlank()) itemView.context.getString(R.string.no_description) else manga.description?.trim() @@ -190,7 +192,6 @@ class MangaHeaderHolder( } with(similar_button) { - visibleIf(presenter.similarEnabled()) setImageDrawable(context.iconicsDrawableLarge(MaterialDesignDx.Icon.gmf_account_tree)) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index e0a862ea0c..85d0e83454 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -22,6 +22,7 @@ import android.widget.SeekBar import android.widget.Toast import androidx.appcompat.app.AppCompatDelegate import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.elvishew.xlog.XLog import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -386,11 +387,11 @@ class ReaderActivity : } } - comment_button.setImageDrawable(this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_comment, size = 22)) - - comment_button.setOnClickListener { - openComments() - } + //comment_button.setImageDrawable(this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_comment, size = 22)) + //comment_button.setOnClickListener { + // openComments() + //} + comment_button.isVisible = false chapters_button.setOnClickListener { if (chapters_bottom_sheet.sheetBehavior?.isExpanded() == true) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index b4672acc31..74c7e8806c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -312,7 +312,7 @@ class ReaderPresenter( } suspend fun loadChapterURL(urlChapterId: String) { - val dbChapter = db.getChapter(MdUtil.oldApiChapter + urlChapterId).executeOnIO() + val dbChapter = db.getChapter(MdUtil.chapterSuffix + urlChapterId).executeOnIO() if (dbChapter?.manga_id != null) { val dbManga = db.getManga(dbChapter.manga_id!!).executeAsBlocking() if (dbManga != null) { @@ -324,7 +324,7 @@ class ReaderPresenter( } val mangaDex = sourceManager.getMangadex() val mangaId = mangaDex.getMangaIdFromChapterId(urlChapterId) - val url = "/manga/$mangaId/" + val url = "/title/$mangaId/" val dbManga = db.getMangadexManga(url).executeAsBlocking() val tempManga = dbManga ?: ( MangaImpl().apply { @@ -345,7 +345,7 @@ class ReaderPresenter( if (chapters.isNotEmpty()) { val (newChapters, _) = syncChaptersWithSource(db, chapters, manga) - val currentChapter = newChapters.find { it.url == MdUtil.oldApiChapter + urlChapterId } + val currentChapter = newChapters.find { it.url == MdUtil.chapterSuffix + urlChapterId } if (currentChapter?.id != null) { withContext(Dispatchers.Main) { init(manga, currentChapter.id!!) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt index 75811ac832..94d2119792 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt @@ -127,6 +127,17 @@ class AboutController : SettingsController() { } } + preference { + titleRes = R.string.similar_credit_title + val url = "https://github.com/similar-manga/similar" + summary = context.resources.getString(R.string.similar_credit_message, url) + onClick { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + startActivity(intent) + } + isIconSpaceReserved = true + } + preference { titleRes = R.string.open_source_licenses diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index 44d457b244..602e87c16d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -53,6 +53,8 @@ class SettingsAdvancedController : SettingsController() { private val coverCache: CoverCache by injectLazy() + private val downloadMangager: DownloadManager by injectLazy() + @SuppressLint("BatteryLife") override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.advanced @@ -79,6 +81,13 @@ class SettingsAdvancedController : SettingsController() { preferenceCategory { titleRes = R.string.data_management + + preference { + titleRes = R.string.force_download_cache_refresh + summaryRes = R.string.force_download_cache_refresh_summary + onClick { downloadMangager.refreshCache() } + } + preference { key = CLEAR_CACHE_KEY titleRes = R.string.clear_chapter_cache @@ -158,6 +167,7 @@ class SettingsAdvancedController : SettingsController() { onClick { LibraryUpdateService.start(context, target = Target.TRACKING) } } + } intListPreference(activity) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index 403bcac4b1..6e55b3e1f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.util.system.iconicsDrawableMedium import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.view.withFadeTransaction +import eu.kanade.tachiyomi.v5.job.V5MigrationJob class SettingsMainController : SettingsController() { @@ -26,6 +27,15 @@ class SettingsMainController : SettingsController() { val size = 18 + preference { + iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_warning) + titleRes = R.string.v5_migration_service + summary = context.resources.getString(R.string.v5_migration_desc) + onClick { + V5MigrationJob.doWorkNow() + } + } + preference { iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_tune) titleRes = R.string.general @@ -57,11 +67,6 @@ class SettingsMainController : SettingsController() { titleRes = R.string.tracking onClick { navigateTo(SettingsTrackingController()) } } - preference { - iconDrawable = context.iconicsDrawableMedium(CommunityMaterial.Icon.cmd_chart_histogram) - titleRes = R.string.similar - onClick { navigateTo(SettingsSimilarController()) } - } preference { iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_backup) titleRes = R.string.backup diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSimilarController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSimilarController.kt deleted file mode 100644 index c8542cebb1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSimilarController.kt +++ /dev/null @@ -1,119 +0,0 @@ -package eu.kanade.tachiyomi.ui.setting - -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.Intent -import androidx.core.net.toUri -import androidx.preference.PreferenceScreen -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.similar.SimilarUpdateJob -import eu.kanade.tachiyomi.util.system.getFilePicker -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys - -class SettingsSimilarController : SettingsController() { - - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { - titleRes = R.string.similar_settings - - preference { - summary = context.resources.getString(R.string.similar_screen_summary_message) - isIconSpaceReserved = true - } - - switchPreference { - key = Keys.similarEnabled - titleRes = R.string.similar_screen - defaultValue = false - onClick { - SimilarUpdateJob.setupTask() - } - } - - switchPreference { - key = Keys.similarOnlyOverWifi - titleRes = R.string.only_download_over_wifi - defaultValue = true - onClick { - SimilarUpdateJob.setupTask(true) - } - } - - preference { - titleRes = R.string.similar_manually_update - summary = context.resources.getString(R.string.similar_manually_update_message) - onClick { - SimilarUpdateJob.doWorkNow() - context.toast(R.string.similar_manually_toast) - } - } - - intListPreference(activity) { - key = Keys.similarUpdateInterval - titleRes = R.string.similar_update_fequency - entriesRes = arrayOf( - R.string.manual, - R.string.daily, - R.string.every_2_days, - R.string.every_3_days, - R.string.weekly, - R.string.monthly - ) - entryValues = listOf(0, 1, 2, 3, 7, 30) - defaultValue = 3 - - onChange { - SimilarUpdateJob.setupTask(true) - true - } - } - - preference { - titleRes = R.string.similar_from_file - summary = context.resources.getString(R.string.similar_from_file_message) - onClick { - val chooseFile = Intent(Intent.ACTION_GET_CONTENT) - chooseFile.type = "*/*" - val intent = Intent.createChooser(chooseFile, "Choose a file") - try { - startActivityForResult(intent, RELATED_FILE_PATH_L) - } catch (e: ActivityNotFoundException) { - startActivityForResult(preferences.context.getFilePicker("/"), RELATED_FILE_PATH_L) - } - } - } - - preference { - titleRes = R.string.similar_credit_title - val url = "https://github.com/goldbattle/MangadexRecomendations" - summary = context.resources.getString(R.string.similar_credit_message, url) - onClick { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - startActivity(intent) - } - isIconSpaceReserved = true - } - } - - /** - * This function is the callback from our file listing activity. - */ - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - RELATED_FILE_PATH_L -> if (data != null && resultCode == Activity.RESULT_OK) { - val context = applicationContext ?: return - val selectedFile = data.data - if (selectedFile != null) { - SimilarUpdateJob.doWorkNowLocal(selectedFile) - context.toast(R.string.similar_manually_toast) - } else { - context.toast(R.string.similar_loading_complete_error) - } - } - } - } - - private companion object { - const val RELATED_FILE_PATH_L = 105 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSiteController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSiteController.kt index 035b76d7be..0a10828a90 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSiteController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSiteController.kt @@ -11,11 +11,10 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.similar.SimilarUpdateJob +import eu.kanade.tachiyomi.data.similar.MangaCacheUpdateJob import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.source.online.MangaDex import eu.kanade.tachiyomi.source.online.utils.MdLang import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.widget.preference.MangadexLoginDialog @@ -70,57 +69,65 @@ class SettingsSiteController : MaterialDialog(activity!!).show { message(R.string.use_cache_source_dialog) positiveButton(android.R.string.ok) { - SimilarUpdateJob.doWorkNow(true) + MangaCacheUpdateJob.doWorkNow() } } } } } - listPreference(activity) { - key = PreferenceKeys.showR18 - titleRes = R.string.show_r18_title + multiSelectListPreferenceMat(activity) { + key = PreferenceKeys.contentRating + titleRes = R.string.content_rating_title + customSummaryRes = R.string.content_rating_summary entriesRes = arrayOf( - R.string.show_r18_no, - R.string.show_r18_all, - R.string.show_r18_show + R.string.content_rating_safe, + R.string.content_rating_suggestive, + R.string.content_rating_erotica, + R.string.content_rating_pornographic, ) - entryValues = listOf("0", "1", "2") - summary = "%s" + entryValues = listOf( + "safe", "suggestive", "erotica", "pornographic" + ) + + defSet = setOf("safe", "suggestive") + + defaultValue = listOf("safe", "suggestive") } + switchPreference { - key = PreferenceKeys.showR18Filter - titleRes = R.string.show_r18_filter_in_search + key = PreferenceKeys.showContentRatingFilter + titleRes = R.string.show_content_rating_filter_in_search defaultValue = true } - listPreference(activity) { - key = PreferenceKeys.imageServer - titleRes = R.string.image_server - entries = MangaDex.SERVER_PREF_ENTRIES - entryValues = MangaDex.SERVER_PREF_ENTRY_VALUES - summary = "%s" + switchPreference { + key = PreferenceKeys.enablePort443Only + titleRes = R.string.use_port_443_title + summaryRes = R.string.use_port_443_summary + defaultValue = true } + switchPreference { key = PreferenceKeys.dataSaver titleRes = R.string.data_saver defaultValue = false } - switchPreference { - key = PreferenceKeys.lowQualityCovers - titleRes = R.string.low_quality_covers - defaultValue = false - } + /* switchPreference { + key = PreferenceKeys.lowQualityCovers + titleRes = R.string.low_quality_covers + defaultValue = false + }*/ - switchPreference { - key = PreferenceKeys.forceLatestCovers - titleRes = R.string.use_latest_cover - summaryRes = R.string.use_latest_cover_summary - defaultValue = false - } + /* switchPreference { + key = PreferenceKeys.forceLatestCovers + titleRes = R.string.use_latest_cover + summaryRes = R.string.use_latest_cover_summary + defaultValue = false + }*/ preference { titleRes = R.string.sync_follows_to_library @@ -196,9 +203,9 @@ class SettingsSiteController : override fun onCreateDialog(savedViewState: Bundle?): Dialog { val activity = activity!! - val options = MdLang.values().map { Pair(it.dexLang, it.name) } + val options = MdLang.values().map { Pair(it.lang, it.name) } val initialLangs = preferences!!.langsToShow().get().split(",") - .map { lang -> options.indexOfFirst { it.first.equals(lang) } }.toIntArray() + .map { lang -> options.indexOfFirst { it.first == lang } }.toIntArray() return MaterialDialog(activity) .title(R.string.show_languages) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarController.kt index 79a8197769..40cf9d5db1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarController.kt @@ -4,21 +4,40 @@ import android.app.Activity import android.os.Bundle import android.view.Menu import android.view.View +import android.view.ViewGroup +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsSingleChoice +import com.google.android.material.snackbar.Snackbar import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.library.LibraryGroup +import eu.kanade.tachiyomi.ui.manga.MangaDetailsPresenter import eu.kanade.tachiyomi.ui.manga.similar.SimilarPresenter import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.marginTop +import eu.kanade.tachiyomi.util.view.scrollViewWith +import eu.kanade.tachiyomi.util.view.snack +import eu.kanade.tachiyomi.util.view.updateLayoutParams import kotlinx.android.synthetic.main.browse_source_controller.* +import kotlinx.android.synthetic.main.browse_source_controller.empty_view +import kotlinx.android.synthetic.main.browse_source_controller.swipe_refresh +import kotlinx.android.synthetic.main.library_list_controller.* +import kotlinx.android.synthetic.main.manga_details_controller.* /** * Controller that shows the latest manga from the catalogue. Inherit [BrowseCatalogueController]. */ class SimilarController(bundle: Bundle) : BrowseSourceController(bundle) { + lateinit var similarPresenter : SimilarPresenter + constructor(manga: Manga, source: Source) : this( Bundle().apply { putLong(MANGA_ID, manga.id!!) @@ -32,16 +51,19 @@ class SimilarController(bundle: Bundle) : BrowseSourceController(bundle) { } override fun createPresenter(): BrowseSourcePresenter { - return SimilarPresenter(bundle!!.getLong(MANGA_ID)) + similarPresenter = SimilarPresenter(bundle!!.getLong(MANGA_ID), this) + return similarPresenter } override fun onViewCreated(view: View) { super.onViewCreated(view) fab.gone() - } - - override fun onActivityPaused(activity: Activity) { - super.onActivityPaused(activity) + swipe_refresh.isEnabled = true + swipe_refresh.isRefreshing = similarPresenter.isRefreshing + swipe_refresh.setProgressViewOffset(false, 0.dpToPx, 120.dpToPx) + swipe_refresh.setOnRefreshListener { + similarPresenter.refreshSimilarManga() + } } override fun onPrepareOptionsMenu(menu: Menu) { @@ -50,6 +72,11 @@ class SimilarController(bundle: Bundle) : BrowseSourceController(bundle) { menu.findItem(R.id.action_open_in_web_view).isVisible = false } + fun showUserMessage(message: String) { + swipe_refresh?.isRefreshing = similarPresenter.isRefreshing + view?.snack(message, Snackbar.LENGTH_LONG) + } + /** * Called from the presenter when the network request fails. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPager.kt index fc4ad4de28..b5fa4c5ed7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPager.kt @@ -15,11 +15,11 @@ import rx.schedulers.Schedulers class SimilarPager(val manga: Manga, val source: Source) : Pager() { override fun requestNext(): Observable { - return source.fetchMangaSimilarObservable(manga) + return source.fetchMangaSimilarObservable(manga, false) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnNext { - if (it.mangas.isNotEmpty()) { + if (it.manga.isNotEmpty()) { onPageReceived(it) } else { throw NoResultsException() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPresenter.kt index e1ab072669..c3fdfbbe1c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPresenter.kt @@ -1,19 +1,66 @@ package eu.kanade.tachiyomi.ui.manga.similar +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.ui.similar.SimilarController import eu.kanade.tachiyomi.ui.source.browse.BrowseSourcePresenter import eu.kanade.tachiyomi.ui.source.browse.Pager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get /** * Presenter of [SimilarController]. Inherit BrowseSourcePresenter. */ -class SimilarPresenter(val mangaId: Long) : BrowseSourcePresenter() { +class SimilarPresenter( + val mangaId: Long, + private val controller: SimilarController, + val preferences: PreferencesHelper = Injekt.get() +) : BrowseSourcePresenter() { var manga: Manga? = null + var isRefreshing: Boolean = false + var scope = CoroutineScope(Job() + Dispatchers.Default) override fun createPager(query: String, filters: FilterList): Pager { this.manga = db.getManga(mangaId).executeAsBlocking() return SimilarPager(this.manga!!, source) } + + fun refreshSimilarManga() { + scope.launch { + withContext(Dispatchers.IO) { + isRefreshing = true + try { + val manga = db.getManga(mangaId).executeAsBlocking() + source.fetchMangaSimilarObservable(manga!!, true).toBlocking().first() + isRefreshing = false + withContext(Dispatchers.Main) { + controller.showUserMessage("Updated Similar Mangas!") + } + } catch (e: java.lang.Exception) { + isRefreshing = false + withContext(Dispatchers.Main) { + controller.showUserMessage(trimException(e)) + } + } + } + restartPager() + } + } + + private fun trimException(e: java.lang.Exception): String { + return ( + if (e.message?.contains(": ") == true) e.message?.split(": ")?.drop(1) + ?.joinToString(": ") + else e.message + ) ?: preferences.context.getString(R.string.unknown_error) + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt index bf397641ef..6cf96e2f84 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt @@ -1,13 +1,23 @@ package eu.kanade.tachiyomi.ui.source.browse +import android.app.SearchManager +import android.database.Cursor +import android.database.MatrixCursor import android.os.Bundle +import android.os.Handler +import android.provider.BaseColumns import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.AutoCompleteTextView +import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView +import androidx.cursoradapter.widget.CursorAdapter +import androidx.cursoradapter.widget.SimpleCursorAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -16,11 +26,14 @@ import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import com.jakewharton.rxbinding.support.v7.widget.queryTextChangeEvents import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import com.pushtorefresh.storio.sqlite.queries.RawQuery 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.Category import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.tables.CachedMangaTable import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.model.Filter @@ -40,6 +53,7 @@ import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController import eu.kanade.tachiyomi.util.view.gone import eu.kanade.tachiyomi.util.view.inflate import eu.kanade.tachiyomi.util.view.scrollViewWith +import eu.kanade.tachiyomi.util.view.setStyle import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.util.view.visible @@ -48,12 +62,16 @@ import eu.kanade.tachiyomi.util.view.withFadeTransaction import eu.kanade.tachiyomi.widget.AutofitRecyclerView import eu.kanade.tachiyomi.widget.EmptyView import kotlinx.android.synthetic.main.browse_source_controller.* +import kotlinx.android.synthetic.main.browse_source_controller.empty_view +import kotlinx.android.synthetic.main.browse_source_controller.progress +import kotlinx.android.synthetic.main.browse_source_controller.swipe_refresh +import kotlinx.android.synthetic.main.library_list_controller.* import kotlinx.android.synthetic.main.main_activity.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import rx.Observable import rx.Subscription -import rx.android.schedulers.AndroidSchedulers import uy.kohesive.injekt.injectLazy -import java.util.concurrent.TimeUnit /** * Controller to manage the catalogues available in the app. @@ -92,6 +110,11 @@ open class BrowseSourceController(bundle: Bundle) : */ private val preferences: PreferencesHelper by injectLazy() + /** + * Database used for autocomplete + */ + private val db: DatabaseHelper by injectLazy() + /** * Adapter containing the list of manga from the catalogue. */ @@ -117,6 +140,12 @@ open class BrowseSourceController(bundle: Bundle) : */ private var progressItem: ProgressItem? = null + /** + * Our query mangadex which will query at a slow rate + */ + private var canRun: Boolean = true + private var handler: Handler = Handler() + init { setHasOptionsMenu(true) } @@ -139,7 +168,7 @@ open class BrowseSourceController(bundle: Bundle) : if (presenter.source.isLogged().not()) { view.snack("You must be logged it. please login") } - if(preferences.useCacheSource()){ + if (preferences.useCacheSource()) { view.snack("Browsing Cached Source") } if (bundle?.getBoolean(APPLY_INSET) == true) { @@ -150,6 +179,11 @@ open class BrowseSourceController(bundle: Bundle) : adapter = FlexibleAdapter(null, this) setupRecycler(view) + // Disable refresh by default + swipe_refresh.setStyle() + swipe_refresh.isRefreshing = false + swipe_refresh.isEnabled = false + fab.visibleIf(presenter.sourceFilters.isNotEmpty() && preferences.useCacheSource().not()) fab.setOnClickListener { showFilters() } progress?.visible() @@ -253,6 +287,7 @@ open class BrowseSourceController(bundle: Bundle) : activity!!.getString(R.string.show_library_manga) } } + } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -638,10 +673,22 @@ open class BrowseSourceController(bundle: Bundle) : } override fun expandSearch() { + // Initialize search menu val searchItem = activity?.toolbar?.menu?.findItem(R.id.action_search)!! val searchView = searchItem.actionView as SearchView + // Autocomplete searching cursor + // https://github.com/korydondzila/kotlin-search + searchView.queryHint = activity!!.getString(R.string.search) + val autoCompleteTextView = searchView.findViewById(R.id.search_src_text) + autoCompleteTextView.threshold = 1 + val from = arrayOf(SearchManager.SUGGEST_COLUMN_TEXT_1) + val to = intArrayOf(R.id.item_label) + val cursorAdapter = SimpleCursorAdapter(activity!!, R.layout.search_item, null, from, to, CursorAdapter.FLAG_AUTO_REQUERY) + searchView.suggestionsAdapter = cursorAdapter + + // Create our subscribers for this search menu val query = presenter.query if (!query.isBlank()) { searchItem.expandActionView() @@ -657,14 +704,59 @@ open class BrowseSourceController(bundle: Bundle) : .share() val writingObservable = searchEventsObservable .filter { !it.isSubmitted } - .debounce(1250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) val submitObservable = searchEventsObservable .filter { it.isSubmitted } searchViewSubscription?.unsubscribe() searchViewSubscription = Observable.merge(writingObservable, submitObservable) - .map { it.queryText().toString() }.filter { it != SearchHandler.PREFIX_GROUP_SEARCH && it != SearchHandler.PREFIX_ID_SEARCH } - .subscribeUntilDestroy { searchWithQuery(it) } + .filter { it.queryText().toString() != SearchHandler.PREFIX_ID_SEARCH } + .subscribeUntilDestroy { + + // Update our cursor for our autocomplete + val cursor = MatrixCursor(arrayOf(BaseColumns._ID, SearchManager.SUGGEST_COLUMN_TEXT_1)) + if(!it.isSubmitted) { + try { + val matches = db.searchCachedManga(it.queryText().toString(), 0, 10).executeAsBlocking() + val matchesClean = matches.filter { manga -> + manga.rating in preferences.contentRatingSelections() + } + matchesClean.take(3).forEachIndexed { index, suggestion -> + cursor.addRow(arrayOf(index, suggestion.title)) + } + } catch (e: Exception) { + XLog.e(e) + } + } + cursorAdapter.changeCursor(cursor) + + // Actually search mangadex for the result with debounce + // https://stackoverflow.com/a/34994785/7718197 + if (canRun || it.isSubmitted) { + canRun = false + handler.postDelayed({ + canRun = true + searchWithQuery(it.queryText().toString()) + }, 1250) + } + + } + + // Callback if the user presses one of auto complete items + // This should fill in the query text and then submit the form + searchView.setOnSuggestionListener(object: SearchView.OnSuggestionListener { + override fun onSuggestionSelect(position: Int): Boolean { + return false + } + override fun onSuggestionClick(position: Int): Boolean { + val inputMethodManager = activity!!.getSystemService(AppCompatActivity.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(view?.windowToken, 0) + val cursor = searchView.suggestionsAdapter.getItem(position) as Cursor + val selection = cursor.getString(cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_1)) + searchView.setQuery(selection, true) + return true + } + }) + } protected companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceGridHolder.kt index 3710eae7b2..0b42595ddc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceGridHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceGridHolder.kt @@ -9,7 +9,6 @@ import coil.request.ImageRequest import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.potentialAltThumbnail import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget import eu.kanade.tachiyomi.ui.library.LibraryCategoryAdapter import eu.kanade.tachiyomi.util.view.gone @@ -65,9 +64,9 @@ class BrowseSourceGridHolder( if (manga.thumbnail_url == null) { cover_thumbnail.clear() } else { - val id = manga.id ?: return + manga.id ?: return val request = ImageRequest.Builder(view.context).data(manga) - .target(CoverViewTarget(cover_thumbnail, progress, manga.potentialAltThumbnail())).build() + .target(CoverViewTarget(cover_thumbnail, progress /* manga.potentialAltThumbnail()*/)).build() Coil.imageLoader(view.context).enqueue(request) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePager.kt index f6e8303860..b7936fc336 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePager.kt @@ -21,7 +21,7 @@ open class BrowseSourcePager(val source: Source, val query: String, val filters: .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnNext { - if (it.mangas.isNotEmpty()) { + if (it.manga.isNotEmpty()) { onPageReceived(it) } else { throw NoResultsException() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/Pager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/Pager.kt index 378839f7af..15612341b0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/Pager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/Pager.kt @@ -24,7 +24,7 @@ abstract class Pager(var currentPage: Int = 1) { fun onPageReceived(mangasPage: MangasPage) { val page = currentPage currentPage++ - hasNextPage = mangasPage.hasNextPage && mangasPage.mangas.isNotEmpty() - results.call(Pair(page, mangasPage.mangas)) + hasNextPage = mangasPage.hasNextPage && mangasPage.manga.isNotEmpty() + results.call(Pair(page, mangasPage.manga)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt new file mode 100644 index 0000000000..ce760e03d5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt @@ -0,0 +1,85 @@ +package eu.kanade.tachiyomi.util.lang + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import rx.Emitter +import rx.Observable +import rx.Subscriber +import rx.Subscription +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/* + * Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY. + */ + +suspend fun Observable.awaitSingle(): T = single().awaitOne() + +private suspend fun Observable.awaitOne(): T = suspendCancellableCoroutine { cont -> + cont.unsubscribeOnCancellation( + subscribe( + object : Subscriber() { + override fun onStart() { + request(1) + } + + override fun onNext(t: T) { + cont.resume(t) + } + + override fun onCompleted() { + if (cont.isActive) cont.resumeWithException( + IllegalStateException( + "Should have invoked onNext" + ) + ) + } + + override fun onError(e: Throwable) { + /* + * Rx1 observable throws NoSuchElementException if cancellation happened before + * element emission. To mitigate this we try to atomically resume continuation with exception: + * if resume failed, then we know that continuation successfully cancelled itself + */ + val token = cont.tryResumeWithException(e) + if (token != null) { + cont.completeResume(token) + } + } + } + ) + ) +} + +internal fun CancellableContinuation.unsubscribeOnCancellation(sub: Subscription) = + invokeOnCancellation { sub.unsubscribe() } + +fun runAsObservable( + block: suspend () -> T, + backpressureMode: Emitter.BackpressureMode = Emitter.BackpressureMode.NONE +): Observable { + return Observable.create( + { emitter -> + val job = GlobalScope.launch(Dispatchers.Unconfined, start = CoroutineStart.ATOMIC) { + try { + emitter.onNext(block()) + emitter.onCompleted() + } catch (e: Throwable) { + // Ignore `CancellationException` as error, since it indicates "normal cancellation" + if (e !is CancellationException) { + emitter.onError(e) + } else { + emitter.onCompleted() + } + } + } + emitter.setCancellation { job.cancel() } + }, + backpressureMode + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt index 463d40f73f..e42b468a8e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt @@ -54,3 +54,18 @@ fun String.capitalizeWords(): String { fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int { return String.CASE_INSENSITIVE_ORDER.then(naturalOrder()).compare(this, other) } + +private val uuidFormatLines = arrayOf(8, 13, 18, 23) +private val uuidFormatDigits = arrayOf((0..7), (9..12), (14..17), (19..22), (24..35)) + +/** + * Check if a string is in UUID format. + */ +fun String.isUUID() = + this.length == 36 + && uuidFormatLines.all { idx -> this[idx] == '-' } + && uuidFormatDigits.all { range -> + range.all { idx -> + this[idx].let { char -> char in '0'..'9' || char in 'a'..'f' || char in 'A'..'F' } + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index aebd48bd51..b6a0346d71 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt @@ -85,7 +85,7 @@ object DiskUtil { * replacing any invalid characters with "_". This method doesn't allow hidden files (starting * with a dot), but you can manually add it later. */ - fun buildValidFilename(origName: String): String { + fun buildValidFilename(origName: String, suffix: String = ""): String { val name = origName.trim('.', ' ') if (name.isNullOrEmpty()) { return "(invalid)" @@ -98,9 +98,13 @@ object DiskUtil { sb.append('_') } } - // Even though vfat allows 255 UCS-2 chars, we might eventually write to - // ext4 through a FUSE layer, so use that limit minus 15 reserved characters. - return sb.toString().take(240) + if (suffix.isNotEmpty()) { + return sb.toString().take(240 - suffix.length) + suffix + } else { + // Even though vfat allows 255 UCS-2 chars, we might eventually write to + // ext4 through a FUSE layer, so use that limit minus reserved characters. + return sb.toString().take(240) + } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbHelper.kt new file mode 100644 index 0000000000..122db70a8b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbHelper.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.v5.db + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * This class provides operations to manage the database through its interfaces. + * https://gist.github.com/wontondon/1271795 + */ +class V5DbHelper(context: Context) { + + val idDb: SQLiteDatabase by lazy { openDatabase(context, "mangadex.db") } + + val dbCovers: SQLiteDatabase by lazy { openDatabase(context, "covers.db") } + + fun openDatabase(context: Context, dbPath: String): SQLiteDatabase { + val dbFile: File = context.getDatabasePath(dbPath) + if (!dbFile.exists()) { + try { + copyDatabase(context, dbFile, dbPath) + } catch (e: IOException) { + throw RuntimeException("Error creating source database", e) + } + } + return SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY) + } + + @Throws(IOException::class) + private fun copyDatabase(context: Context, dbFile: File, dbPath: String) { + val `is`: InputStream = context.getAssets().open(dbPath) + val os: OutputStream = FileOutputStream(dbFile) + val buffer = ByteArray(1024) + while (`is`.read(buffer) > 0) { + os.write(buffer) + } + os.flush() + os.close() + `is`.close() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbQueries.kt new file mode 100644 index 0000000000..ff2ea144ef --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbQueries.kt @@ -0,0 +1,52 @@ +package eu.kanade.tachiyomi.v5.db + +import android.database.sqlite.SQLiteDatabase + +class V5DbQueries { + + companion object { + + fun getAltCover(db: SQLiteDatabase, id: String): String? { + if (!db.isOpen) { + return null + } + val queryString = "SELECT cover_url FROM covers WHERE id = ? LIMIT 1" + val whereArgs = arrayOf(id) + val cursor = db.rawQuery(queryString, whereArgs) ?: return "" + if (cursor.moveToFirst()) { + return cursor.getString(0) + } + cursor.close() + return null + } + + fun getNewMangaId(db: SQLiteDatabase, id: String): String { + if (!db.isOpen) { + return "" + } + val queryString = "SELECT new_id FROM manga WHERE legacy_id = ? LIMIT 1" + val whereArgs = arrayOf(id) + val cursor = db.rawQuery(queryString, whereArgs) ?: return "" + if (cursor.moveToFirst()) { + return cursor.getString(0) + } + cursor.close() + return "" + } + + fun getNewChapterId(db: SQLiteDatabase, id: String): String { + if (!db.isOpen) { + return "" + } + val queryString = "SELECT new_id FROM chapter WHERE legacy_id = ? LIMIT 1" + val whereArgs = arrayOf(id) + val cursor = db.rawQuery(queryString, whereArgs) ?: return "" + if (cursor.moveToFirst()) { + return cursor.getString(0) + } + cursor.close() + return "" + } + } +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationJob.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationJob.kt new file mode 100644 index 0000000000..37b9717ddd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationJob.kt @@ -0,0 +1,25 @@ +package eu.kanade.tachiyomi.v5.job + +import android.content.Context +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters + +class V5MigrationJob(private val context: Context, workerParams: WorkerParameters) : + Worker(context, workerParams) { + + override fun doWork(): Result { + V5MigrationService.start(context) + return Result.success() + } + + companion object { + private const val TAG = "V5Migration" + + fun doWorkNow() { + WorkManager.getInstance().enqueue(OneTimeWorkRequestBuilder().build()) + } + + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationNotifier.kt new file mode 100644 index 0000000000..bf4f2d2c6e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationNotifier.kt @@ -0,0 +1,153 @@ +package eu.kanade.tachiyomi.v5.job + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import coil.Coil +import coil.request.CachePolicy +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.lang.chop +import eu.kanade.tachiyomi.util.system.notification +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager +import uy.kohesive.injekt.injectLazy + +class V5MigrationNotifier(private val context: Context) { + + /** + * Pending intent of action that cancels the library update + */ + private val cancelIntent by lazy { + NotificationReceiver.cancelV5MigrationUpdatePendingBroadcast(context) + } + + /** + * Bitmap of the app for notifications. + */ + private val notificationBitmap by lazy { + BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher) + } + + /** + * Cached progress notification to avoid creating a lot. + */ + val progressNotificationBuilder by lazy { + context.notificationBuilder(Notifications.CHANNEL_V5_MIGRATION) { + setContentTitle(context.getString(R.string.app_name)) + setSmallIcon(R.drawable.ic_refresh_24dp) + setLargeIcon(notificationBitmap) + setOngoing(true) + setOnlyAlertOnce(true) + color = ContextCompat.getColor(context, R.color.colorAccent) + addAction(R.drawable.ic_close_24dp, context.getString(android.R.string.cancel), cancelIntent) + } + } + + /** + * Shows the notification containing the currently updating manga and the progress. + * + * @param manga the manga that's being updated. + * @param current the current progress. + * @param total the total progress. + */ + fun showProgressNotification(manga: SManga, current: Int, total: Int) { + val title = manga.title + context.notificationManager.notify( + Notifications.ID_V5_MIGRATION_PROGRESS, + progressNotificationBuilder + .setContentTitle(title) + .setProgress(total, current, false) + .build() + ) + } + + /** + * Shows the notification containing the currently updating manga and the progress. + * + * @param manga the manga that's being updated. + * @param current the current progress. + * @param total the total progress. + */ + fun showProgressNotification(chapter: SChapter, current: Int, total: Int) { + val title = chapter.chapter_title + context.notificationManager.notify( + Notifications.ID_V5_MIGRATION_PROGRESS, + progressNotificationBuilder + .setContentTitle(title) + .setProgress(total, current, false) + .build() + ) + } + + /** + * Shows notification containing update entries that failed with action to open full log. + * + * @param errors List of entry titles that failed to update. + * @param uri Uri for error log file containing all titles that failed. + */ + fun showUpdateErrorNotification(errors: List, uri: Uri?) { + if (errors.isEmpty()) { + return + } + context.notificationManager.notify( + Notifications.ID_V5_MIGRATION_ERROR, + context.notificationBuilder(Notifications.CHANNEL_V5_MIGRATION) { + setContentTitle(context.resources.getQuantityString(R.plurals.notification_update_failed, errors.size, errors.size)) + addAction( + R.drawable.nnf_ic_file_folder, + context.getString(R.string.view_all_errors), + NotificationReceiver.openErrorLogPendingActivity(context, uri!!) + ) + setStyle( + NotificationCompat.BigTextStyle().bigText( + errors.joinToString("\n") { + it.chop(TITLE_MAX_LEN) + } + ) + ) + setSmallIcon(R.drawable.ic_neko_notification) + }.build() + ) + } + + /** + * Cancels the progress notification. + */ + fun cancelProgressNotification() { + context.notificationManager.cancel(Notifications.ID_V5_MIGRATION_PROGRESS) + } + + /** + * Returns an intent to open the main activity. + */ + private fun getNotificationIntent(): PendingIntent { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + action = MainActivity.SHORTCUT_RECENTLY_UPDATED + } + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + companion object { + private const val MAX_CHAPTERS = 5 + private const val TITLE_MAX_LEN = 45 + private const val ICON_SIZE = 192 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationService.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationService.kt new file mode 100644 index 0000000000..bf6c871cc7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationService.kt @@ -0,0 +1,267 @@ +package eu.kanade.tachiyomi.v5.job + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PowerManager +import androidx.core.text.isDigitsOnly +import com.elvishew.xlog.XLog +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.model.isMergedChapter +import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.isServiceRunning +import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.v5.db.V5DbQueries +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * This class will perform migration of old mangas ids to the new v5 mangadex. + */ +class V5MigrationService( + val db: DatabaseHelper = Injekt.get(), + val dbV5: V5DbHelper = Injekt.get(), + val preferences: PreferencesHelper = Injekt.get(), + val trackManager: TrackManager = Injekt.get(), +) : Service() { + + /** + * Wake lock that will be held until the service is destroyed. + */ + private lateinit var wakeLock: PowerManager.WakeLock + private lateinit var notifier: V5MigrationNotifier + + private var job: Job? = null + + // List containing failed updates + private val failedUpdatesMangas = mutableMapOf() + private val failedUpdatesChapters = mutableMapOf() + private val failedUpdatesErrors = mutableListOf() + + /** + * Method called when the service is created. It injects dagger dependencies and acquire + * the wake lock. + */ + override fun onCreate() { + super.onCreate() + notifier = V5MigrationNotifier(this) + startForeground(Notifications.ID_V5_MIGRATION_PROGRESS, notifier.progressNotificationBuilder.build()) + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "V5MigrationService:WakeLock" + ) + wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) + } + + /** + * Method called when the service is destroyed. It cancels jobs and releases the wake lock. + */ + override fun onDestroy() { + job?.cancel() + super.onDestroy() + } + + /** + * This method needs to be implemented, but it's not used/needed. + */ + override fun onBind(intent: Intent) = null + + /** + * Method called when the service receives an intent. + * + * @param intent the start intent from. + * @param flags the flags of the command. + * @param startId the start id of this command. + * @return the start value of the command. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) return START_NOT_STICKY + + // Update our library + val handler = CoroutineExceptionHandler { _, exception -> + XLog.e(exception) + stopSelf(startId) + } + job = GlobalScope.launch(handler) { + migrateLibraryToV5() + finishUpdates() + } + job?.invokeOnCompletion { stopSelf(startId) } + + // Return that we have started + return START_REDELIVER_INTENT + } + + /** + * This will migrate the mangas in the library to the new ids + */ + private fun migrateLibraryToV5() { + val mangas = db.getMangas().executeAsBlocking() + mangas.forEachIndexed { index, manga -> + + // Return if job was canceled + if (job?.isCancelled == true) { + return + } + + // Update progress bar + notifier.showProgressNotification(manga, index, mangas.size) + + // Get the old id and check if it is a number + val oldMangaId = MdUtil.getMangaId(manga.url) + val isNumericId = oldMangaId.isDigitsOnly() + + // Get the new id for this manga + // We skip mangas which have already been converted (non-numeric ids) + var mangaErroredOut = false + if (isNumericId) { + val newMangaId = V5DbQueries.getNewMangaId(dbV5.idDb, oldMangaId) + if (newMangaId.isNotBlank()) { + manga.url = "/title/${newMangaId}" + manga.initialized = false + manga.thumbnail_url = null + db.insertManga(manga).executeAsBlocking() + val tracks = db.getTracks(manga).executeAsBlocking() + tracks.firstOrNull { it.sync_id == trackManager.mdList.id }?.let { + it.tracking_url = MdUtil.baseUrl + manga.url + db.insertTrack(it).executeAsBlocking() + } + } else { + failedUpdatesMangas[manga] = "unable to find new manga id" + failedUpdatesErrors.add(manga.title + ": unable to find new manga id, MangaDex might have removed it") + mangaErroredOut = true + } + } + + // Now loop through the chapters for this manga and update them + val chapters = db.getChapters(manga).executeAsBlocking() + val chapterErrors = mutableListOf() + if (!mangaErroredOut) { + chapters.asSequence().filter { it.isMergedChapter().not() }.forEach { chapter -> + // Return if job was canceled + if (job?.isCancelled == true) { + return + } + // Get the old id and check if it is a number + val oldChapterId = chapter.mangadex_chapter_id + + // Get the new id for this chapter + // We skip chapters which have already been converted (non-numeric ids) + if (oldChapterId.isDigitsOnly()) { + val newChapterId = V5DbQueries.getNewChapterId(dbV5.idDb, oldChapterId) + if (newChapterId != "") { + chapter.mangadex_chapter_id = newChapterId + chapter.url = MdUtil.chapterSuffix + newChapterId + chapter.old_mangadex_id = oldChapterId + db.insertChapter(chapter).executeAsBlocking() + } else { + failedUpdatesChapters[chapter] = "unable to find new chapter V5 id deleting chapter" + chapterErrors.add( + "\t- unable to find new chapter id for " + + "vol ${chapter.vol} - ${chapter.chapter_number} - ${chapter.name}" + ) + db.deleteChapter(chapter).executeAsBlocking() + } + } + } + // Append chapter errors if we have them + if (chapterErrors.size > 0) { + failedUpdatesErrors.add(manga.title + ": has chapter conversion errors") + chapterErrors.forEach { + failedUpdatesErrors.add(it) + } + } + } + } + } + + /** + * Finall function called when we have finished / requested to stop the update + */ + private fun finishUpdates() { + if (failedUpdatesMangas.isNotEmpty() || failedUpdatesChapters.isNotEmpty()) { + val errorFile = writeErrorFile(failedUpdatesErrors) + notifier.showUpdateErrorNotification( + failedUpdatesMangas.map { it.key.title } + + failedUpdatesChapters.map { it.key.chapter_title }, + errorFile.getUriCompat(this) + ) + } + notifier.cancelProgressNotification() + } + + /** + * Writes basic file of update errors to cache dir. + */ + private fun writeErrorFile(errors: MutableList): File { + try { + if (errors.isNotEmpty()) { + val destFile = File(externalCacheDir, "neko_v5_migration_errors.txt") + destFile.bufferedWriter().use { out -> + errors.forEach { error -> + out.write("$error\n") + } + } + return destFile + } + } catch (e: Exception) { + // Empty + } + return File("") + } + + companion object { + + /** + * Returns the status of the service. + * + * @param context the application context. + * @return true if the service is running, false otherwise. + */ + fun isRunning(context: Context): Boolean { + return context.isServiceRunning(V5MigrationService::class.java) + } + + /** + * Starts the service. It will be started only if there isn't another instance already + * running. + * + * @param context the application context. + */ + fun start(context: Context) { + if (!isRunning(context)) { + val intent = Intent(context, V5MigrationService::class.java) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + context.startService(intent) + } else { + context.startForegroundService(intent) + } + } + } + + /** + * Stops the service. + * + * @param context the application context. + */ + fun stop(context: Context) { + GlobalScope.launch { + context.getSystemService(V5MigrationService::class.java)?.finishUpdates() + } + context.stopService(Intent(context, V5MigrationService::class.java)) + } + } +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLogoutDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLogoutDialog.kt index 22e799a67a..969bf1ccbf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLogoutDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLogoutDialog.kt @@ -34,6 +34,8 @@ class MangadexLogoutDialog(bundle: Bundle? = null) : DialogController(bundle) { if (loggedOut.loggedOut) { preferences.setSourceCredentials(source, "", "") + preferences.setSessionToken("") + preferences.setRefreshToken("") activity?.toast(R.string.successfully_logged_out) (targetController as? Listener)?.siteLogoutDialogClosed(source) } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MultiListMatPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MultiListMatPreference.kt index 1da6b4fc34..cd0523ff41 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MultiListMatPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MultiListMatPreference.kt @@ -22,11 +22,15 @@ class MultiListMatPreference @JvmOverloads constructor( var allSelectionRes: Int? = null var customSummaryRes: Int get() = 0 - set(value) { customSummary = context.getString(value) } + set(value) { + customSummary = context.getString(value) + } + + var defSet: Set = emptySet() override fun getSummary(): CharSequence { if (customSummary != null) return customSummary!! - return prefs.getStringSet(key, emptySet()).getOrDefault().mapNotNull { + return prefs.getStringSet(key, defSet).getOrDefault().mapNotNull { if (entryValues.indexOf(it) == -1) null else entryValues.indexOf(it) + if (allSelectionRes != null) 1 else 0 }.toIntArray().joinToString(",") { @@ -36,7 +40,7 @@ class MultiListMatPreference @JvmOverloads constructor( @SuppressLint("CheckResult") override fun MaterialDialog.setItems() { - val set = prefs.getStringSet(key, emptySet()).getOrDefault() + val set = prefs.getStringSet(key, defSet).getOrDefault() var default = set.mapNotNull { if (entryValues.indexOf(it) == -1) null else entryValues.indexOf(it) + if (allSelectionRes != null) 1 else 0 @@ -44,14 +48,14 @@ class MultiListMatPreference @JvmOverloads constructor( .toIntArray() if (allSelectionRes != null && default.isEmpty()) default = intArrayOf(0) val items = if (allSelectionRes != null) - (listOf(context.getString(allSelectionRes!!)) + entries) else entries + (listOf(context.getString(allSelectionRes!!)) + entries) else entries positiveButton(android.R.string.ok) { val pos = mutableListOf() for (i in items.indices) if (!(allSelectionRes != null && i == 0) && isItemChecked(i)) pos.add(i) var value = pos.map { entryValues[it - if (allSelectionRes != null) 1 else 0] - }?.toSet() ?: emptySet() + }?.toSet() if (allSelectionRes != null && isItemChecked(0)) value = emptySet() prefs.getStringSet(key, emptySet()).set(value) callChangeListener(value) diff --git a/app/src/main/res/layout/browse_source_controller.xml b/app/src/main/res/layout/browse_source_controller.xml index 2ca45af974..33b79f1885 100644 --- a/app/src/main/res/layout/browse_source_controller.xml +++ b/app/src/main/res/layout/browse_source_controller.xml @@ -6,22 +6,30 @@ android:id="@+id/source_layout" android:layout_height="match_parent"> - - - + + + android:orientation="vertical" + tools:context="eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController"> + + + + - + diff --git a/app/src/main/res/layout/pref_site_login.xml b/app/src/main/res/layout/pref_site_login.xml index d7bc2f3e9b..b3033b48a7 100644 --- a/app/src/main/res/layout/pref_site_login.xml +++ b/app/src/main/res/layout/pref_site_login.xml @@ -67,6 +67,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="10dp" + android:visibility="gone" + tools:visibility="visible" android:text="@string/two_factor" /> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/raw/changelog_release.xml b/app/src/main/res/raw/changelog_release.xml index 1e36be99a4..22a84b92e2 100644 --- a/app/src/main/res/raw/changelog_release.xml +++ b/app/src/main/res/raw/changelog_release.xml @@ -1,5 +1,10 @@ + + See github lets be real, double the changelog makes no sense + diff --git a/app/src/main/res/values/strings_neko.xml b/app/src/main/res/values/strings_neko.xml index 7c0054628d..7a174d1771 100644 --- a/app/src/main/res/values/strings_neko.xml +++ b/app/src/main/res/values/strings_neko.xml @@ -74,41 +74,23 @@ Similar manga Similar Manga Settings - Show similar manga - Pull latest database - Starting manual update - - Download the latest similar manga database. - This is around 9MB in size and is updated daily. - - Load database from file - - Load a local JSON file from disk. - Use this if the network download isn\'t working. - - Similar update frequency - Updating similar manga (%1$d / %2$d updated) - Updating similar manga complete - Error trying to load/process similar manga - Downloading data file… This is a feature where one can get manga recommendations. This is a recommendation system outside of MangaDex, and works by matching by genres, demographics, content type, themes, and then using term frequency–inverse document frequency (tf–idf) to get the similarity of two manga\'s descriptions. When enabled this file will download immediately!! The file is about 9 MB in size. - Credits + Similar Manga Credits For more information and to view the source code:\n%s - Enable Similar Manga? - - Would you like to enable similar manga recommendations? - This will download approximately 9 MB of data if enabled right now. - You can always enable it in the Settings / Similar manga menu. - - Enable - Skip + + + Cache Source + Downloading data file… + Updating cache manga (%1$d / %2$d updated) + Updating cache manga complete! + Error trying to load/process cache manga Skip chapters hidden by filters @@ -116,11 +98,12 @@ MangaDex settings - Choose image server - Default R18+ setting - show only R18+ - Show No R18+ - Show all + Content Ratings + Choose which ratings to show in results. Choosing none is the same as choosing all. + Safe (No sexual themes) + Suggestive (Manga usually tagged Ecchi) + Erotica (Manga usually tagged smut) + Pornographic (Manga usually tagged Hentai) Choose languages to show Data Saver (Compressed Chapter Images) Disabled @@ -138,11 +121,14 @@ When enabled, it uses the latest uploaded manga cover under the /covers url instead of using the cover on MangaDex\'s manga page (Experiment) Mark MDList chapters read in app When enabled, this will mark chapters read in Neko when they are tracked and read on MangaDex - Show R18 filter in search + Show Content rating filter in search + Use Image Servers with Port 443 only + "Enable to only request image servers that use port 443. This allows users with stricter firewall restrictions to access MangaDex images" + Add to library as planned to read When enabled adding a manga to your library inside the manga view will add the manga as planned to read in MDList - Use cached manga api source - This will download 1.5MB file. You only need to download it once. If you already downloaded just dismiss this dialog. + Use cached manga api source and search + This will download 5MB file. You only need to download it once. If you already downloaded just dismiss this dialog. @@ -150,6 +136,9 @@ "Log level" Changing this can impact app performance. Force-restart app after changing. Log file is stored at /Neko/logs. + Refresh the download cache + This will force the download cache to recalculate. Use after you migrated to v5 or if you copied downloads outside of this app + 2FA Code @@ -169,5 +158,9 @@ Crash logs Open log + + V5 Migration + Perform migration from old MangaDex to V5 site. + diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt b/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt index a1a416154d..3c82ea6b8b 100644 --- a/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt +++ b/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt @@ -411,7 +411,7 @@ class BackupTest { val manga = MangaImpl() manga.source = 1 manga.title = title - manga.url = "/manga/$title" + manga.url = "/title/$title" manga.favorite = true return manga } diff --git a/buildSrc/src/main/kotlin/Configs.kt b/buildSrc/src/main/kotlin/Configs.kt index c1fc219cfb..86ceae73b4 100644 --- a/buildSrc/src/main/kotlin/Configs.kt +++ b/buildSrc/src/main/kotlin/Configs.kt @@ -5,8 +5,8 @@ object Configs { const val minSdkVersion = 24 const val targetSdkVersion = 29 const val testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - const val versionCode = 112 - const val versionName = "2.3" + const val versionCode = 114 + const val versionName = "2.4" } object LegacyPluginClassPath { diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index f8678efa51..b4e3ec1c19 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -18,6 +18,7 @@ object Libs { const val recyclerView = "androidx.recyclerview:recyclerview:${Versions.androidRecyclerView}" const val workManager = "androidx.work:work-runtime:${Versions.androidWorkManager}" const val workManagerKtx = "androidx.work:work-runtime-ktx:${Versions.androidWorkManager}" + const val dataBinding = "com.android.databinding:compiler:${Versions.dataBinding}" } object Database { @@ -173,6 +174,7 @@ object Versions { const val androidRecyclerView = "1.1.0" const val androidSqlite = "2.1.0" const val androidWorkManager = "2.4.0" + const val dataBinding = "3.1.4" const val assertJ = "3.12.2" const val changelog = "2.1.0" const val chucker = "3.2.0"