diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 46c36823c..755c6ba5d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,3 +1,7 @@ +# Catalogue requests + +* Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions/issues, not here + # Bugs * Include version (Setting > About > Version) * If not latest, try updating, it may have already been solved diff --git a/app/build.gradle b/app/build.gradle index 1429a36e8..98a4b158c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,10 +28,9 @@ ext { } } - android { compileSdkVersion 25 - buildToolsVersion "25.0.1" + buildToolsVersion "25.0.2" publishNonDefault true dexOptions { @@ -53,7 +52,7 @@ android { vectorDrawables.useSupportLibrary = true ndk { - abiFilters "armeabi", "armeabi-v7a", "x86" + abiFilters "armeabi-v7a", "arm64-v8a", "x86" } } @@ -103,10 +102,11 @@ android { dependencies { // Modified dependencies - compile 'com.github.inorichi:subsampling-scale-image-view:c4db85c' + compile 'com.github.inorichi:subsampling-scale-image-view:4255750' + compile 'com.github.inorichi:junrar-android:634c1f5' // Android support library - final support_library_version = '25.0.1' + final support_library_version = '25.2.0' compile "com.android.support:support-v4:$support_library_version" compile "com.android.support:appcompat-v7:$support_library_version" compile "com.android.support:cardview-v7:$support_library_version" @@ -115,30 +115,30 @@ dependencies { compile "com.android.support:support-annotations:$support_library_version" compile "com.android.support:customtabs:$support_library_version" - compile 'com.android.support.constraint:constraint-layout:1.0.0-beta4' + compile 'com.android.support.constraint:constraint-layout:1.0.0' compile 'com.android.support:multidex:1.0.1' // ReactiveX compile 'io.reactivex:rxandroid:1.2.1' - compile 'io.reactivex:rxjava:1.2.4' + compile 'io.reactivex:rxjava:1.2.6' compile 'com.jakewharton.rxrelay:rxrelay:1.2.0' compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2' compile 'com.github.pwittchen:reactivenetwork:0.7.0' // Network client - compile "com.squareup.okhttp3:okhttp:3.5.0" + compile "com.squareup.okhttp3:okhttp:3.6.0" compile 'com.squareup.okio:okio:1.11.0' // REST - final retrofit_version = '2.1.0' + final retrofit_version = '2.2.0' compile "com.squareup.retrofit2:retrofit:$retrofit_version" compile "com.squareup.retrofit2:converter-gson:$retrofit_version" compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version" // JSON compile 'com.google.code.gson:gson:2.8.0' - compile 'com.github.salomonbrys.kotson:kotson:2.4.0' + compile 'com.github.salomonbrys.kotson:kotson:2.5.0' // YAML compile 'com.github.bmoliveira:snake-yaml:v1.18-android' @@ -151,17 +151,17 @@ dependencies { compile 'com.github.seven332:unifile:1.0.0' // HTML parser - compile 'org.jsoup:jsoup:1.10.1' + compile 'org.jsoup:jsoup:1.10.2' // Job scheduling - compile 'com.evernote:android-job:1.1.3' - compile 'com.google.android.gms:play-services-gcm:10.0.1' + compile 'com.evernote:android-job:1.1.6' + compile 'com.google.android.gms:play-services-gcm:10.2.0' // Changelog compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' // Database - compile "com.pushtorefresh.storio:sqlite:1.11.0" + compile "com.pushtorefresh.storio:sqlite:1.12.3" // Model View Presenter final nucleus_version = '3.0.0' @@ -179,16 +179,23 @@ dependencies { compile 'jp.wasabeef:glide-transformations:2.0.1' // Logging - compile 'com.jakewharton.timber:timber:4.4.0' + compile 'com.jakewharton.timber:timber:4.5.1' + + // Crash reports + compile 'ch.acra:acra:4.9.2' + + // Sort + compile 'com.github.gpanther:java-nat-sort:natural-comparator-1.1' // UI compile 'com.dmitrymalkovich.android:material-design-dimens:1.4' compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' - compile 'eu.davidea:flexible-adapter:4.2.0' + compile 'eu.davidea:flexible-adapter:5.0.0-rc1' + compile 'com.github.inorichi:FlexibleAdapter:93985fe' // v4.2.0 to be removed compile 'com.nononsenseapps:filepicker:2.5.2' compile 'com.github.amulyakhare:TextDrawable:558677e' - compile 'com.afollestad.material-dialogs:core:0.9.1.0' - compile 'net.xpece.android:support-preference:1.2.0' + compile 'com.afollestad.material-dialogs:core:0.9.3.0' + compile 'net.xpece.android:support-preference:1.2.5' compile 'me.zhanghai.android.systemuihelper:library:1.0.0' compile 'de.hdodenhof:circleimageview:2.1.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ed841bbf..75ac8b5a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,16 +1,17 @@ - - + - - + + - + android:theme="@style/Theme.Tachiyomi"> + @@ -31,40 +31,40 @@ - + android:exported="true" + android:parentActivityName=".ui.main.MainActivity" /> - + android:theme="@style/Theme.Reader" /> - + android:parentActivityName=".ui.main.MainActivity" /> - + android:parentActivityName=".ui.main.MainActivity" /> - + android:theme="@style/FilePickerTheme" /> + + + + android:resource="@xml/provider_paths" /> - + - + - + - + - + - - - + > { - return Observable.fromCallable> { + fun getPageListFromCache(chapter: Chapter): Observable> { + return Observable.fromCallable { // Get the key for the chapter. - val key = DiskUtil.hashKeyForDisk(chapterUrl) + val key = DiskUtil.hashKeyForDisk(getKey(chapter)) // Convert JSON string to list of objects. Throws an exception if snapshot is null diskCache.get(key).use { @@ -110,10 +111,10 @@ class ChapterCache(private val context: Context) { /** * Add page list to disk cache. * - * @param chapterUrl the url of the chapter. + * @param chapter the chapter. * @param pages list of pages. */ - fun putPageListToCache(chapterUrl: String, pages: List) { + fun putPageListToCache(chapter: Chapter, pages: List) { // Convert list of pages to json string. val cachedValue = gson.toJson(pages) @@ -122,7 +123,7 @@ class ChapterCache(private val context: Context) { try { // Get editor from md5 key. - val key = DiskUtil.hashKeyForDisk(chapterUrl) + val key = DiskUtil.hashKeyForDisk(getKey(chapter)) editor = diskCache.edit(key) ?: return // Write chapter urls to cache. @@ -196,5 +197,8 @@ class ChapterCache(private val context: Context) { } } + private fun getKey(chapter: Chapter): String { + return "${chapter.manga_id}${chapter.url}" + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt index ca7a1de50..dee208e0c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt @@ -20,7 +20,7 @@ class CoverCache(private val context: Context) { /** * Cache directory used for cache management. */ - private val cacheDir: File = File(context.externalCacheDir, "cover_disk_cache") + private val cacheDir = context.getExternalFilesDir("covers") /** * Returns the cover from cache. diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt index 0d410a2b1..71ca01420 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt @@ -69,7 +69,7 @@ open class MangaGetResolver : DefaultGetResolver() { override fun mapFromCursor(cursor: Cursor): Manga = MangaImpl().apply { id = cursor.getLong(cursor.getColumnIndex(COL_ID)) - source = cursor.getInt(cursor.getColumnIndex(COL_SOURCE)) + source = cursor.getLong(cursor.getColumnIndex(COL_SOURCE)) url = cursor.getString(cursor.getColumnIndex(COL_URL)) artist = cursor.getString(cursor.getColumnIndex(COL_ARTIST)) author = cursor.getString(cursor.getColumnIndex(COL_AUTHOR)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt index af2346fc8..589ed671d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt @@ -1,17 +1,14 @@ package eu.kanade.tachiyomi.data.database.models +import eu.kanade.tachiyomi.source.model.SChapter import java.io.Serializable -interface Chapter : Serializable { +interface Chapter : SChapter, Serializable { var id: Long? var manga_id: Long? - var url: String - - var name: String - var read: Boolean var bookmark: Boolean @@ -20,10 +17,6 @@ interface Chapter : Serializable { var date_fetch: Long - var date_upload: Long - - var chapter_number: Float - var source_order: Int val isRecognizedNumber: Boolean diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt index 320560aa9..7621f64d8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt @@ -1,35 +1,17 @@ package eu.kanade.tachiyomi.data.database.models -import java.io.Serializable +import eu.kanade.tachiyomi.source.model.SManga -interface Manga : Serializable { +interface Manga : SManga { var id: Long? - var source: Int - - var url: String - - var title: String - - var artist: String? - - var author: String? - - var description: String? - - var genre: String? - - var status: Int - - var thumbnail_url: String? + var source: Long var favorite: Boolean var last_update: Long - var initialized: Boolean - var viewer: Int var chapter_flags: Int @@ -38,27 +20,6 @@ interface Manga : Serializable { var category: Int - fun copyFrom(other: Manga) { - if (other.author != null) - author = other.author - - if (other.artist != null) - artist = other.artist - - if (other.description != null) - description = other.description - - if (other.genre != null) - genre = other.genre - - if (other.thumbnail_url != null) - thumbnail_url = other.thumbnail_url - - status = other.status - - initialized = true - } - fun setChapterOrder(order: Int) { setFlags(order, SORT_MASK) } @@ -94,11 +55,6 @@ interface Manga : Serializable { companion object { - const val UNKNOWN = 0 - const val ONGOING = 1 - const val COMPLETED = 2 - const val LICENSED = 3 - const val SORT_DESC = 0x00000000 const val SORT_ASC = 0x00000001 const val SORT_MASK = 0x00000001 @@ -126,12 +82,13 @@ interface Manga : Serializable { const val DISPLAY_NUMBER = 0x00100000 const val DISPLAY_MASK = 0x00100000 - fun create(source: Int): Manga = MangaImpl().apply { + fun create(source: Long): Manga = MangaImpl().apply { this.source = source } - fun create(pathUrl: String, source: Int = 0): Manga = MangaImpl().apply { + fun create(pathUrl: String, title: String, source: Long = 0): Manga = MangaImpl().apply { url = pathUrl + this.title = title this.source = source } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt index 000618a3a..401d99a05 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt @@ -4,7 +4,7 @@ class MangaImpl : Manga { override var id: Long? = null - override var source: Int = 0 + override var source: Long = -1 override lateinit var url: String diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt index ce04b5303..5068f899e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt @@ -22,8 +22,6 @@ interface Track : Serializable { var status: Int - var update: Boolean - fun copyPersonalFrom(other: Track) { last_chapter_read = other.last_chapter_read score = other.score diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt index f94c85993..4ae4723aa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt @@ -20,8 +20,6 @@ class TrackImpl : Track { override var status: Int = 0 - override var update: Boolean = false - 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/queries/MangaQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt index e6206835b..b22a2fa33 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt @@ -40,7 +40,7 @@ interface MangaQueries : DbProvider { .build()) .prepare() - fun getManga(url: String, sourceId: Int) = db.get() + fun getManga(url: String, sourceId: Long) = db.get() .`object`(Manga::class.java) .withQuery(Query.builder() .table(MangaTable.TABLE) 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 2eae39901..0d4e79048 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 @@ -6,8 +6,8 @@ import com.jakewharton.rxrelay.BehaviorRelay import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.DownloadQueue -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.Page import rx.Observable /** @@ -60,10 +60,19 @@ class DownloadManager(context: Context) { } /** - * Empties the download queue. + * Tells the downloader to pause downloads. */ - fun clearQueue() { - downloader.clearQueue() + fun pauseDownloads() { + downloader.pause() + } + + /** + * Empties the download queue. + * + * @param isNotification value that determines if status is set (needed for view updates) + */ + fun clearQueue(isNotification: Boolean = false) { + downloader.clearQueue(isNotification) } /** @@ -168,5 +177,4 @@ class DownloadManager(context: Context) { fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) { provider.findChapterDir(source, manga, chapter)?.delete() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt index 6fab6691a..f624ccdda 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt @@ -7,6 +7,8 @@ import eu.kanade.tachiyomi.Constants import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.DownloadQueue +import eu.kanade.tachiyomi.data.notification.NotificationHandler +import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.util.chop import eu.kanade.tachiyomi.util.notificationManager @@ -33,12 +35,34 @@ internal class DownloadNotifier(private val context: Context) { * The size of queue on start download. */ var initialQueueSize = 0 + get() = field + set(value) { + if (value != 0){ + isSingleChapter = (value == 1) + } + field = value + } /** * Simultaneous download setting > 1. */ var multipleDownloadThreads = false + /** + * Updated when error is thrown + */ + var errorThrown = false + + /** + * Updated when only single page is downloaded + */ + var isSingleChapter = false + + /** + * Updated when paused + */ + var paused = false + /** * Shows a notification from this builder. * @@ -48,6 +72,14 @@ internal class DownloadNotifier(private val context: Context) { context.notificationManager.notify(id, build()) } + /** + * Clear old actions if they exist. + */ + private fun clearActions() = with(notification) { + if (!mActions.isEmpty()) + mActions.clear() + } + /** * Dismiss the downloader's notification. Downloader error notifications use a different id, so * those can only be dismissed by the user. @@ -88,24 +120,15 @@ internal class DownloadNotifier(private val context: Context) { * @param queue the queue containing downloads. */ private fun doOnProgressChange(download: Download?, queue: DownloadQueue) { - // Check if download is completed - if (multipleDownloadThreads) { - if (queue.isEmpty()) { - onChapterCompleted(null) - return - } - } else { - if (download != null && download.pages!!.size == download.downloadedImages) { - onChapterCompleted(download) - return - } - } - // Create notification with(notification) { - // Check if icon needs refresh + // Check if first call. if (!isDownloading) { setSmallIcon(android.R.drawable.stat_sys_download) + setAutoCancel(false) + clearActions() + // Open download manager when clicked + setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) isDownloading = true } @@ -121,7 +144,9 @@ internal class DownloadNotifier(private val context: Context) { setProgress(initialQueueSize, initialQueueSize - queue.size, false) } else { download?.let { - setContentTitle(it.chapter.name.chop(30)) + val title = it.manga.title.chop(15) + val chapter = download.chapter.name.replaceFirst("$title[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") + setContentTitle("$title - $chapter".chop(30)) setContentText(context.getString(R.string.chapter_downloading_progress) .format(it.downloadedImages, it.pages!!.size)) setProgress(it.pages!!.size, it.downloadedImages, false) @@ -133,17 +158,57 @@ internal class DownloadNotifier(private val context: Context) { notification.show() } + /** + * Show notification when download is paused. + */ + fun onDownloadPaused() { + with(notification) { + setContentTitle(context.getString(R.string.chapter_paused)) + setContentText(context.getString(R.string.download_notifier_download_paused)) + setSmallIcon(R.drawable.ic_av_pause_grey_24dp_img) + setAutoCancel(false) + setProgress(0, 0, false) + clearActions() + // Open download manager when clicked + setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) + // Resume action + addAction(R.drawable.ic_av_play_arrow_grey_img, + context.getString(R.string.action_resume), + NotificationReceiver.resumeDownloadsPendingBroadcast(context)) + //Clear action + addAction(R.drawable.ic_clear_grey_24dp_img, + context.getString(R.string.action_clear), + NotificationReceiver.clearDownloadsPendingBroadcast(context)) + } + + // Show notification. + notification.show() + + // Reset initial values + isDownloading = false + initialQueueSize = 0 + } + /** * Called when chapter is downloaded. * * @param download download object containing download information. */ - private fun onChapterCompleted(download: Download?) { + fun onDownloadCompleted(download: Download, queue: DownloadQueue) { + // Check if last download + if (!queue.isEmpty()) { + return + } // Create notification. with(notification) { - setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name)) + val title = download.manga.title.chop(15) + val chapter = download.chapter.name.replaceFirst("$title[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") + setContentTitle("$title - $chapter".chop(30)) setContentText(context.getString(R.string.update_check_notification_download_complete)) setSmallIcon(android.R.drawable.stat_sys_download_done) + setAutoCancel(true) + clearActions() + setContentIntent(NotificationReceiver.openChapterPendingBroadcast(context, download.manga, download.chapter)) setProgress(0, 0, false) } @@ -165,9 +230,15 @@ internal class DownloadNotifier(private val context: Context) { setContentTitle(context.getString(R.string.download_notifier_downloader_title)) setContentText(reason) setSmallIcon(android.R.drawable.stat_sys_warning) + setAutoCancel(true) + clearActions() + setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) setProgress(0, 0, false) } notification.show() + + // Reset download information + isDownloading = false } /** @@ -183,11 +254,15 @@ internal class DownloadNotifier(private val context: Context) { setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title)) setContentText(error ?: context.getString(R.string.download_notifier_unkown_error)) setSmallIcon(android.R.drawable.stat_sys_warning) + clearActions() + setAutoCancel(false) + setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) setProgress(0, 0, false) } notification.show(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID) // Reset download information + errorThrown = true isDownloading = false } } 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 018126b90..894395d11 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 @@ -7,7 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.source.Source +import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.util.DiskUtil import uy.kohesive.injekt.injectLazy diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt index 72d79c10b..4edf6b761 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt @@ -5,8 +5,8 @@ import com.google.gson.Gson import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.source.SourceManager -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.HttpSource import uy.kohesive.injekt.injectLazy /** @@ -93,7 +93,7 @@ class DownloadStore(context: Context) { val manga = cachedManga.getOrPut(mangaId) { db.getManga(mangaId).executeAsBlocking() } ?: continue - val source = sourceManager.get(manga.source) as? OnlineSource ?: continue + val source = sourceManager.get(manga.source) as? HttpSource ?: continue val chapter = db.getChapter(chapterId).executeAsBlocking() ?: continue downloads.add(Download(source, manga, chapter)) } 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 5c28ec345..82a3bcbec 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 @@ -10,13 +10,12 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.source.SourceManager -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.OnlineSource -import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator -import eu.kanade.tachiyomi.util.RetryWithDelay -import eu.kanade.tachiyomi.util.plusAssign -import eu.kanade.tachiyomi.util.saveTo +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList +import eu.kanade.tachiyomi.util.* import okhttp3.Response import rx.Observable import rx.android.schedulers.AndroidSchedulers @@ -25,7 +24,6 @@ import rx.subjects.BehaviorSubject import rx.subscriptions.CompositeSubscription import timber.log.Timber import uy.kohesive.injekt.injectLazy -import java.net.URLConnection /** * This class is the one in charge of downloading chapters. @@ -132,15 +130,42 @@ class Downloader(private val context: Context, private val provider: DownloadPro if (reason != null) { notifier.onWarning(reason) } else { - notifier.dismiss() + if (notifier.paused) { + notifier.paused = false + notifier.onDownloadPaused() + } else if (notifier.isSingleChapter && !notifier.errorThrown) { + notifier.isSingleChapter = false + } else { + notifier.dismiss() + } } } /** - * Removes everything from the queue. + * Pauses the downloader */ - fun clearQueue() { + fun pause() { destroySubscriptions() + queue + .filter { it.status == Download.DOWNLOADING } + .forEach { it.status = Download.QUEUE } + notifier.paused = true + } + + /** + * Removes everything from the queue. + * + * @param isNotification value that determines if status is set (needed for view updates) + */ + fun clearQueue(isNotification: Boolean = false) { + destroySubscriptions() + + //Needed to update the chapter view + if (isNotification) { + queue + .filter { it.status == Download.QUEUE } + .forEach { it.status = Download.NOT_DOWNLOADED } + } queue.clear() notifier.dismiss() } @@ -192,7 +217,7 @@ class Downloader(private val context: Context, private val provider: DownloadPro * @param chapters the list of chapters to download. */ fun queueChapters(manga: Manga, chapters: List) { - val source = sourceManager.get(manga.source) as? OnlineSource ?: return + val source = sourceManager.get(manga.source) as? HttpSource ?: return val chaptersToQueue = chapters // Avoid downloading chapters with the same name. @@ -213,6 +238,9 @@ class Downloader(private val context: Context, private val provider: DownloadPro // Initialize queue size. notifier.initialQueueSize = queue.size + // Initial multi-thread + notifier.multipleDownloadThreads = preferences.downloadThreads().getOrDefault() > 1 + if (isRunning) { // Send the list of downloads to the downloader. downloadsRelay.call(chaptersToQueue) @@ -251,8 +279,11 @@ class Downloader(private val context: Context, private val provider: DownloadPro val pageListObservable = if (download.pages == null) { // Pull page list from network and add them to download object - download.source.fetchPageListFromNetwork(download.chapter) + download.source.fetchPageList(download.chapter) .doOnNext { pages -> + if (pages.isEmpty()) { + throw Exception("Page list is empty") + } download.pages = pages } } else { @@ -309,7 +340,7 @@ class Downloader(private val context: Context, private val provider: DownloadPro tmpFile?.delete() // Try to find the image file. - val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.")} + val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") } // If the image is already downloaded, do nothing. Otherwise download from network val pageObservable = if (imageFile != null) @@ -342,10 +373,10 @@ class Downloader(private val context: Context, private val provider: DownloadPro * @param tmpDir the temporary directory of the download. * @param filename the filename of the image. */ - private fun downloadImage(page: Page, source: OnlineSource, tmpDir: UniFile, filename: String): Observable { + private fun downloadImage(page: Page, source: HttpSource, tmpDir: UniFile, filename: String): Observable { page.status = Page.DOWNLOAD_IMAGE page.progress = 0 - return source.imageResponse(page) + return source.fetchImage(page) .map { response -> val file = tmpDir.createFile("$filename.tmp") try { @@ -373,12 +404,10 @@ class Downloader(private val context: Context, private val provider: DownloadPro private fun getImageExtension(response: Response, file: UniFile): String { // Read content type if available. val mime = response.body().contentType()?.let { ct -> "${ct.type()}/${ct.subtype()}" } - // Else guess from the uri. - ?: context.contentResolver.getType(file.uri) - // Else read magic numbers. - ?: file.openInputStream().buffered().use { - URLConnection.guessContentTypeFromStream(it) - } + // Else guess from the uri. + ?: context.contentResolver.getType(file.uri) + // Else read magic numbers. + ?: DiskUtil.findImageMime { file.openInputStream() } return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg" } @@ -417,6 +446,9 @@ class Downloader(private val context: Context, private val provider: DownloadPro notifier.onProgressChange(queue) } if (areAllDownloadsFinished()) { + if (notifier.isSingleChapter && !notifier.errorThrown) { + notifier.onDownloadCompleted(download, queue) + } DownloadService.stop(context) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt index 48b2e0b62..9c2b503e9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt @@ -2,11 +2,11 @@ package eu.kanade.tachiyomi.data.download.model import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.online.HttpSource import rx.subjects.PublishSubject -class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) { +class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) { var pages: List? = null diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt index 2bb7febd2..389dd3822 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.download.model import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.download.DownloadStore -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import rx.Observable import rx.subjects.PublishSubject import java.util.concurrent.CopyOnWriteArrayList diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/AppGlideModule.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/AppGlideModule.kt index 6e1862373..b1b722acb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/AppGlideModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/AppGlideModule.kt @@ -8,7 +8,7 @@ import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.module.GlideModule import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkHelper import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.InputStream diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt new file mode 100644 index 000000000..6e1e06ff4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.data.glide + +import com.bumptech.glide.Priority +import com.bumptech.glide.load.data.DataFetcher +import java.io.File +import java.io.IOException +import java.io.InputStream + +open class FileFetcher(private val file: File) : DataFetcher { + + private var data: InputStream? = null + + override fun loadData(priority: Priority): InputStream { + data = file.inputStream() + return data!! + } + + override fun cleanup() { + data?.let { data -> + try { + data.close() + } catch (e: IOException) { + // Ignore + } + } + } + + override fun cancel() { + // Do nothing. + } + + override fun getId(): String { + return file.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaFileFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaFileFetcher.kt new file mode 100644 index 000000000..5e594e496 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaFileFetcher.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.data.glide + +import eu.kanade.tachiyomi.data.database.models.Manga +import java.io.File + +open class MangaFileFetcher(private val file: File, private val manga: Manga) : FileFetcher(file) { + + /** + * Returns the id for this manga's cover. + * + * Appending the file's modified date to the url, we can force Glide to skip its memory and disk + * lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when + * the file has changed. If the file doesn't exist it will append a 0. + */ + override fun getId(): String { + return manga.thumbnail_url + file.lastModified() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt index 429086ff0..f5342c451 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt @@ -3,20 +3,21 @@ package eu.kanade.tachiyomi.data.glide import android.content.Context import android.util.LruCache import com.bumptech.glide.Glide +import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.model.* import com.bumptech.glide.load.model.stream.StreamModelLoader import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.SourceManager -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.HttpSource import uy.kohesive.injekt.injectLazy import java.io.File import java.io.InputStream /** * A class for loading a cover associated with a [Manga] that can be present in our own cache. - * Coupled with [MangaDataFetcher], this class allows to implement the following flow: + * Coupled with [MangaUrlFetcher], this class allows to implement the following flow: * * - Check in RAM LRU. * - Check in disk LRU. @@ -30,17 +31,17 @@ class MangaModelLoader(context: Context) : StreamModelLoader { /** * Cover cache where persistent covers are stored. */ - val coverCache: CoverCache by injectLazy() + private val coverCache: CoverCache by injectLazy() /** * Source manager. */ - val sourceManager: SourceManager by injectLazy() + private val sourceManager: SourceManager by injectLazy() /** * Base network loader. */ - private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java, + private val baseUrlLoader = Glide.buildModelLoader(GlideUrl::class.java, InputStream::class.java, context) /** @@ -52,7 +53,7 @@ class MangaModelLoader(context: Context) : StreamModelLoader { /** * Map where request headers are stored for a source. */ - private val cachedHeaders = hashMapOf() + private val cachedHeaders = hashMapOf() /** * Factory class for creating [MangaModelLoader] instances. @@ -66,7 +67,7 @@ class MangaModelLoader(context: Context) : StreamModelLoader { } /** - * Returns a [MangaDataFetcher] for the given manga or null if the url is empty. + * Returns a fetcher for the given manga or null if the url is empty. * * @param manga the model. * @param width the width of the view where the resource will be loaded. @@ -78,22 +79,33 @@ class MangaModelLoader(context: Context) : StreamModelLoader { // Check thumbnail is not null or empty val url = manga.thumbnail_url - if (url.isNullOrEmpty()) { + if (url == null || url.isEmpty()) { return null } - // Obtain the request url and the file for this url from the LRU cache, or calculate it - // and add them to the cache. - val (glideUrl, file) = lruCache.get(url) ?: - Pair(GlideUrl(url, getHeaders(manga)), coverCache.getCoverFile(url!!)).apply { - lruCache.put(url, this) - } + if (url.startsWith("http")) { + val source = sourceManager.get(manga.source) as? HttpSource - // Get the network fetcher for this request url. - val networkFetcher = baseLoader.getResourceFetcher(glideUrl, width, height) + // Obtain the request url and the file for this url from the LRU cache, or calculate it + // and add them to the cache. + val (glideUrl, file) = lruCache.get(url) ?: + Pair(GlideUrl(url, getHeaders(manga, source)), coverCache.getCoverFile(url)).apply { + lruCache.put(url, this) + } - // Return an instance of our fetcher providing the needed elements. - return MangaDataFetcher(networkFetcher, file, manga) + // Get the resource fetcher for this request url. + val networkFetcher = source?.let { OkHttpStreamFetcher(it.client, glideUrl) } + ?: baseUrlLoader.getResourceFetcher(glideUrl, width, height) + + // Return an instance of the fetcher providing the needed elements. + return MangaUrlFetcher(networkFetcher, file, manga) + } else { + // Get the file from the url, removing the scheme if present. + val file = File(url.substringAfter("file://")) + + // Return an instance of the fetcher providing the needed elements. + return MangaFileFetcher(file, manga) + } } /** @@ -101,8 +113,9 @@ class MangaModelLoader(context: Context) : StreamModelLoader { * * @param manga the model. */ - fun getHeaders(manga: Manga): Headers { - val source = sourceManager.get(manga.source) as? OnlineSource ?: return LazyHeaders.DEFAULT + fun getHeaders(manga: Manga, source: HttpSource?): Headers { + if (source == null) return LazyHeaders.DEFAULT + return cachedHeaders.getOrPut(manga.source) { LazyHeaders.Builder().apply { val nullStr: String? = null diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaDataFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaUrlFetcher.kt similarity index 74% rename from app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaDataFetcher.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaUrlFetcher.kt index 2853fea7e..193309583 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaDataFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaUrlFetcher.kt @@ -18,13 +18,12 @@ import java.io.InputStream * @param file the file where this cover should be. It may exists or not. * @param manga the manga of the cover to load. */ -class MangaDataFetcher(private val networkFetcher: DataFetcher, - private val file: File, - private val manga: Manga) -: DataFetcher { +class MangaUrlFetcher(private val networkFetcher: DataFetcher, + private val file: File, + private val manga: Manga) +: MangaFileFetcher(file, manga) { - @Throws(Exception::class) - override fun loadData(priority: Priority): InputStream? { + override fun loadData(priority: Priority): InputStream { if (manga.favorite) { synchronized(file) { if (!file.exists()) { @@ -51,7 +50,7 @@ class MangaDataFetcher(private val networkFetcher: DataFetcher, } } } - return file.inputStream() + return super.loadData(priority) } else { if (file.exists()) { file.delete() @@ -60,22 +59,12 @@ class MangaDataFetcher(private val networkFetcher: DataFetcher, } } - /** - * Returns the id for this manga's cover. - * - * Appending the file's modified date to the url, we can force Glide to skip its memory and disk - * lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when - * the file has changed. If the file doesn't exist it will append a 0. - */ - override fun getId(): String { - return manga.thumbnail_url + file.lastModified() - } - override fun cancel() { networkFetcher.cancel() } override fun cleanup() { + super.cleanup() networkFetcher.cleanup() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt index 90a51f594..766107486 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.library import android.app.PendingIntent import android.app.Service -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.graphics.BitmapFactory @@ -18,10 +17,12 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start +import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.source.SourceManager -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.util.* import rx.Observable @@ -68,6 +69,11 @@ class LibraryUpdateService : Service() { */ private var subscription: Subscription? = null + /** + * Pending intent of action that cancels the library update + */ + private val cancelPendingIntent by lazy {NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)} + /** * Id of the library update notification. */ @@ -214,7 +220,7 @@ class LibraryUpdateService : Service() { } if (!intent.getBooleanExtra(UPDATE_DETAILS, false) && preferences.updateOnlyNonCompleted()) { - listToUpdate = listToUpdate.filter { it.status != Manga.COMPLETED } + listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED } } return listToUpdate @@ -235,13 +241,10 @@ class LibraryUpdateService : Service() { val newUpdates = ArrayList() val failedUpdates = ArrayList() - val cancelIntent = PendingIntent.getBroadcast(this, 0, - Intent(this, CancelUpdateReceiver::class.java), 0) - // Emit each manga and update it sequentially. return Observable.from(mangaToUpdate) // Notify manga that will update. - .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) } + .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) } // Update the chapters of the manga. .concatMap { manga -> updateManga(manga) @@ -297,7 +300,7 @@ class LibraryUpdateService : Service() { * @return a pair of the inserted and removed chapters. */ fun updateManga(manga: Manga): Observable, List>> { - val source = sourceManager.get(manga.source) as? OnlineSource ?: return Observable.empty() + val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty() return source.fetchChapterList(manga) .map { syncChaptersWithSource(db, it, manga, source) } } @@ -315,22 +318,20 @@ class LibraryUpdateService : Service() { // Initialize the variables holding the progress of the updates. val count = AtomicInteger(0) - val cancelIntent = PendingIntent.getBroadcast(this, 0, - Intent(this, CancelUpdateReceiver::class.java), 0) - // Emit each manga and update it sequentially. return Observable.from(mangaToUpdate) // Notify manga that will update. - .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) } + .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) } // Update the details of the manga. .concatMap { manga -> - val source = sourceManager.get(manga.source) as? OnlineSource + val source = sourceManager.get(manga.source) as? HttpSource ?: return@concatMap Observable.empty() source.fetchMangaDetails(manga) - .doOnNext { networkManga -> + .map { networkManga -> manga.copyFrom(networkManga) db.insertManga(manga).executeAsBlocking() + manga } .onErrorReturn { manga } } @@ -457,19 +458,4 @@ class LibraryUpdateService : Service() { intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } - - /** - * Class that stops updating the library. - */ - class CancelUpdateReceiver : BroadcastReceiver() { - /** - * Method called when user wants a library update. - * @param context the application context. - * @param intent the intent received. - */ - override fun onReceive(context: Context, intent: Intent) { - LibraryUpdateService.stop(context) - context.notificationManager.cancel(Constants.NOTIFICATION_LIBRARY_ID) - } - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt new file mode 100644 index 000000000..ae160492e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.data.notification + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.support.v4.content.FileProvider +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.ui.download.DownloadActivity +import eu.kanade.tachiyomi.util.getUriCompat +import java.io.File + +/** + * Class that manages [PendingIntent] of activity's + */ +object NotificationHandler { + /** + * Returns [PendingIntent] that starts a download activity. + * + * @param context context of application + */ + internal fun openDownloadManagerPendingActivity(context: Context): PendingIntent { + val intent = Intent(context, DownloadActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + } + return PendingIntent.getActivity(context, 0, intent, 0) + } + + /** + * Returns [PendingIntent] that starts a gallery activity + * + * @param context context of application + * @param file file containing image + */ + internal fun openImagePendingActivity(context: Context, file: File): PendingIntent { + val intent = Intent(Intent.ACTION_VIEW).apply { + val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file) + setDataAndType(uri, "image/*") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that prompts user with apk install intent + * + * @param context context + * @param file file of apk that is installed + */ + fun installApkPendingActivity(context: Context, file: File): PendingIntent { + val intent = Intent(Intent.ACTION_VIEW).apply { + val uri = file.getUriCompat(context) + setDataAndType(uri, "application/vnd.android.package-archive") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + return PendingIntent.getActivity(context, 0, intent, 0) + } +} 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 new file mode 100644 index 000000000..ce4804ab4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -0,0 +1,277 @@ +package eu.kanade.tachiyomi.data.notification + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import eu.kanade.tachiyomi.Constants +import eu.kanade.tachiyomi.R +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.download.DownloadManager +import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.util.deleteIfExists +import eu.kanade.tachiyomi.util.getUriCompat +import eu.kanade.tachiyomi.util.notificationManager +import eu.kanade.tachiyomi.util.toast +import uy.kohesive.injekt.injectLazy +import java.io.File +import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID + +/** + * Global [BroadcastReceiver] that runs on UI thread + * Pending Broadcasts should be made from here. + * NOTE: Use local broadcasts if possible. + */ +class NotificationReceiver : BroadcastReceiver() { + /** + * Download manager. + */ + private val downloadManager: DownloadManager by injectLazy() + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + // Dismiss notification + ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) + // Resume the download service + ACTION_RESUME_DOWNLOADS -> DownloadService.start(context) + // Clear the download queue + ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true) + // Launch share activity and dismiss notification + ACTION_SHARE_IMAGE -> shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION), + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) + // Delete image from path and dismiss notification + ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION), + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) + // Cancel library update and dismiss notification + ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Constants.NOTIFICATION_LIBRARY_ID) + // Open reader activity + ACTION_OPEN_CHAPTER -> { + openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1), + intent.getLongExtra(EXTRA_CHAPTER_ID, -1)) + } + } + } + + /** + * Dismiss the notification + * + * @param notificationId the id of the notification + */ + private fun dismissNotification(context: Context, notificationId: Int) { + context.notificationManager.cancel(notificationId) + } + + /** + * Called to start share intent to share image + * + * @param context context of application + * @param path path of file + * @param notificationId id of notification + */ + private fun shareImage(context: Context, path: String, notificationId: Int) { + // Create intent + val intent = Intent(Intent.ACTION_SEND).apply { + val uri = File(path).getUriCompat(context) + putExtra(Intent.EXTRA_STREAM, uri) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + type = "image/*" + } + // Dismiss notification + dismissNotification(context, notificationId) + // Launch share activity + context.startActivity(intent) + } + + /** + * Starts reader activity + * + * @param context context of application + * @param mangaId id of manga + * @param chapterId id of chapter + */ + internal fun openChapter(context: Context, mangaId: Long, chapterId: Long) { + val db = DatabaseHelper(context) + val manga = db.getManga(mangaId).executeAsBlocking() + val chapter = db.getChapter(chapterId).executeAsBlocking() + + if (manga != null && chapter != null) { + val intent = ReaderActivity.newIntent(context, manga, chapter).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + context.startActivity(intent) + } else { + context.toast(context.getString(R.string.chapter_error)) + } + } + + /** + * Called to delete image + * + * @param path path of file + * @param notificationId id of notification + */ + private fun deleteImage(context: Context, path: String, notificationId: Int) { + // Dismiss notification + dismissNotification(context, notificationId) + + // Delete file + File(path).deleteIfExists() + } + + /** + * Method called when user wants to stop a library update + * + * @param context context of application + * @param notificationId id of notification + */ + private fun cancelLibraryUpdate(context: Context, notificationId: Int) { + LibraryUpdateService.stop(context) + Handler().post { dismissNotification(context, notificationId) } + } + + companion object { + private const val NAME = "NotificationReceiver" + + // Called to launch share intent. + private const val ACTION_SHARE_IMAGE = "$ID.$NAME.SHARE_IMAGE" + + // Called to delete image. + private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE" + + // Called to cancel library update. + private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" + + // Called to open chapter + private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER" + + // Value containing file location. + private const val EXTRA_FILE_LOCATION = "$ID.$NAME.FILE_LOCATION" + + // Called to resume downloads. + private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS" + + // Called to clear downloads. + private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS" + + // Called to dismiss notification. + private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION" + + // Value containing notification id. + private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID" + + // Value containing manga id. + private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID" + + // Value containing chapter id. + private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID" + + /** + * Returns a [PendingIntent] that resumes the download of a chapter + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun resumeDownloadsPendingBroadcast(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_RESUME_DOWNLOADS + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT) + } + + /** + * Returns a [PendingIntent] that clears the download queue + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun clearDownloadsPendingBroadcast(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CLEAR_DOWNLOADS + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT) + } + + /** + * Returns [PendingIntent] that starts a service which dismissed the notification + * + * @param context context of application + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun dismissNotificationPendingBroadcast(context: Context, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_DISMISS_NOTIFICATION + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT) + } + + /** + * Returns [PendingIntent] that starts a service which cancels the notification and starts a share activity + * + * @param context context of application + * @param path location path of file + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun shareImagePendingBroadcast(context: Context, path: String, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_SHARE_IMAGE + putExtra(EXTRA_FILE_LOCATION, path) + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT) + } + + /** + * Returns [PendingIntent] that starts a service which removes an image from disk + * + * @param context context of application + * @param path location path of file + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun deleteImagePendingBroadcast(context: Context, path: String, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_DELETE_IMAGE + putExtra(EXTRA_FILE_LOCATION, path) + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT) + } + + /** + * Returns [PendingIntent] that start a reader activity containing chapter. + * + * @param context context of application + * @param manga manga of chapter + * @param chapter chapter that needs to be opened + */ + internal fun openChapterPendingBroadcast(context: Context, manga: Manga, chapter: Chapter): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_OPEN_CHAPTER + putExtra(EXTRA_MANGA_ID, manga.id) + putExtra(EXTRA_CHAPTER_ID, chapter.id) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT) + } + + /** + * Returns [PendingIntent] that starts a service which stops the library update + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun cancelLibraryUpdatePendingBroadcast(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CANCEL_LIBRARY_UPDATE + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT) + } + } +} \ No newline at end of file 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 9f0681371..fa77e4ad4 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 @@ -41,6 +41,8 @@ class PreferenceKeys(context: Context) { val readerTheme = context.getString(R.string.pref_reader_theme_key) + val cropBorders = context.getString(R.string.pref_crop_borders_key) + val readWithTapping = context.getString(R.string.pref_read_with_tapping_key) val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key) @@ -91,9 +93,9 @@ class PreferenceKeys(context: Context) { val downloadNew = context.getString(R.string.pref_download_new_key) - fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId" + fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" - fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId" + fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" 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 c4deaecc9..7e96d8f08 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 @@ -7,8 +7,8 @@ import android.preference.PreferenceManager import com.f2prateek.rx.preferences.Preference import com.f2prateek.rx.preferences.RxSharedPreferences import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.Source import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.source.Source import exh.ui.migration.MigrationStatus import java.io.File @@ -35,7 +35,7 @@ class PreferencesHelper(val context: Context) { fun rotation() = rxPrefs.getInteger(keys.rotation, 1) - fun enableTransitions() = rxPrefs.getBoolean(keys.enableTransitions, true) + fun pageTransitions() = rxPrefs.getBoolean(keys.enableTransitions, true) fun showPageNumber() = rxPrefs.getBoolean(keys.showPageNumber, true) @@ -61,6 +61,8 @@ class PreferencesHelper(val context: Context) { fun readerTheme() = rxPrefs.getInteger(keys.readerTheme, 0) + fun cropBorders() = rxPrefs.getBoolean(keys.cropBorders, false) + fun readWithTapping() = rxPrefs.getBoolean(keys.readWithTapping, true) fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false) @@ -75,7 +77,7 @@ class PreferencesHelper(val context: Context) { fun askUpdateTrack() = prefs.getBoolean(keys.askUpdateTrack, false) - fun lastUsedCatalogueSource() = rxPrefs.getInteger(keys.lastUsedCatalogueSource, -1) + fun lastUsedCatalogueSource() = rxPrefs.getLong(keys.lastUsedCatalogueSource, -1) fun lastUsedCategory() = rxPrefs.getInteger(keys.lastUsedCategory, 0) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.kt deleted file mode 100644 index 7b9b82bff..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.kt +++ /dev/null @@ -1,96 +0,0 @@ -package eu.kanade.tachiyomi.data.source - -import android.Manifest.permission.READ_EXTERNAL_STORAGE -import android.content.Context -import android.os.Environment -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.source.online.OnlineSource -import eu.kanade.tachiyomi.data.source.online.YamlOnlineSource -import eu.kanade.tachiyomi.data.source.online.all.EHentai -import eu.kanade.tachiyomi.data.source.online.all.EHentaiMetadata -import eu.kanade.tachiyomi.data.source.online.english.* -import eu.kanade.tachiyomi.data.source.online.german.WieManga -import eu.kanade.tachiyomi.data.source.online.russian.Mangachan -import eu.kanade.tachiyomi.data.source.online.russian.Mintmanga -import eu.kanade.tachiyomi.data.source.online.russian.Readmanga -import eu.kanade.tachiyomi.util.hasPermission -import org.yaml.snakeyaml.Yaml -import rx.functions.Action1 -import timber.log.Timber -import uy.kohesive.injekt.injectLazy -import java.io.File - -open class SourceManager(private val context: Context) { - - private val prefs: PreferencesHelper by injectLazy() - - private var sourcesMap = createSources() - - open fun get(sourceKey: Int): Source? { - return sourcesMap[sourceKey] - } - - fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java) - - private fun createOnlineSourceList(): List = listOf( - Batoto(101), - Mangahere(102), - Mangafox(103), - Kissmanga(104), - Readmanga(105), - Mintmanga(106), - Mangachan(107), - Readmangatoday(108), - Mangasee(109), - WieManga(110) - ) - - private fun createEHSources(): List { - val exSrcs = mutableListOf( - EHentai(1, false, context), - EHentaiMetadata(3, false, context) - ) - if(prefs.enableExhentai().getOrDefault()) { - exSrcs += EHentai(2, true, context) - exSrcs += EHentaiMetadata(4, true, context) - } - return exSrcs - } - - init { - //Rebuild EH when settings change - val action: Action1 = Action1 { sourcesMap = createSources() } - - prefs.enableExhentai().asObservable().subscribe(action) - prefs.imageQuality().asObservable().subscribe (action) - prefs.useHentaiAtHome().asObservable().subscribe(action) - prefs.useJapaneseTitle().asObservable().subscribe { - action.call(null) - } - prefs.ehSearchSize().asObservable().subscribe (action) - prefs.thumbnailRows().asObservable().subscribe(action) - } - - private fun createSources(): Map = hashMapOf().apply { - createEHSources().forEach { put(it.id, it) } - createOnlineSourceList().forEach { put(it.id, it) } - - val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath + - File.separator + context.getString(R.string.app_name), "parsers") - - if (parsersDir.exists() && context.hasPermission(READ_EXTERNAL_STORAGE)) { - val yaml = Yaml() - for (file in parsersDir.listFiles().filter { it.extension == "yml" }) { - try { - val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) } - YamlOnlineSource(map).let { put(it.id, it) } - } catch (e: Exception) { - Timber.e("Error loading source from file. Bad format?") - } - } - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/model/MangasPage.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/model/MangasPage.kt deleted file mode 100644 index 78b9aa054..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/model/MangasPage.kt +++ /dev/null @@ -1,13 +0,0 @@ -package eu.kanade.tachiyomi.data.source.model - -import eu.kanade.tachiyomi.data.database.models.Manga - -class MangasPage(val page: Int) { - - val mangas: MutableList = mutableListOf() - - lateinit var url: String - - var nextPageUrl: String? = null - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt deleted file mode 100644 index 611fcc06d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt +++ /dev/null @@ -1,469 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online - -import android.net.Uri -import eu.kanade.tachiyomi.data.cache.ChapterCache -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.network.GET -import eu.kanade.tachiyomi.data.network.NetworkHelper -import eu.kanade.tachiyomi.data.network.asObservableSuccess -import eu.kanade.tachiyomi.data.network.newCallWithProgress -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.util.UrlUtil -import okhttp3.Headers -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import rx.Observable -import uy.kohesive.injekt.injectLazy - -/** - * A simple implementation for sources from a website. - */ -abstract class OnlineSource() : Source { - - /** - * Network service. - */ - val network: NetworkHelper by injectLazy() - - /** - * Chapter cache. - */ - val chapterCache: ChapterCache by injectLazy() - - /** - * Preferences helper. - */ - val preferences: PreferencesHelper by injectLazy() - - /** - * Base url of the website without the trailing slash, like: http://mysite.com - */ - abstract val baseUrl: String - - /** - * An ISO 639-1 compliant language code (two characters in lower case). - */ - abstract val lang: String - - /** - * Whether the source has support for latest updates. - */ - abstract val supportsLatest : Boolean - - /** - * Headers used for requests. - */ - val headers by lazy { headersBuilder().build() } - - /** - * Genre filters. - */ - val filters by lazy { getFilterList() } - - /** - * Default network client for doing requests. - */ - open val client: OkHttpClient - get() = network.client - - /** - * Headers builder for requests. Implementations can override this method for custom headers. - */ - open protected fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") - } - - /** - * Visible name of the source. - */ - override fun toString() = "$name (${lang.toUpperCase()})" - - /** - * Returns an observable containing a page with a list of manga. Normally it's not needed to - * override this method. - * - * @param page the page object where the information will be saved, like the list of manga, - * the current page and the next page url. - */ - open fun fetchPopularManga(page: MangasPage): Observable = client - .newCall(popularMangaRequest(page)) - .asObservableSuccess() - .map { response -> - popularMangaParse(response, page) - page - } - - /** - * Returns the request for the popular manga given the page. Override only if it's needed to - * send different headers or request method like POST. - * - * @param page the page object. - */ - open protected fun popularMangaRequest(page: MangasPage): Request { - if (page.page == 1) { - page.url = popularMangaInitialUrl() - } - return GET(page.url, headers) - } - - /** - * Returns the absolute url of the first page to popular manga. - */ - abstract protected fun popularMangaInitialUrl(): String - - /** - * Parse the response from the site. It should add a list of manga and the absolute url to the - * next page (if it has a next one) to [page]. - * - * @param response the response from the site. - * @param page the page object to be filled. - */ - abstract protected fun popularMangaParse(response: Response, page: MangasPage) - - /** - * Returns an observable containing a page with a list of manga. Normally it's not needed to - * override this method. - * - * @param page the page object where the information will be saved, like the list of manga, - * the current page and the next page url. - * @param query the search query. - */ - open fun fetchSearchManga(page: MangasPage, query: String, filters: List): Observable = client - .newCall(searchMangaRequest(page, query, filters)) - .asObservableSuccess() - .map { response -> - searchMangaParse(response, page, query, filters) - page - } - - /** - * Returns the request for the search manga given the page. Override only if it's needed to - * send different headers or request method like POST. - * - * @param page the page object. - * @param query the search query. - */ - open protected fun searchMangaRequest(page: MangasPage, query: String, filters: List): Request { - if (page.page == 1) { - page.url = searchMangaInitialUrl(query, filters) - } - return GET(page.url, headers) - } - - /** - * Returns the absolute url of the first page to popular manga. - * - * @param query the search query. - */ - abstract protected fun searchMangaInitialUrl(query: String, filters: List): String - - /** - * Parse the response from the site. It should add a list of manga and the absolute url to the - * next page (if it has a next one) to [page]. - * - * @param response the response from the site. - * @param page the page object to be filled. - * @param query the search query. - */ - abstract protected fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List) - - /** - * Returns an observable containing a page with a list of latest manga. - */ - open fun fetchLatestUpdates(page: MangasPage): Observable = client - .newCall(latestUpdatesRequest(page)) - .asObservableSuccess() - .map { response -> - latestUpdatesParse(response, page) - page - } - - /** - * Returns the request for latest manga given the page. - */ - open protected fun latestUpdatesRequest(page: MangasPage): Request { - if (page.page == 1) { - page.url = latestUpdatesInitialUrl() - } - return GET(page.url, headers) - } - - /** - * Returns the absolute url of the first page to latest manga. - */ - abstract protected fun latestUpdatesInitialUrl(): String - - /** - * Same as [popularMangaParse], but for latest manga. - */ - abstract protected fun latestUpdatesParse(response: Response, page: MangasPage) - - /** - * Returns an observable with the updated details for a manga. Normally it's not needed to - * override this method. - * - * @param manga the manga to be updated. - */ - override fun fetchMangaDetails(manga: Manga): Observable = client - .newCall(mangaDetailsRequest(manga)) - .asObservableSuccess() - .map { response -> - Manga.create(manga.url, id).apply { - mangaDetailsParse(response, this) - initialized = true - } - } - - /** - * Returns the request for updating a manga. Override only if it's needed to override the url, - * send different headers or request method like POST. - * - * @param manga the manga to be updated. - */ - open fun mangaDetailsRequest(manga: Manga): Request { - return GET(baseUrl + manga.url, headers) - } - - /** - * Parse the response from the site. It should fill [manga]. - * - * @param response the response from the site. - * @param manga the manga whose fields have to be filled. - */ - abstract protected fun mangaDetailsParse(response: Response, manga: Manga) - - /** - * Returns an observable with the updated chapter list for a manga. Normally it's not needed to - * override this method. - * - * @param manga the manga to look for chapters. - */ - override fun fetchChapterList(manga: Manga): Observable> = client - .newCall(chapterListRequest(manga)) - .asObservableSuccess() - .map { response -> - mutableListOf().apply { - chapterListParse(response, this) - if (isEmpty()) { - throw Exception("No chapters found") - } - } - } - - /** - * Returns the request for updating the chapter list. Override only if it's needed to override - * the url, send different headers or request method like POST. - * - * @param manga the manga to look for chapters. - */ - open protected fun chapterListRequest(manga: Manga): Request { - return GET(baseUrl + manga.url, headers) - } - - /** - * Parse the response from the site. It should fill [chapters]. - * - * @param response the response from the site. - * @param chapters the chapter list to be filled. - */ - abstract protected fun chapterListParse(response: Response, chapters: MutableList) - - /** - * Returns an observable with the page list for a chapter. It tries to return the page list from - * the local cache, otherwise fallbacks to network calling [fetchPageListFromNetwork]. - * - * @param chapter the chapter whose page list has to be fetched. - */ - final override fun fetchPageList(chapter: Chapter): Observable> = chapterCache - .getPageListFromCache(getChapterCacheKey(chapter)) - .onErrorResumeNext { fetchPageListFromNetwork(chapter) } - - /** - * Returns an observable with the page list for a chapter. Normally it's not needed to override - * this method. - * - * @param chapter the chapter whose page list has to be fetched. - */ - open fun fetchPageListFromNetwork(chapter: Chapter): Observable> = client - .newCall(pageListRequest(chapter)) - .asObservableSuccess() - .map { response -> - mutableListOf().apply { - pageListParse(response, this) - if (isEmpty()) { - throw Exception("Page list is empty") - } - } - } - - /** - * Returns the request for getting the page list. Override only if it's needed to override the - * url, send different headers or request method like POST. - * - * @param chapter the chapter whose page list has to be fetched - */ - open protected fun pageListRequest(chapter: Chapter): Request { - return GET(baseUrl + chapter.url, headers) - } - - /** - * Parse the response from the site. It should fill [pages]. - * - * @param response the response from the site. - * @param pages the page list to be filled. - */ - abstract protected fun pageListParse(response: Response, pages: MutableList) - - /** - * Returns the key for the page list to be stored in [ChapterCache]. - */ - private fun getChapterCacheKey(chapter: Chapter) = "$id${chapter.url}" - - /** - * Returns an observable with the page containing the source url of the image. If there's any - * error, it will return null instead of throwing an exception. - * - * @param page the page whose source image has to be fetched. - */ - open protected fun fetchImageUrl(page: Page): Observable { - page.status = Page.LOAD_PAGE - return client - .newCall(imageUrlRequest(page)) - .asObservableSuccess() - .map { imageUrlParse(it) } - .doOnError { page.status = Page.ERROR } - .onErrorReturn { null } - .doOnNext { page.imageUrl = it } - .map { page } - } - - /** - * Returns the request for getting the url to the source image. Override only if it's needed to - * override the url, send different headers or request method like POST. - * - * @param page the chapter whose page list has to be fetched - */ - open protected fun imageUrlRequest(page: Page): Request { - return GET(page.url, headers) - } - - /** - * Parse the response from the site. It should return the absolute url to the source image. - * - * @param response the response from the site. - */ - abstract protected fun imageUrlParse(response: Response): String - - /** - * Returns an observable of the page with the downloaded image. - * - * @param page the page whose source image has to be downloaded. - */ - final override fun fetchImage(page: Page): Observable = - if (page.imageUrl.isNullOrEmpty()) - fetchImageUrl(page).flatMap { getCachedImage(it) } - else - getCachedImage(page) - - /** - * Returns an observable with the response of the source image. - * - * @param page the page whose source image has to be downloaded. - */ - fun imageResponse(page: Page): Observable = client - .newCallWithProgress(imageRequest(page), page) - .asObservableSuccess() - - /** - * Returns the request for getting the source image. Override only if it's needed to override - * the url, send different headers or request method like POST. - * - * @param page the chapter whose page list has to be fetched - */ - open protected fun imageRequest(page: Page): Request { - return GET(page.imageUrl!!, headers) - } - - /** - * Returns an observable of the page that gets the image from the chapter or fallbacks to - * network and copies it to the cache calling [cacheImage]. - * - * @param page the page. - */ - fun getCachedImage(page: Page): Observable { - val imageUrl = page.imageUrl ?: return Observable.just(page) - - return Observable.just(page) - .flatMap { - if (!chapterCache.isImageInCache(imageUrl)) { - cacheImage(page) - } else { - Observable.just(page) - } - } - .doOnNext { - page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl)) - page.status = Page.READY - } - .doOnError { page.status = Page.ERROR } - .onErrorReturn { page } - } - - /** - * Returns an observable of the page that downloads the image to [ChapterCache]. - * - * @param page the page. - */ - private fun cacheImage(page: Page): Observable { - page.status = Page.DOWNLOAD_IMAGE - return imageResponse(page) - .doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) } - .map { page } - } - - - // Utility methods - - fun fetchAllImageUrlsFromPageList(pages: List) = Observable.from(pages) - .filter { !it.imageUrl.isNullOrEmpty() } - .mergeWith(fetchRemainingImageUrlsFromPageList(pages)) - - fun fetchRemainingImageUrlsFromPageList(pages: List) = Observable.from(pages) - .filter { it.imageUrl.isNullOrEmpty() } - .concatMap { fetchImageUrl(it) } - - fun savePageList(chapter: Chapter, pages: List?) { - if (pages != null) { - chapterCache.putPageListToCache(getChapterCacheKey(chapter), pages) - } - } - - fun Chapter.setUrlWithoutDomain(url: String) { - this.url = UrlUtil.getPath(url) - } - - fun Manga.setUrlWithoutDomain(url: String) { - this.url = UrlUtil.getPath(url) - } - - - /** - * Called before inserting a new chapter into database. Use it if you need to override chapter - * fields, like the title or the chapter number. Do not change anything to [manga]. - * - * @param chapter the chapter to be added. - * @param manga the manga of the chapter. - */ - open fun prepareNewChapter(chapter: Chapter, manga: Manga) { - - } - - data class Filter(val id: String, val name: String) - - open fun getFilterList(): List = emptyList() -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/ParsedOnlineSource.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/ParsedOnlineSource.kt deleted file mode 100644 index 6b0e21158..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/ParsedOnlineSource.kt +++ /dev/null @@ -1,211 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element - -/** - * A simple implementation for sources from a website using Jsoup, an HTML parser. - */ -abstract class ParsedOnlineSource() : OnlineSource() { - - /** - * Parse the response from the site and fills [page]. - * - * @param response the response from the site. - * @param page the page object to be filled. - */ - override fun popularMangaParse(response: Response, page: MangasPage) { - val document = response.asJsoup() - for (element in document.select(popularMangaSelector())) { - Manga.create(id).apply { - popularMangaFromElement(element, this) - page.mangas.add(this) - } - } - - popularMangaNextPageSelector()?.let { selector -> - page.nextPageUrl = document.select(selector).first()?.absUrl("href") - } - } - - /** - * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. - */ - abstract protected fun popularMangaSelector(): String - - /** - * Fills [manga] with the given [element]. Most sites only show the title and the url, it's - * totally safe to fill only those two values. - * - * @param element an element obtained from [popularMangaSelector]. - * @param manga the manga to fill. - */ - abstract protected fun popularMangaFromElement(element: Element, manga: Manga) - - /** - * Returns the Jsoup selector that returns the tag linking to the next page, or null if - * there's no next page. - */ - abstract protected fun popularMangaNextPageSelector(): String? - - /** - * Parse the response from the site and fills [page]. - * - * @param response the response from the site. - * @param page the page object to be filled. - * @param query the search query. - */ - override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List) { - val document = response.asJsoup() - for (element in document.select(searchMangaSelector())) { - Manga.create(id).apply { - searchMangaFromElement(element, this) - page.mangas.add(this) - } - } - - searchMangaNextPageSelector()?.let { selector -> - page.nextPageUrl = document.select(selector).first()?.absUrl("href") - } - } - - /** - * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. - */ - abstract protected fun searchMangaSelector(): String - - /** - * Fills [manga] with the given [element]. Most sites only show the title and the url, it's - * totally safe to fill only those two values. - * - * @param element an element obtained from [searchMangaSelector]. - * @param manga the manga to fill. - */ - abstract protected fun searchMangaFromElement(element: Element, manga: Manga) - - /** - * Returns the Jsoup selector that returns the tag linking to the next page, or null if - * there's no next page. - */ - abstract protected fun searchMangaNextPageSelector(): String? - - /** - * Parse the response from the site for latest updates and fills [page]. - */ - override fun latestUpdatesParse(response: Response, page: MangasPage) { - val document = response.asJsoup() - for (element in document.select(latestUpdatesSelector())) { - Manga.create(id).apply { - latestUpdatesFromElement(element, this) - page.mangas.add(this) - } - } - - latestUpdatesNextPageSelector()?.let { selector -> - page.nextPageUrl = document.select(selector).first()?.absUrl("href") - } - } - - /** - * Returns the Jsoup selector similar to [popularMangaSelector], but for latest updates. - */ - abstract protected fun latestUpdatesSelector(): String - - /** - * Fills [manga] with the given [element]. For latest updates. - */ - abstract protected fun latestUpdatesFromElement(element: Element, manga: Manga) - - /** - * Returns the Jsoup selector that returns the tag, like [popularMangaNextPageSelector]. - */ - abstract protected fun latestUpdatesNextPageSelector(): String? - - /** - * Parse the response from the site and fills the details of [manga]. - * - * @param response the response from the site. - * @param manga the manga to fill. - */ - override fun mangaDetailsParse(response: Response, manga: Manga) { - mangaDetailsParse(response.asJsoup(), manga) - } - - /** - * Fills the details of [manga] from the given [document]. - * - * @param document the parsed document. - * @param manga the manga to fill. - */ - abstract protected fun mangaDetailsParse(document: Document, manga: Manga) - - /** - * Parse the response from the site and fills the chapter list. - * - * @param response the response from the site. - * @param chapters the list of chapters to fill. - */ - override fun chapterListParse(response: Response, chapters: MutableList) { - val document = response.asJsoup() - - for (element in document.select(chapterListSelector())) { - Chapter.create().apply { - chapterFromElement(element, this) - chapters.add(this) - } - } - } - - /** - * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. - */ - abstract protected fun chapterListSelector(): String - - /** - * Fills [chapter] with the given [element]. - * - * @param element an element obtained from [chapterListSelector]. - * @param chapter the chapter to fill. - */ - abstract protected fun chapterFromElement(element: Element, chapter: Chapter) - - /** - * Parse the response from the site and fills the page list. - * - * @param response the response from the site. - * @param pages the list of pages to fill. - */ - override fun pageListParse(response: Response, pages: MutableList) { - pageListParse(response.asJsoup(), pages) - } - - /** - * Fills [pages] from the given [document]. - * - * @param document the parsed document. - * @param pages the list of pages to fill. - */ - abstract protected fun pageListParse(document: Document, pages: MutableList) - - /** - * Parse the response from the site and returns the absolute url to the source image. - * - * @param response the response from the site. - */ - override fun imageUrlParse(response: Response): String { - return imageUrlParse(response.asJsoup()) - } - - /** - * Returns the absolute url to the source image from the document. - * - * @param document the parsed document. - */ - abstract protected fun imageUrlParse(document: Document): String -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.kt deleted file mode 100644 index 19c678087..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.kt +++ /dev/null @@ -1,354 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.english - -import android.net.Uri -import android.text.Html -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.network.GET -import eu.kanade.tachiyomi.data.network.POST -import eu.kanade.tachiyomi.data.network.asObservable -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.LoginSource -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import eu.kanade.tachiyomi.util.asJsoup -import eu.kanade.tachiyomi.util.selectText -import okhttp3.FormBody -import okhttp3.Request -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import rx.Observable -import java.net.URI -import java.text.ParseException -import java.text.SimpleDateFormat -import java.util.* -import java.util.regex.Pattern - -class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource { - - override val name = "Batoto" - - override val baseUrl = "http://bato.to" - - override val lang = "en" - - override val supportsLatest = true - - private val datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*") - - private val dateFields = HashMap().apply { - put("second", Calendar.SECOND) - put("minute", Calendar.MINUTE) - put("hour", Calendar.HOUR) - put("day", Calendar.DATE) - put("week", Calendar.WEEK_OF_YEAR) - put("month", Calendar.MONTH) - put("year", Calendar.YEAR) - } - - private val staffNotice = Pattern.compile("=+Batoto Staff Notice=+([^=]+)==+", Pattern.CASE_INSENSITIVE) - - override fun headersBuilder() = super.headersBuilder() - .add("Cookie", "lang_option=English") - - private val pageHeaders = super.headersBuilder() - .add("Referer", "http://bato.to/reader") - .build() - - override fun popularMangaInitialUrl() = "$baseUrl/search_ajax?order_cond=views&order=desc&p=1" - - override fun latestUpdatesInitialUrl() = "$baseUrl/search_ajax?order_cond=update&order=desc&p=1" - - override fun popularMangaParse(response: Response, page: MangasPage) { - val document = response.asJsoup() - for (element in document.select(popularMangaSelector())) { - Manga.create(id).apply { - popularMangaFromElement(element, this) - page.mangas.add(this) - } - } - - page.nextPageUrl = document.select(popularMangaNextPageSelector()).first()?.let { - "$baseUrl/search_ajax?order_cond=views&order=desc&p=${page.page + 1}" - } - } - - override fun latestUpdatesParse(response: Response, page: MangasPage) { - val document = response.asJsoup() - for (element in document.select(latestUpdatesSelector())) { - Manga.create(id).apply { - latestUpdatesFromElement(element, this) - page.mangas.add(this) - } - } - - page.nextPageUrl = document.select(latestUpdatesNextPageSelector()).first()?.let { - "$baseUrl/search_ajax?order_cond=update&order=desc&p=${page.page + 1}" - } - } - - override fun popularMangaSelector() = "tr:has(a)" - - override fun latestUpdatesSelector() = "tr:has(a)" - - override fun popularMangaFromElement(element: Element, manga: Manga) { - element.select("a[href^=http://bato.to]").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text().trim() - } - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = "#show_more_row" - - override fun latestUpdatesNextPageSelector() = "#show_more_row" - - override fun searchMangaInitialUrl(query: String, filters: List) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=1${getFilterParams(filters)}" - - private fun getFilterParams(filters: List): String { - var genres = "" - var completed = "" - for (filter in filters) { - if (filter.equals(completedFilter)) completed = "&completed=c" - else genres += ";i" + filter.id - } - return if (genres.isEmpty()) completed else "&genres=$genres&genre_cond=and$completed" - } - - override fun searchMangaRequest(page: MangasPage, query: String, filters: List): Request { - if (page.page == 1) { - page.url = searchMangaInitialUrl(query, filters) - } - return GET(page.url, headers) - } - - override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List) { - val document = response.asJsoup() - for (element in document.select(searchMangaSelector())) { - Manga.create(id).apply { - searchMangaFromElement(element, this) - page.mangas.add(this) - } - } - - page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let { - "$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=${page.page + 1}${getFilterParams(filters)}" - } - } - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - - override fun mangaDetailsRequest(manga: Manga): Request { - val mangaId = manga.url.substringAfterLast("r") - return GET("$baseUrl/comic_pop?id=$mangaId", headers) - } - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val tbody = document.select("tbody").first() - val artistElement = tbody.select("tr:contains(Author/Artist:)").first() - - manga.author = artistElement.selectText("td:eq(1)") - manga.artist = artistElement.selectText("td:eq(2)") ?: manga.author - manga.description = tbody.selectText("tr:contains(Description:) > td:eq(1)") - manga.thumbnail_url = document.select("img[src^=http://img.bato.to/forums/uploads/]").first()?.attr("src") - manga.status = parseStatus(document.selectText("tr:contains(Status:) > td:eq(1)")) - manga.genre = tbody.select("tr:contains(Genres:) img").map { it.attr("alt") }.joinToString(", ") - } - - private fun parseStatus(status: String?) = when (status) { - "Ongoing" -> Manga.ONGOING - "Complete" -> Manga.COMPLETED - else -> Manga.UNKNOWN - } - - override fun chapterListParse(response: Response, chapters: MutableList) { - val body = response.body().string() - val matcher = staffNotice.matcher(body) - if (matcher.find()) { - val notice = Html.fromHtml(matcher.group(1)).toString().trim() - throw Exception(notice) - } - - val document = response.asJsoup(body) - - for (element in document.select(chapterListSelector())) { - Chapter.create().apply { - chapterFromElement(element, this) - chapters.add(this) - } - } - } - - override fun chapterListSelector() = "tr.row.lang_English.chapter_row" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select("a[href^=http://bato.to/reader").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = urlElement.text() - chapter.date_upload = element.select("td").getOrNull(4)?.let { - parseDateFromElement(it) - } ?: 0 - } - - private fun parseDateFromElement(dateElement: Element): Long { - val dateAsString = dateElement.text() - - var date: Date - try { - date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString) - } catch (e: ParseException) { - val m = datePattern.matcher(dateAsString) - - if (m.matches()) { - val number = m.group(1) - val amount = if (number.contains("A")) 1 else Integer.parseInt(m.group(1)) - val unit = m.group(2) - - date = Calendar.getInstance().apply { - add(dateFields[unit]!!, -amount) - }.time - } else { - return 0 - } - } - - return date.time - } - - override fun pageListRequest(chapter: Chapter): Request { - val id = chapter.url.substringAfterLast("#") - return GET("$baseUrl/areader?id=$id&p=1", pageHeaders) - } - - override fun pageListParse(document: Document, pages: MutableList) { - val selectElement = document.select("#page_select").first() - if (selectElement != null) { - for ((i, element) in selectElement.select("option").withIndex()) { - pages.add(Page(i, element.attr("value"))) - } - pages.getOrNull(0)?.imageUrl = imageUrlParse(document) - } else { - // For webtoons in one page - for ((i, element) in document.select("div > img").withIndex()) { - pages.add(Page(i, "", element.attr("src"))) - } - } - } - - override fun imageUrlRequest(page: Page): Request { - val pageUrl = page.url - val start = pageUrl.indexOf("#") + 1 - val end = pageUrl.indexOf("_", start) - val id = pageUrl.substring(start, end) - return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end + 1)}", pageHeaders) - } - - override fun imageUrlParse(document: Document): String { - return document.select("#comic_page").first().attr("src") - } - - override fun login(username: String, password: String) = - client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global§ion=login", headers)) - .asObservable() - .flatMap { doLogin(it, username, password) } - .map { isAuthenticationSuccessful(it) } - - private fun doLogin(response: Response, username: String, password: String): Observable { - val doc = response.asJsoup() - val form = doc.select("#login").first() - val url = form.attr("action") - val authKey = form.select("input[name=auth_key]").first() - - val payload = FormBody.Builder().apply { - add(authKey.attr("name"), authKey.attr("value")) - add("ips_username", username) - add("ips_password", password) - add("invisible", "1") - add("rememberMe", "1") - }.build() - - return client.newCall(POST(url, headers, payload)).asObservable() - } - - override fun isAuthenticationSuccessful(response: Response) = - response.priorResponse() != null && response.priorResponse().code() == 302 - - override fun isLogged(): Boolean { - return network.cookies.get(URI(baseUrl)).any { it.name() == "pass_hash" } - } - - override fun fetchChapterList(manga: Manga): Observable> { - if (!isLogged()) { - val username = preferences.sourceUsername(this) - val password = preferences.sourcePassword(this) - - if (username.isNullOrEmpty() || password.isNullOrEmpty()) { - return Observable.error(Exception("User not logged")) - } else { - return login(username, password).flatMap { super.fetchChapterList(manga) } - } - - } else { - return super.fetchChapterList(manga) - } - } - - private val completedFilter = Filter("completed", "Completed") - // [...document.querySelectorAll("#advanced_options div.genre_buttons")].map((el,i) => { - // const onClick=el.getAttribute('onclick');const id=onClick.substr(14,onClick.length-16);return `Filter("${id}", "${el.textContent.trim()}")` - // }).join(',\n') - // on https://bato.to/search - override fun getFilterList(): List = listOf( - completedFilter, - Filter("40", "4-Koma"), - Filter("1", "Action"), - Filter("2", "Adventure"), - Filter("39", "Award Winning"), - Filter("3", "Comedy"), - Filter("41", "Cooking"), - Filter("9", "Doujinshi"), - Filter("10", "Drama"), - Filter("12", "Ecchi"), - Filter("13", "Fantasy"), - Filter("15", "Gender Bender"), - Filter("17", "Harem"), - Filter("20", "Historical"), - Filter("22", "Horror"), - Filter("34", "Josei"), - Filter("27", "Martial Arts"), - Filter("30", "Mecha"), - Filter("42", "Medical"), - Filter("37", "Music"), - Filter("4", "Mystery"), - Filter("38", "Oneshot"), - Filter("5", "Psychological"), - Filter("6", "Romance"), - Filter("7", "School Life"), - Filter("8", "Sci-fi"), - Filter("32", "Seinen"), - Filter("35", "Shoujo"), - Filter("16", "Shoujo Ai"), - Filter("33", "Shounen"), - Filter("19", "Shounen Ai"), - Filter("21", "Slice of Life"), - Filter("23", "Smut"), - Filter("25", "Sports"), - Filter("26", "Supernatural"), - Filter("28", "Tragedy"), - Filter("36", "Webtoon"), - Filter("29", "Yaoi"), - Filter("31", "Yuri") - ) - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.kt deleted file mode 100644 index 767ae0137..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.kt +++ /dev/null @@ -1,181 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.english - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.network.GET -import eu.kanade.tachiyomi.data.network.POST -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import okhttp3.FormBody -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.regex.Pattern - -class Kissmanga(override val id: Int) : ParsedOnlineSource() { - - override val name = "Kissmanga" - - override val baseUrl = "http://kissmanga.com" - - override val lang = "en" - - override val supportsLatest = true - - override val client: OkHttpClient = network.cloudflareClient - - override fun popularMangaInitialUrl() = "$baseUrl/MangaList/MostPopular" - - override fun latestUpdatesInitialUrl() = "http://kissmanga.com/MangaList/LatestUpdate" - - override fun popularMangaSelector() = "table.listing tr:gt(1)" - - override fun latestUpdatesSelector() = "table.listing tr:gt(1)" - - override fun popularMangaFromElement(element: Element, manga: Manga) { - element.select("td a:eq(0)").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text() - } - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = "li > a:contains(› Next)" - - override fun latestUpdatesNextPageSelector(): String = "ul.pager > li > a:contains(Next)" - - override fun searchMangaRequest(page: MangasPage, query: String, filters: List): Request { - if (page.page == 1) { - page.url = searchMangaInitialUrl(query, filters) - } - - val form = FormBody.Builder().apply { - add("authorArtist", "") - add("mangaName", query) - - this@Kissmanga.filters.forEach { filter -> - if (filter.equals(completedFilter)) add("status", if (filter in filters) filter.id else "") - else add("genres", if (filter in filters) "1" else "0") - } - } - - return POST(page.url, headers, form.build()) - } - - override fun searchMangaInitialUrl(query: String, filters: List) = "$baseUrl/AdvanceSearch" - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun searchMangaNextPageSelector() = null - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val infoElement = document.select("div.barContent").first() - - manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text() - manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text() - manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text() - manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) } - manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src") - } - - fun parseStatus(status: String) = when { - status.contains("Ongoing") -> Manga.ONGOING - status.contains("Completed") -> Manga.COMPLETED - else -> Manga.UNKNOWN - } - - override fun chapterListSelector() = "table.listing tr:gt(1)" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select("a").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = urlElement.text() - chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let { - SimpleDateFormat("MM/dd/yyyy").parse(it).time - } ?: 0 - } - - override fun pageListRequest(chapter: Chapter) = POST(baseUrl + chapter.url, headers) - - override fun pageListParse(response: Response, pages: MutableList) { - //language=RegExp - val p = Pattern.compile("""lstImages.push\("(.+?)"""") - val m = p.matcher(response.body().string()) - - var i = 0 - while (m.find()) { - pages.add(Page(i++, "", m.group(1))) - } - } - - // Not used - override fun pageListParse(document: Document, pages: MutableList) { - } - - override fun imageUrlRequest(page: Page) = GET(page.url) - - override fun imageUrlParse(document: Document) = "" - - private val completedFilter = Filter("Completed", "Completed") - // $("select[name=\"genres\"]").map((i,el) => `Filter("${i}", "${$(el).next().text().trim()}")`).get().join(',\n') - // on http://kissmanga.com/AdvanceSearch - override fun getFilterList(): List = listOf( - completedFilter, - Filter("0", "Action"), - Filter("1", "Adult"), - Filter("2", "Adventure"), - Filter("3", "Comedy"), - Filter("4", "Comic"), - Filter("5", "Cooking"), - Filter("6", "Doujinshi"), - Filter("7", "Drama"), - Filter("8", "Ecchi"), - Filter("9", "Fantasy"), - Filter("10", "Gender Bender"), - Filter("11", "Harem"), - Filter("12", "Historical"), - Filter("13", "Horror"), - Filter("14", "Josei"), - Filter("15", "Lolicon"), - Filter("16", "Manga"), - Filter("17", "Manhua"), - Filter("18", "Manhwa"), - Filter("19", "Martial Arts"), - Filter("20", "Mature"), - Filter("21", "Mecha"), - Filter("22", "Medical"), - Filter("23", "Music"), - Filter("24", "Mystery"), - Filter("25", "One shot"), - Filter("26", "Psychological"), - Filter("27", "Romance"), - Filter("28", "School Life"), - Filter("29", "Sci-fi"), - Filter("30", "Seinen"), - Filter("31", "Shotacon"), - Filter("32", "Shoujo"), - Filter("33", "Shoujo Ai"), - Filter("34", "Shounen"), - Filter("35", "Shounen Ai"), - Filter("36", "Slice of Life"), - Filter("37", "Smut"), - Filter("38", "Sports"), - Filter("39", "Supernatural"), - Filter("40", "Tragedy"), - Filter("41", "Webtoon"), - Filter("42", "Yaoi"), - Filter("43", "Yuri") - ) -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.kt deleted file mode 100644 index 31b580c7a..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.kt +++ /dev/null @@ -1,171 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.english - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.ParseException -import java.text.SimpleDateFormat -import java.util.* - -class Mangafox(override val id: Int) : ParsedOnlineSource() { - - override val name = "Mangafox" - - override val baseUrl = "http://mangafox.me" - - override val lang = "en" - - override val supportsLatest = true - - override fun popularMangaInitialUrl() = "$baseUrl/directory/" - - override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?latest" - - override fun popularMangaSelector() = "div#mangalist > ul.list > li" - - override fun latestUpdatesSelector() = "div#mangalist > ul.list > li" - - override fun popularMangaFromElement(element: Element, manga: Manga) { - element.select("a.title").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text() - } - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = "a:has(span.next)" - - override fun latestUpdatesNextPageSelector() = "a:has(span.next)" - - override fun searchMangaInitialUrl(query: String, filters: List) = - "$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1&${filters.map { it.id + "=1" }.joinToString("&")}" - - override fun searchMangaSelector() = "div#mangalist > ul.list > li" - - override fun searchMangaFromElement(element: Element, manga: Manga) { - element.select("a.title").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text() - } - } - - override fun searchMangaNextPageSelector() = "a:has(span.next)" - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val infoElement = document.select("div#title").first() - val rowElement = infoElement.select("table > tbody > tr:eq(1)").first() - val sideInfoElement = document.select("#series_info").first() - - manga.author = rowElement.select("td:eq(1)").first()?.text() - manga.artist = rowElement.select("td:eq(2)").first()?.text() - manga.genre = rowElement.select("td:eq(3)").first()?.text() - manga.description = infoElement.select("p.summary").first()?.text() - manga.status = sideInfoElement.select(".data").first()?.text().orEmpty().let { parseStatus(it) } - manga.thumbnail_url = sideInfoElement.select("div.cover > img").first()?.attr("src") - } - - private fun parseStatus(status: String) = when { - status.contains("Ongoing") -> Manga.ONGOING - status.contains("Completed") -> Manga.COMPLETED - else -> Manga.UNKNOWN - } - - override fun chapterListSelector() = "div#chapters li div" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select("a.tips").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = urlElement.text() - chapter.date_upload = element.select("span.date").first()?.text()?.let { parseChapterDate(it) } ?: 0 - } - - private fun parseChapterDate(date: String): Long { - return if ("Today" in date || " ago" in date) { - Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - }.timeInMillis - } else if ("Yesterday" in date) { - Calendar.getInstance().apply { - add(Calendar.DATE, -1) - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - }.timeInMillis - } else { - try { - SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time - } catch (e: ParseException) { - 0L - } - } - } - - override fun pageListParse(response: Response, pages: MutableList) { - val document = response.asJsoup() - - val url = response.request().url().toString().substringBeforeLast('/') - document.select("select.m").first()?.select("option:not([value=0])")?.forEach { - pages.add(Page(pages.size, "$url/${it.attr("value")}.html")) - } - } - - // Not used, overrides parent. - override fun pageListParse(document: Document, pages: MutableList) {} - - override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src") - - // $('select.genres').map((i,el)=>`Filter("${$(el).attr('name')}", "${$(el).next().text().trim()}")`).get().join(',\n') - // on http://kissmanga.com/AdvanceSearch - override fun getFilterList(): List = listOf( - Filter("is_completed", "Completed"), - Filter("genres[Action]", "Action"), - Filter("genres[Adult]", "Adult"), - Filter("genres[Adventure]", "Adventure"), - Filter("genres[Comedy]", "Comedy"), - Filter("genres[Doujinshi]", "Doujinshi"), - Filter("genres[Drama]", "Drama"), - Filter("genres[Ecchi]", "Ecchi"), - Filter("genres[Fantasy]", "Fantasy"), - Filter("genres[Gender Bender]", "Gender Bender"), - Filter("genres[Harem]", "Harem"), - Filter("genres[Historical]", "Historical"), - Filter("genres[Horror]", "Horror"), - Filter("genres[Josei]", "Josei"), - Filter("genres[Martial Arts]", "Martial Arts"), - Filter("genres[Mature]", "Mature"), - Filter("genres[Mecha]", "Mecha"), - Filter("genres[Mystery]", "Mystery"), - Filter("genres[One Shot]", "One Shot"), - Filter("genres[Psychological]", "Psychological"), - Filter("genres[Romance]", "Romance"), - Filter("genres[School Life]", "School Life"), - Filter("genres[Sci-fi]", "Sci-fi"), - Filter("genres[Seinen]", "Seinen"), - Filter("genres[Shoujo]", "Shoujo"), - Filter("genres[Shoujo Ai]", "Shoujo Ai"), - Filter("genres[Shounen]", "Shounen"), - Filter("genres[Shounen Ai]", "Shounen Ai"), - Filter("genres[Slice of Life]", "Slice of Life"), - Filter("genres[Smut]", "Smut"), - Filter("genres[Sports]", "Sports"), - Filter("genres[Supernatural]", "Supernatural"), - Filter("genres[Tragedy]", "Tragedy"), - Filter("genres[Webtoons]", "Webtoons"), - Filter("genres[Yaoi]", "Yaoi"), - Filter("genres[Yuri]", "Yuri") - ) - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.kt deleted file mode 100644 index b5d8c9989..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.kt +++ /dev/null @@ -1,172 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.english - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.ParseException -import java.text.SimpleDateFormat -import java.util.* - -class Mangahere(override val id: Int) : ParsedOnlineSource() { - - override val name = "Mangahere" - - override val baseUrl = "http://www.mangahere.co" - - override val lang = "en" - - override val supportsLatest = true - - override fun popularMangaInitialUrl() = "$baseUrl/directory/?views.za" - - override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?last_chapter_time.za" - - override fun popularMangaSelector() = "div.directory_list > ul > li" - - override fun latestUpdatesSelector() = "div.directory_list > ul > li" - - private fun mangaFromElement(query: String, element: Element, manga: Manga) { - element.select(query).first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = if (it.hasAttr("title")) it.attr("title") else if (it.hasAttr("rel")) it.attr("rel") else it.text() - } - } - - override fun popularMangaFromElement(element: Element, manga: Manga) { - mangaFromElement("div.title > a", element, manga) - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = "div.next-page > a.next" - - override fun latestUpdatesNextPageSelector() = "div.next-page > a.next" - - override fun searchMangaInitialUrl(query: String, filters: List) = "$baseUrl/search.php?name=$query&page=1&sort=views&order=za&${filters.map { it.id + "=1" }.joinToString("&")}&advopts=1" - - override fun searchMangaSelector() = "div.result_search > dl:has(dt)" - - override fun searchMangaFromElement(element: Element, manga: Manga) { - mangaFromElement("a.manga_info", element, manga) - } - - override fun searchMangaNextPageSelector() = "div.next-page > a.next" - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val detailElement = document.select(".manga_detail_top").first() - val infoElement = detailElement.select(".detail_topText").first() - - manga.author = infoElement.select("a[href^=http://www.mangahere.co/author/]").first()?.text() - manga.artist = infoElement.select("a[href^=http://www.mangahere.co/artist/]").first()?.text() - manga.genre = infoElement.select("li:eq(3)").first()?.text()?.substringAfter("Genre(s):") - manga.description = infoElement.select("#show").first()?.text()?.substringBeforeLast("Show less") - manga.status = infoElement.select("li:eq(6)").first()?.text().orEmpty().let { parseStatus(it) } - manga.thumbnail_url = detailElement.select("img.img").first()?.attr("src") - } - - private fun parseStatus(status: String) = when { - status.contains("Ongoing") -> Manga.ONGOING - status.contains("Completed") -> Manga.COMPLETED - else -> Manga.UNKNOWN - } - - override fun chapterListSelector() = ".detail_list > ul:not([class]) > li" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val parentEl = element.select("span.left").first() - - val urlElement = parentEl.select("a").first() - - var volume = parentEl.select("span.mr6")?.first()?.text()?.trim()?:"" - if (volume.length > 0) { - volume = " - " + volume - } - - var title = parentEl?.textNodes()?.last()?.text()?.trim()?:"" - if (title.length > 0) { - title = " - " + title - } - - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = urlElement.text() + volume + title - chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0 - } - - private fun parseChapterDate(date: String): Long { - return if ("Today" in date) { - Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - }.timeInMillis - } else if ("Yesterday" in date) { - Calendar.getInstance().apply { - add(Calendar.DATE, -1) - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - }.timeInMillis - } else { - try { - SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time - } catch (e: ParseException) { - 0L - } - } - } - - override fun pageListParse(document: Document, pages: MutableList) { - document.select("select.wid60").first()?.getElementsByTag("option")?.forEach { - pages.add(Page(pages.size, it.attr("value"))) - } - pages.getOrNull(0)?.imageUrl = imageUrlParse(document) - } - - override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src") - - // [...document.querySelectorAll("select[id^='genres'")].map((el,i) => `Filter("${el.getAttribute('name')}", "${el.nextSibling.nextSibling.textContent.trim()}")`).join(',\n') - // http://www.mangahere.co/advsearch.htm - override fun getFilterList(): List = listOf( - Filter("is_completed", "Completed"), - Filter("genres[Action]", "Action"), - Filter("genres[Adventure]", "Adventure"), - Filter("genres[Comedy]", "Comedy"), - Filter("genres[Doujinshi]", "Doujinshi"), - Filter("genres[Drama]", "Drama"), - Filter("genres[Ecchi]", "Ecchi"), - Filter("genres[Fantasy]", "Fantasy"), - Filter("genres[Gender Bender]", "Gender Bender"), - Filter("genres[Harem]", "Harem"), - Filter("genres[Historical]", "Historical"), - Filter("genres[Horror]", "Horror"), - Filter("genres[Josei]", "Josei"), - Filter("genres[Martial Arts]", "Martial Arts"), - Filter("genres[Mature]", "Mature"), - Filter("genres[Mecha]", "Mecha"), - Filter("genres[Mystery]", "Mystery"), - Filter("genres[One Shot]", "One Shot"), - Filter("genres[Psychological]", "Psychological"), - Filter("genres[Romance]", "Romance"), - Filter("genres[School Life]", "School Life"), - Filter("genres[Sci-fi]", "Sci-fi"), - Filter("genres[Seinen]", "Seinen"), - Filter("genres[Shoujo]", "Shoujo"), - Filter("genres[Shoujo Ai]", "Shoujo Ai"), - Filter("genres[Shounen]", "Shounen"), - Filter("genres[Shounen Ai]", "Shounen Ai"), - Filter("genres[Slice of Life]", "Slice of Life"), - Filter("genres[Sports]", "Sports"), - Filter("genres[Supernatural]", "Supernatural"), - Filter("genres[Tragedy]", "Tragedy"), - Filter("genres[Yaoi]", "Yaoi"), - Filter("genres[Yuri]", "Yuri") - ) - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangasee.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangasee.kt deleted file mode 100644 index 6e29363bd..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangasee.kt +++ /dev/null @@ -1,261 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.english - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.network.POST -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.FormBody -import okhttp3.HttpUrl -import okhttp3.Request -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.regex.Pattern - -class Mangasee(override val id: Int) : ParsedOnlineSource() { - - override val name = "Mangasee" - - override val baseUrl = "http://mangaseeonline.net" - - override val lang = "en" - - override val supportsLatest = true - - private val recentUpdatesPattern = Pattern.compile("(.*?)\\s(\\d+\\.?\\d*)\\s?(Completed)?") - - private val indexPattern = Pattern.compile("-index-(.*?)-") - - override fun popularMangaInitialUrl() = "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending" - - override fun popularMangaSelector() = "div.requested > div.row" - - override fun popularMangaRequest(page: MangasPage): Request { - if (page.page == 1) { - page.url = popularMangaInitialUrl() - } - val (body, requestUrl) = convertQueryToPost(page) - return POST(requestUrl, headers, body.build()) - } - - override fun popularMangaParse(response: Response, page: MangasPage) { - val document = response.asJsoup() - for (element in document.select(popularMangaSelector())) { - Manga.create(id).apply { - popularMangaFromElement(element, this) - page.mangas.add(this) - } - } - - page.nextPageUrl = page.url - } - - override fun popularMangaFromElement(element: Element, manga: Manga) { - element.select("a.resultLink").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text() - } - } - - // Not used, overrides parent. - override fun popularMangaNextPageSelector() = "" - - override fun searchMangaInitialUrl(query: String, filters: List): String { - var url = "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending&keyword=$query" - var genres: String? = null - for (filter in filters) { - if (filter.equals(completedFilter)) url += "&status=Complete" - else if (genres == null) genres = filter.id - else genres += "," + filter.id - } - return if (genres == null) url else url + "&genre=$genres" - } - - override fun searchMangaSelector() = "div.searchResults > div.requested > div.row" - - override fun searchMangaRequest(page: MangasPage, query: String, filters: List): Request { - if (page.page == 1) { - page.url = searchMangaInitialUrl(query, filters) - } - val (body, requestUrl) = convertQueryToPost(page) - return POST(requestUrl, headers, body.build()) - } - - private fun convertQueryToPost(page: MangasPage): Pair { - val url = HttpUrl.parse(page.url) - val body = FormBody.Builder().add("page", page.page.toString()) - for (i in 0..url.querySize() - 1) { - body.add(url.queryParameterName(i), url.queryParameterValue(i)) - } - val requestUrl = url.scheme() + "://" + url.host() + url.encodedPath() - return Pair(body, requestUrl) - } - - override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List) { - val document = response.asJsoup() - for (element in document.select(popularMangaSelector())) { - Manga.create(id).apply { - popularMangaFromElement(element, this) - page.mangas.add(this) - } - } - - page.nextPageUrl = page.url - } - - override fun searchMangaFromElement(element: Element, manga: Manga) { - element.select("a.resultLink").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text() - } - } - - // Not used, overrides parent. - override fun searchMangaNextPageSelector() = "" - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val detailElement = document.select("div.well > div.row").first() - - manga.author = detailElement.select("a[href^=/search/?author=]").first()?.text() - manga.genre = detailElement.select("span.details > div.row > div:has(b:contains(Genre(s))) > a").map { it.text() }.joinToString() - manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text() - manga.status = detailElement.select("a[href^=/search/?status=]").first()?.text().orEmpty().let { parseStatus(it) } - manga.thumbnail_url = detailElement.select("div > img").first()?.absUrl("src") - } - - private fun parseStatus(status: String) = when { - status.contains("Ongoing (Scan)") -> Manga.ONGOING - status.contains("Complete (Scan)") -> Manga.COMPLETED - else -> Manga.UNKNOWN - } - - override fun chapterListSelector() = "div.chapter-list > a" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select("a").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = element.select("span.chapterLabel").first().text()?.let { it } ?: "" - chapter.date_upload = element.select("time").first()?.attr("datetime")?.let { parseChapterDate(it) } ?: 0 - } - - private fun parseChapterDate(dateAsString: String): Long { - return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(dateAsString).time - } - - override fun pageListParse(response: Response, pages: MutableList) { - val document = response.asJsoup() - val fullUrl = response.request().url().toString() - val url = fullUrl.substringBeforeLast('/') - - val series = document.select("input.IndexName").first().attr("value") - val chapter = document.select("span.CurChapter").first().text() - var index = "" - - val m = indexPattern.matcher(fullUrl) - if (m.find()) { - val indexNumber = m.group(1) - index = "-index-$indexNumber" - } - - document.select("div.ContainerNav").first().select("select.PageSelect > option").forEach { - pages.add(Page(pages.size, "$url/$series-chapter-$chapter$index-page-${pages.size + 1}.html")) - } - pages.getOrNull(0)?.imageUrl = imageUrlParse(document) - } - - // Not used, overrides parent. - override fun pageListParse(document: Document, pages: MutableList) { - } - - override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src") - - private val completedFilter = Filter("Complete", "Completed") - // [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n') - // http://mangasee.co/advanced-search/ - override fun getFilterList(): List = listOf( - completedFilter, - Filter("Action", "Action"), - Filter("Adult", "Adult"), - Filter("Adventure", "Adventure"), - Filter("Comedy", "Comedy"), - Filter("Doujinshi", "Doujinshi"), - Filter("Drama", "Drama"), - Filter("Ecchi", "Ecchi"), - Filter("Fantasy", "Fantasy"), - Filter("Gender_Bender", "Gender Bender"), - Filter("Harem", "Harem"), - Filter("Hentai", "Hentai"), - Filter("Historical", "Historical"), - Filter("Horror", "Horror"), - Filter("Josei", "Josei"), - Filter("Lolicon", "Lolicon"), - Filter("Martial_Arts", "Martial Arts"), - Filter("Mature", "Mature"), - Filter("Mecha", "Mecha"), - Filter("Mystery", "Mystery"), - Filter("Psychological", "Psychological"), - Filter("Romance", "Romance"), - Filter("School_Life", "School Life"), - Filter("Sci-fi", "Sci-fi"), - Filter("Seinen", "Seinen"), - Filter("Shotacon", "Shotacon"), - Filter("Shoujo", "Shoujo"), - Filter("Shoujo_Ai", "Shoujo Ai"), - Filter("Shounen", "Shounen"), - Filter("Shounen_Ai", "Shounen Ai"), - Filter("Slice_of_Life", "Slice of Life"), - Filter("Smut", "Smut"), - Filter("Sports", "Sports"), - Filter("Supernatural", "Supernatural"), - Filter("Tragedy", "Tragedy"), - Filter("Yaoi", "Yaoi"), - Filter("Yuri", "Yuri") - ) - - override fun latestUpdatesInitialUrl(): String = "http://mangaseeonline.net/home/latest.request.php" - - // Not used, overrides parent. - override fun latestUpdatesNextPageSelector(): String = "" - - override fun latestUpdatesSelector(): String = "a.latestSeries" - - override fun latestUpdatesRequest(page: MangasPage): Request { - if (page.page == 1) { - page.url = latestUpdatesInitialUrl() - } - val (body, requestUrl) = convertQueryToPost(page) - return POST(requestUrl, headers, body.build()) - } - - override fun latestUpdatesParse(response: Response, page: MangasPage) { - val document = response.asJsoup() - for (element in document.select(latestUpdatesSelector())) { - Manga.create(id).apply { - latestUpdatesFromElement(element, this) - page.mangas.add(this) - } - } - - page.nextPageUrl = page.url - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - element.select("a.latestSeries").first().let { - val chapterUrl = it.attr("href") - val indexOfMangaUrl = chapterUrl.indexOf("-chapter-") - val indexOfLastPath = chapterUrl.lastIndexOf("/") - val mangaUrl = chapterUrl.substring(indexOfLastPath, indexOfMangaUrl) - val defaultText = it.select("p.clamp2").text() - val m = recentUpdatesPattern.matcher(defaultText) - val title = if (m.matches()) m.group(1) else defaultText - manga.setUrlWithoutDomain("/manga" + mangaUrl) - manga.title = title - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Readmangatoday.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Readmangatoday.kt deleted file mode 100644 index 5422c7ddb..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Readmangatoday.kt +++ /dev/null @@ -1,197 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.english - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.network.POST -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.OnlineSource -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import okhttp3.Headers -import okhttp3.OkHttpClient -import okhttp3.Request -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.util.* - -class Readmangatoday(override val id: Int) : ParsedOnlineSource() { - - override val name = "ReadMangaToday" - - override val baseUrl = "http://www.readmanga.today" - - override val lang = "en" - - override val supportsLatest = true - - override val client: OkHttpClient get() = network.cloudflareClient - - /** - * Search only returns data with this set - */ - override fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") - add("X-Requested-With", "XMLHttpRequest") - } - - override fun popularMangaInitialUrl() = "$baseUrl/hot-manga/" - - override fun latestUpdatesInitialUrl() = "$baseUrl/latest-releases/" - - override fun popularMangaSelector() = "div.hot-manga > div.style-list > div.box" - - override fun latestUpdatesSelector() = "div.hot-manga > div.style-grid > div.box" - - override fun popularMangaFromElement(element: Element, manga: Manga) { - element.select("div.title > h2 > a").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.attr("title") - } - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)" - - override fun latestUpdatesNextPageSelector(): String = "div.hot-manga > ul.pagination > li > a:contains(»)" - - override fun searchMangaInitialUrl(query: String, filters: List) = - "$baseUrl/service/advanced_search" - - - override fun searchMangaRequest(page: MangasPage, query: String, filters: List): Request { - if (page.page == 1) { - page.url = searchMangaInitialUrl(query, filters) - } - - val builder = okhttp3.FormBody.Builder() - builder.add("manga-name", query) - builder.add("type", "all") - var status = "both" - for (filter in filters) { - if (filter.equals(completedFilter)) status = filter.id - else builder.add("include[]", filter.id) - } - builder.add("status", status) - - return POST(page.url, headers, builder.build()) - } - - override fun searchMangaSelector() = "div.style-list > div.box" - - override fun searchMangaFromElement(element: Element, manga: Manga) { - element.select("div.title > h2 > a").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.attr("title") - } - } - - override fun searchMangaNextPageSelector() = "div.next-page > a.next" - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val detailElement = document.select("div.movie-meta").first() - - manga.author = document.select("ul.cast-list li.director > ul a").first()?.text() - manga.artist = document.select("ul.cast-list li:not(.director) > ul a").first()?.text() - manga.genre = detailElement.select("dl.dl-horizontal > dd:eq(5)").first()?.text() - manga.description = detailElement.select("li.movie-detail").first()?.text() - manga.status = detailElement.select("dl.dl-horizontal > dd:eq(3)").first()?.text().orEmpty().let { parseStatus(it) } - manga.thumbnail_url = detailElement.select("img.img-responsive").first()?.attr("src") - } - - private fun parseStatus(status: String) = when { - status.contains("Ongoing") -> Manga.ONGOING - status.contains("Completed") -> Manga.COMPLETED - else -> Manga.UNKNOWN - } - - override fun chapterListSelector() = "ul.chp_lst > li" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select("a").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = urlElement.select("span.val").text() - chapter.date_upload = element.select("span.dte").first()?.text()?.let { parseChapterDate(it) } ?: 0 - } - - private fun parseChapterDate(date: String): Long { - val dateWords : List = date.split(" ") - - if (dateWords.size == 3) { - val timeAgo = Integer.parseInt(dateWords[0]) - var date : Calendar = Calendar.getInstance() - - if (dateWords[1].contains("Minute")) { - date.add(Calendar.MINUTE, - timeAgo) - } else if (dateWords[1].contains("Hour")) { - date.add(Calendar.HOUR_OF_DAY, - timeAgo) - } else if (dateWords[1].contains("Day")) { - date.add(Calendar.DAY_OF_YEAR, -timeAgo) - } else if (dateWords[1].contains("Week")) { - date.add(Calendar.WEEK_OF_YEAR, -timeAgo) - } else if (dateWords[1].contains("Month")) { - date.add(Calendar.MONTH, -timeAgo) - } else if (dateWords[1].contains("Year")) { - date.add(Calendar.YEAR, -timeAgo) - } - - return date.getTimeInMillis() - } - - return 0L - } - - override fun pageListParse(document: Document, pages: MutableList) { - document.select("ul.list-switcher-2 > li > select.jump-menu").first().getElementsByTag("option").forEach { - pages.add(Page(pages.size, it.attr("value"))) - } - pages.getOrNull(0)?.imageUrl = imageUrlParse(document) - } - - override fun imageUrlParse(document: Document) = document.select("img.img-responsive-2").first().attr("src") - - private val completedFilter = Filter("completed", "Completed") - // [...document.querySelectorAll("ul.manga-cat span")].map(el => `Filter("${el.getAttribute('data-id')}", "${el.nextSibling.textContent.trim()}")`).join(',\n') - // http://www.readmanga.today/advanced-search - override fun getFilterList(): List = listOf( - completedFilter, - Filter("2", "Action"), - Filter("4", "Adventure"), - Filter("5", "Comedy"), - Filter("6", "Doujinshi"), - Filter("7", "Drama"), - Filter("8", "Ecchi"), - Filter("9", "Fantasy"), - Filter("10", "Gender Bender"), - Filter("11", "Harem"), - Filter("12", "Historical"), - Filter("13", "Horror"), - Filter("14", "Josei"), - Filter("15", "Lolicon"), - Filter("16", "Martial Arts"), - Filter("17", "Mature"), - Filter("18", "Mecha"), - Filter("19", "Mystery"), - Filter("20", "One shot"), - Filter("21", "Psychological"), - Filter("22", "Romance"), - Filter("23", "School Life"), - Filter("24", "Sci-fi"), - Filter("25", "Seinen"), - Filter("26", "Shotacon"), - Filter("27", "Shoujo"), - Filter("28", "Shoujo Ai"), - Filter("29", "Shounen"), - Filter("30", "Shounen Ai"), - Filter("31", "Slice of Life"), - Filter("32", "Smut"), - Filter("33", "Sports"), - Filter("34", "Supernatural"), - Filter("35", "Tragedy"), - Filter("36", "Yaoi"), - Filter("37", "Yuri") - ) -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mangachan.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mangachan.kt deleted file mode 100644 index 9075fa6f8..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mangachan.kt +++ /dev/null @@ -1,207 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.russian - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.* - -class Mangachan(override val id: Int) : ParsedOnlineSource() { - - override val name = "Mangachan" - - override val baseUrl = "http://mangachan.me" - - override val lang = "ru" - - override val supportsLatest = true - - override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites" - - override fun latestUpdatesInitialUrl() = "$baseUrl/newestch" - - override fun searchMangaInitialUrl(query: String, filters: List): String { - if (query.isNotEmpty()) { - return "$baseUrl/?do=search&subaction=search&story=$query" - } else if (filters.isNotEmpty()) { - var genres = "" - filters.forEach { genres = genres + it.name + '+' } - return "$baseUrl/tags/${genres.dropLast(1)}" - } else { - return "$baseUrl/?do=search&subaction=search&story=$query" - } - } - - override fun popularMangaSelector() = "div.content_row" - - override fun latestUpdatesSelector() = "ul.area_rightNews li" - - override fun searchMangaSelector() = popularMangaSelector() - - override fun popularMangaFromElement(element: Element, manga: Manga) { - element.select("h2 > a").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text() - } - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - element.select("a:nth-child(1)").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text() - } - } - - override fun searchMangaFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = "a:contains(Вперед)" - - override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() - - override fun searchMangaNextPageSelector() = "a:contains(Далее)" - - private fun searchGenresNextPageSelector() = popularMangaNextPageSelector() - - override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List) { - val document = response.asJsoup() - for (element in document.select(searchMangaSelector())) { - Manga.create(id).apply { - searchMangaFromElement(element, this) - page.mangas.add(this) - } - } - - searchMangaNextPageSelector().let { selector -> - if (page.nextPageUrl.isNullOrEmpty() && filters.isEmpty()) { - val onClick = document.select(selector).first()?.attr("onclick") - val pageNum = onClick?.substring(23, onClick.indexOf("); return(false)")) - page.nextPageUrl = searchMangaInitialUrl(query, emptyList()) + "&search_start=" + pageNum - } - } - - searchGenresNextPageSelector().let { selector -> - if (page.nextPageUrl.isNullOrEmpty() && filters.isNotEmpty()) { - val url = document.select(selector).first()?.attr("href") - page.nextPageUrl = searchMangaInitialUrl(query, filters) + url - } - } - } - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val infoElement = document.select("table.mangatitle").first() - val descElement = document.select("div#description").first() - val imgElement = document.select("img#cover").first() - - manga.author = infoElement.select("tr:eq(2) > td:eq(1)").text() - manga.genre = infoElement.select("tr:eq(5) > td:eq(1)").text() - manga.status = parseStatus(infoElement.select("tr:eq(4) > td:eq(1)").text()) - manga.description = descElement.textNodes().first().text() - manga.thumbnail_url = baseUrl + imgElement.attr("src") - } - - private fun parseStatus(element: String): Int { - when { - element.contains("перевод завершен") -> return Manga.COMPLETED - element.contains("перевод продолжается") -> return Manga.ONGOING - else -> return Manga.UNKNOWN - } - } - - override fun chapterListSelector() = "table.table_cha tr:gt(1)" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select("a").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = urlElement.text() - chapter.date_upload = element.select("div.date").first()?.text()?.let { - SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(it).time - } ?: 0 - } - - override fun pageListParse(response: Response, pages: MutableList) { - val html = response.body().string() - val beginIndex = html.indexOf("fullimg\":[") + 10 - val endIndex = html.indexOf(",]", beginIndex) - val trimmedHtml = html.substring(beginIndex, endIndex).replace("\"", "") - val pageUrls = trimmedHtml.split(',') - - pageUrls.mapIndexedTo(pages) { i, url -> Page(i, "", url) } - } - - override fun pageListParse(document: Document, pages: MutableList) { } - - override fun imageUrlParse(document: Document) = "" - - /* [...document.querySelectorAll("li.sidetag > a:nth-child(1)")].map((el,i) => - * { const link=el.getAttribute('href');const id=link.substr(6,link.length); - * return `Filter("${id}", "${id}")` }).join(',\n') - * on http://mangachan.me/ - */ - override fun getFilterList(): List = listOf( - Filter("18_плюс", "18_плюс"), - Filter("bdsm", "bdsm"), - Filter("арт", "арт"), - Filter("биография", "биография"), - Filter("боевик", "боевик"), - Filter("боевые_искусства", "боевые_искусства"), - Filter("вампиры", "вампиры"), - Filter("веб", "веб"), - Filter("гарем", "гарем"), - Filter("гендерная_интрига", "гендерная_интрига"), - Filter("героическое_фэнтези", "героическое_фэнтези"), - Filter("детектив", "детектив"), - Filter("дзёсэй", "дзёсэй"), - Filter("додзинси", "додзинси"), - Filter("драма", "драма"), - Filter("игра", "игра"), - Filter("инцест", "инцест"), - Filter("искусство", "искусство"), - Filter("история", "история"), - Filter("киберпанк", "киберпанк"), - Filter("кодомо", "кодомо"), - Filter("комедия", "комедия"), - Filter("литРПГ", "литРПГ"), - Filter("махо-сёдзё", "махо-сёдзё"), - Filter("меха", "меха"), - Filter("мистика", "мистика"), - Filter("музыка", "музыка"), - Filter("научная_фантастика", "научная_фантастика"), - Filter("повседневность", "повседневность"), - Filter("постапокалиптика", "постапокалиптика"), - Filter("приключения", "приключения"), - Filter("психология", "психология"), - Filter("романтика", "романтика"), - Filter("самурайский_боевик", "самурайский_боевик"), - Filter("сборник", "сборник"), - Filter("сверхъестественное", "сверхъестественное"), - Filter("сказка", "сказка"), - Filter("спорт", "спорт"), - Filter("супергерои", "супергерои"), - Filter("сэйнэн", "сэйнэн"), - Filter("сёдзё", "сёдзё"), - Filter("сёдзё-ай", "сёдзё-ай"), - Filter("сёнэн", "сёнэн"), - Filter("сёнэн-ай", "сёнэн-ай"), - Filter("тентакли", "тентакли"), - Filter("трагедия", "трагедия"), - Filter("триллер", "триллер"), - Filter("ужасы", "ужасы"), - Filter("фантастика", "фантастика"), - Filter("фурри", "фурри"), - Filter("фэнтези", "фэнтези"), - Filter("школа", "школа"), - Filter("эротика", "эротика"), - Filter("юри", "юри"), - Filter("яой", "яой"), - Filter("ёнкома", "ёнкома") - ) -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mintmanga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mintmanga.kt deleted file mode 100644 index 2e1755676..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mintmanga.kt +++ /dev/null @@ -1,163 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.russian - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.* -import java.util.regex.Pattern - -class Mintmanga(override val id: Int) : ParsedOnlineSource() { - - override val name = "Mintmanga" - - override val baseUrl = "http://mintmanga.com" - - override val lang = "ru" - - override val supportsLatest = true - - override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate" - - override fun latestUpdatesInitialUrl() = "$baseUrl/list?sortType=updated" - - override fun searchMangaInitialUrl(query: String, filters: List) = - "$baseUrl/search?q=$query&${filters.map { it.id + "=in" }.joinToString("&")}" - - override fun popularMangaSelector() = "div.desc" - - override fun latestUpdatesSelector() = "div.desc" - - override fun popularMangaFromElement(element: Element, manga: Manga) { - element.select("h3 > a").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.attr("title") - } - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = "a.nextLink" - - override fun latestUpdatesNextPageSelector() = "a.nextLink" - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - // max 200 results - override fun searchMangaNextPageSelector() = null - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val infoElement = document.select("div.leftContent").first() - - manga.author = infoElement.select("span.elem_author").first()?.text() - manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",") - manga.description = infoElement.select("div.manga-description").text() - manga.status = parseStatus(infoElement.html()) - manga.thumbnail_url = infoElement.select("img").attr("data-full") - } - - private fun parseStatus(element: String): Int { - when { - element.contains("

Запрещена публикация произведения по копирайту

") -> return Manga.LICENSED - element.contains("

Сингл") || element.contains("Перевод: завершен") -> return Manga.COMPLETED - element.contains("Перевод: продолжается") -> return Manga.ONGOING - else -> return Manga.UNKNOWN - } - } - - override fun chapterListSelector() = "div.chapters-link tbody tr" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select("a").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mature=1") - chapter.name = urlElement.text().replace(" новое", "") - chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let { - SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time - } ?: 0 - } - - override fun prepareNewChapter(chapter: Chapter, manga: Manga) { - chapter.chapter_number = -2f - } - - override fun pageListParse(response: Response, pages: MutableList) { - val html = response.body().string() - val beginIndex = html.indexOf("rm_h.init( [") - val endIndex = html.indexOf("], 0, false);", beginIndex) - val trimmedHtml = html.substring(beginIndex, endIndex) - - val p = Pattern.compile("'.+?','.+?',\".+?\"") - val m = p.matcher(trimmedHtml) - - var i = 0 - while (m.find()) { - val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',') - pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2])) - } - } - - override fun pageListParse(document: Document, pages: MutableList) { } - - override fun imageUrlParse(document: Document) = "" - - /* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => { - * const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33); - * return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n') - * on http://mintmanga.com/search - */ - override fun getFilterList(): List = listOf( - Filter("el_2220", "арт"), - Filter("el_1353", "бара"), - Filter("el_1346", "боевик"), - Filter("el_1334", "боевые искусства"), - Filter("el_1339", "вампиры"), - Filter("el_1333", "гарем"), - Filter("el_1347", "гендерная интрига"), - Filter("el_1337", "героическое фэнтези"), - Filter("el_1343", "детектив"), - Filter("el_1349", "дзёсэй"), - Filter("el_1332", "додзинси"), - Filter("el_1310", "драма"), - Filter("el_5229", "игра"), - Filter("el_1311", "история"), - Filter("el_1351", "киберпанк"), - Filter("el_1328", "комедия"), - Filter("el_1318", "меха"), - Filter("el_1324", "мистика"), - Filter("el_1325", "научная фантастика"), - Filter("el_1327", "повседневность"), - Filter("el_1342", "постапокалиптика"), - Filter("el_1322", "приключения"), - Filter("el_1335", "психология"), - Filter("el_1313", "романтика"), - Filter("el_1316", "самурайский боевик"), - Filter("el_1350", "сверхъестественное"), - Filter("el_1314", "сёдзё"), - Filter("el_1320", "сёдзё-ай"), - Filter("el_1326", "сёнэн"), - Filter("el_1330", "сёнэн-ай"), - Filter("el_1321", "спорт"), - Filter("el_1329", "сэйнэн"), - Filter("el_1344", "трагедия"), - Filter("el_1341", "триллер"), - Filter("el_1317", "ужасы"), - Filter("el_1331", "фантастика"), - Filter("el_1323", "фэнтези"), - Filter("el_1319", "школа"), - Filter("el_1340", "эротика"), - Filter("el_1354", "этти"), - Filter("el_1315", "юри"), - Filter("el_1336", "яой") - ) -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Readmanga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Readmanga.kt deleted file mode 100644 index f3005a502..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Readmanga.kt +++ /dev/null @@ -1,162 +0,0 @@ -package eu.kanade.tachiyomi.data.source.online.russian - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.* -import java.util.regex.Pattern - -class Readmanga(override val id: Int) : ParsedOnlineSource() { - - override val name = "Readmanga" - - override val baseUrl = "http://readmanga.me" - - override val lang = "ru" - - override val supportsLatest = true - - override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate" - - override fun latestUpdatesInitialUrl() = "$baseUrl/list?sortType=updated" - - override fun searchMangaInitialUrl(query: String, filters: List) = - "$baseUrl/search?q=$query&${filters.map { it.id + "=in" }.joinToString("&")}" - - override fun popularMangaSelector() = "div.desc" - - override fun latestUpdatesSelector() = "div.desc" - - override fun popularMangaFromElement(element: Element, manga: Manga) { - element.select("h3 > a").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.attr("title") - } - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = "a.nextLink" - - override fun latestUpdatesNextPageSelector() = "a.nextLink" - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - // max 200 results - override fun searchMangaNextPageSelector() = null - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val infoElement = document.select("div.leftContent").first() - - manga.author = infoElement.select("span.elem_author").first()?.text() - manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",") - manga.description = infoElement.select("div.manga-description").text() - manga.status = parseStatus(infoElement.html()) - manga.thumbnail_url = infoElement.select("img").attr("data-full") - } - - private fun parseStatus(element: String): Int { - when { - element.contains("

Запрещена публикация произведения по копирайту

") -> return Manga.LICENSED - element.contains("

Сингл") || element.contains("Перевод: завершен") -> return Manga.COMPLETED - element.contains("Перевод: продолжается") -> return Manga.ONGOING - else -> return Manga.UNKNOWN - } - } - - override fun chapterListSelector() = "div.chapters-link tbody tr" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select("a").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mature=1") - chapter.name = urlElement.text().replace(" новое", "") - chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let { - SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time - } ?: 0 - } - - override fun prepareNewChapter(chapter: Chapter, manga: Manga) { - chapter.chapter_number = -2f - } - - override fun pageListParse(response: Response, pages: MutableList) { - val html = response.body().string() - val beginIndex = html.indexOf("rm_h.init( [") - val endIndex = html.indexOf("], 0, false);", beginIndex) - val trimmedHtml = html.substring(beginIndex, endIndex) - - val p = Pattern.compile("'.+?','.+?',\".+?\"") - val m = p.matcher(trimmedHtml) - - var i = 0 - while (m.find()) { - val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',') - pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2])) - } - } - - override fun pageListParse(document: Document, pages: MutableList) { } - - override fun imageUrlParse(document: Document) = "" - - /* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => { - * const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33); - * return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n') - * on http://readmanga.me/search - */ - override fun getFilterList(): List = listOf( - Filter("el_5685", "арт"), - Filter("el_2155", "боевик"), - Filter("el_2143", "боевые искусства"), - Filter("el_2148", "вампиры"), - Filter("el_2142", "гарем"), - Filter("el_2156", "гендерная интрига"), - Filter("el_2146", "героическое фэнтези"), - Filter("el_2152", "детектив"), - Filter("el_2158", "дзёсэй"), - Filter("el_2141", "додзинси"), - Filter("el_2118", "драма"), - Filter("el_2154", "игра"), - Filter("el_2119", "история"), - Filter("el_8032", "киберпанк"), - Filter("el_2137", "кодомо"), - Filter("el_2136", "комедия"), - Filter("el_2147", "махо-сёдзё"), - Filter("el_2126", "меха"), - Filter("el_2132", "мистика"), - Filter("el_2133", "научная фантастика"), - Filter("el_2135", "повседневность"), - Filter("el_2151", "постапокалиптика"), - Filter("el_2130", "приключения"), - Filter("el_2144", "психология"), - Filter("el_2121", "романтика"), - Filter("el_2124", "самурайский боевик"), - Filter("el_2159", "сверхъестественное"), - Filter("el_2122", "сёдзё"), - Filter("el_2128", "сёдзё-ай"), - Filter("el_2134", "сёнэн"), - Filter("el_2139", "сёнэн-ай"), - Filter("el_2129", "спорт"), - Filter("el_2138", "сэйнэн"), - Filter("el_2153", "трагедия"), - Filter("el_2150", "триллер"), - Filter("el_2125", "ужасы"), - Filter("el_2140", "фантастика"), - Filter("el_2131", "фэнтези"), - Filter("el_2127", "школа"), - Filter("el_2149", "этти"), - Filter("el_2123", "юри") - ) -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index 39b5db83f..1b91e812a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.data.track import android.support.annotation.CallSuper import android.support.annotation.DrawableRes import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.network.NetworkHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.network.NetworkHelper import okhttp3.OkHttpClient import rx.Completable import rx.Observable diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackUpdateService.kt deleted file mode 100644 index 06609cfc6..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackUpdateService.kt +++ /dev/null @@ -1,74 +0,0 @@ -package eu.kanade.tachiyomi.data.track - -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.IBinder -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Track -import rx.Observable -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import rx.subscriptions.CompositeSubscription -import uy.kohesive.injekt.injectLazy - -class TrackUpdateService : Service() { - - val trackManager: TrackManager by injectLazy() - val db: DatabaseHelper by injectLazy() - - private lateinit var subscriptions: CompositeSubscription - - override fun onCreate() { - super.onCreate() - subscriptions = CompositeSubscription() - } - - override fun onDestroy() { - subscriptions.unsubscribe() - super.onDestroy() - } - - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - val track = intent.getSerializableExtra(EXTRA_TRACK) - if (track != null) { - updateLastChapterRead(track as Track, startId) - return Service.START_REDELIVER_INTENT - } else { - stopSelf(startId) - return Service.START_NOT_STICKY - } - } - - override fun onBind(intent: Intent): IBinder? { - return null - } - - private fun updateLastChapterRead(track: Track, startId: Int) { - val sync = trackManager.getService(track.sync_id) - if (sync == null) { - stopSelf(startId) - return - } - - subscriptions.add(Observable.defer { sync.update(track) } - .flatMap { db.insertTrack(track).asRxObservable() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ stopSelf(startId) }, - { stopSelf(startId) })) - } - - companion object { - - private val EXTRA_TRACK = "extra_track" - - @JvmStatic - fun start(context: Context, track: Track) { - val intent = Intent(context, TrackUpdateService::class.java) - intent.putExtra(EXTRA_TRACK, track) - context.startService(intent) - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index 82ec5fa4a..1e1e620cb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -5,7 +5,7 @@ import com.github.salomonbrys.kotson.int import com.github.salomonbrys.kotson.string import com.google.gson.JsonObject import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.network.POST +import eu.kanade.tachiyomi.network.POST import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.ResponseBody diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index 12d6de5b4..2ddca50b6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.kitsu import com.github.salomonbrys.kotson.* import com.google.gson.JsonObject import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.network.POST +import eu.kanade.tachiyomi.network.POST import okhttp3.FormBody import okhttp3.OkHttpClient import retrofit2.Retrofit @@ -90,7 +90,8 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) .map { json -> val data = json["data"].array if (data.size() > 0) { - KitsuLibManga(data[0].obj, json["included"].array[0].obj).toTrack() + val manga = json["included"].array[0].obj + KitsuLibManga(data[0].obj, manga).toTrack() } else { null } @@ -102,8 +103,8 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) .map { json -> val data = json["data"].array if (data.size() > 0) { - val include = json["included"].array[0].obj - KitsuLibManga(data[0].obj, include).toTrack() + val manga = json["included"].array[0].obj + KitsuLibManga(data[0].obj, manga).toTrack() } else { throw Exception("Could not find manga") } @@ -150,13 +151,13 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) @Query("filter[media_id]", encoded = true) remoteId: Int, @Query("filter[user_id]", encoded = true) userId: String, @Query("page[limit]", encoded = true) limit: Int = 10000, - @Query("include") includes: String = "media" + @Query("include") includes: String = "manga" ): Observable @GET("library-entries") fun getLibManga( @Query("filter[id]", encoded = true) remoteId: Int, - @Query("include") includes: String = "media" + @Query("include") includes: String = "manga" ): Observable @GET("users") diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt index d40985ca0..973b2f26e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt @@ -3,11 +3,11 @@ package eu.kanade.tachiyomi.data.track.myanimelist import android.net.Uri import android.util.Xml import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.network.GET -import eu.kanade.tachiyomi.data.network.POST -import eu.kanade.tachiyomi.data.network.asObservable -import eu.kanade.tachiyomi.data.network.asObservableSuccess import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectText import okhttp3.* diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateCheckerJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateCheckerJob.kt index 7f444ea69..e3dcb8b84 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateCheckerJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateCheckerJob.kt @@ -1,5 +1,7 @@ package eu.kanade.tachiyomi.data.updater +import android.app.PendingIntent +import android.content.Intent import android.support.v4.app.NotificationCompat import com.evernote.android.job.Job import com.evernote.android.job.JobManager @@ -17,6 +19,10 @@ class UpdateCheckerJob : Job() { if (result is GithubUpdateResult.NewUpdate) { val url = result.release.downloadLink + val intent = Intent(context, UpdateDownloaderService::class.java).apply { + putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url) + } + NotificationCompat.Builder(context).update { setContentTitle(context.getString(R.string.app_name)) setContentText(context.getString(R.string.update_check_notification_update_available)) @@ -24,7 +30,7 @@ class UpdateCheckerJob : Job() { // Download action addAction(android.R.drawable.stat_sys_download_done, context.getString(R.string.action_download), - UpdateNotificationReceiver.downloadApkIntent(context, url)) + PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) } } Job.Result.SUCCESS diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateDownloaderReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateDownloaderReceiver.kt new file mode 100644 index 000000000..5174c204f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateDownloaderReceiver.kt @@ -0,0 +1,144 @@ +package eu.kanade.tachiyomi.data.updater + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.support.v4.app.NotificationCompat +import eu.kanade.tachiyomi.Constants +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.NotificationHandler +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.util.notificationManager +import java.io.File +import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID + +/** + * Local [BroadcastReceiver] that runs on UI thread + * Notification calls from [UpdateDownloaderService] should be made from here. + */ +internal class UpdateDownloaderReceiver(val context: Context) : BroadcastReceiver() { + + companion object { + private const val NAME = "UpdateDownloaderReceiver" + + // Called to show initial notification. + internal const val NOTIFICATION_UPDATER_INITIAL = "$ID.$NAME.UPDATER_INITIAL" + + // Called to show progress notification. + internal const val NOTIFICATION_UPDATER_PROGRESS = "$ID.$NAME.UPDATER_PROGRESS" + + // Called to show install notification. + internal const val NOTIFICATION_UPDATER_INSTALL = "$ID.$NAME.UPDATER_INSTALL" + + // Called to show error notification + internal const val NOTIFICATION_UPDATER_ERROR = "$ID.$NAME.UPDATER_ERROR" + + // Value containing action of BroadcastReceiver + internal const val EXTRA_ACTION = "$ID.$NAME.ACTION" + + // Value containing progress + internal const val EXTRA_PROGRESS = "$ID.$NAME.PROGRESS" + + // Value containing apk path + internal const val EXTRA_APK_PATH = "$ID.$NAME.APK_PATH" + + // Value containing apk url + internal const val EXTRA_APK_URL = "$ID.$NAME.APK_URL" + } + + /** + * Notification shown to user + */ + private val notification = NotificationCompat.Builder(context) + + override fun onReceive(context: Context, intent: Intent) { + when (intent.getStringExtra(EXTRA_ACTION)) { + NOTIFICATION_UPDATER_INITIAL -> basicNotification() + NOTIFICATION_UPDATER_PROGRESS -> updateProgress(intent.getIntExtra(EXTRA_PROGRESS, 0)) + NOTIFICATION_UPDATER_INSTALL -> installNotification(intent.getStringExtra(EXTRA_APK_PATH)) + NOTIFICATION_UPDATER_ERROR -> errorNotification(intent.getStringExtra(EXTRA_APK_URL)) + } + } + + /** + * Called to show basic notification + */ + private fun basicNotification() { + // Create notification + with(notification) { + setContentTitle(context.getString(R.string.app_name)) + setContentText(context.getString(R.string.update_check_notification_download_in_progress)) + setSmallIcon(android.R.drawable.stat_sys_download) + setOngoing(true) + } + notification.show() + } + + /** + * Called to show progress notification + * + * @param progress progress of download + */ + private fun updateProgress(progress: Int) { + with(notification) { + setProgress(100, progress, false) + } + notification.show() + } + + /** + * Called to show install notification + * + * @param path path of file + */ + private fun installNotification(path: String) { + // Prompt the user to install the new update. + with(notification) { + setContentText(context.getString(R.string.update_check_notification_download_complete)) + setSmallIcon(android.R.drawable.stat_sys_download_done) + setProgress(0, 0, false) + // Install action + setContentIntent(NotificationHandler.installApkPendingActivity(context, File(path))) + addAction(R.drawable.ic_system_update_grey_24dp_img, + context.getString(R.string.action_install), + NotificationHandler.installApkPendingActivity(context, File(path))) + // Cancel action + addAction(R.drawable.ic_clear_grey_24dp_img, + context.getString(R.string.action_cancel), + NotificationReceiver.dismissNotificationPendingBroadcast(context, Constants.NOTIFICATION_UPDATER_ID)) + } + notification.show() + } + + /** + * Called to show error notification + * + * @param url url of apk + */ + private fun errorNotification(url: String) { + // Prompt the user to retry the download. + with(notification) { + setContentText(context.getString(R.string.update_check_notification_download_error)) + setSmallIcon(android.R.drawable.stat_sys_warning) + setProgress(0, 0, false) + // Retry action + addAction(R.drawable.ic_refresh_grey_24dp_img, + context.getString(R.string.action_retry), + UpdateDownloaderService.downloadApkPendingService(context, url)) + // Cancel action + addAction(R.drawable.ic_clear_grey_24dp_img, + context.getString(R.string.action_cancel), + NotificationReceiver.dismissNotificationPendingBroadcast(context, Constants.NOTIFICATION_UPDATER_ID)) + } + notification.show() + } + + /** + * Shows a notification from this builder. + * + * @param id the id of the notification. + */ + private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_UPDATER_ID) { + context.notificationManager.notify(id, build()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateDownloaderService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateDownloaderService.kt index 028150b10..774a3ff99 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateDownloaderService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateDownloaderService.kt @@ -1,28 +1,160 @@ package eu.kanade.tachiyomi.data.updater import android.app.IntentService +import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.support.v4.app.NotificationCompat -import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.network.GET -import eu.kanade.tachiyomi.data.network.NetworkHelper -import eu.kanade.tachiyomi.data.network.ProgressListener -import eu.kanade.tachiyomi.data.network.newCallWithProgress -import eu.kanade.tachiyomi.util.notificationManager +import android.content.IntentFilter +import android.os.Build +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.ProgressListener +import eu.kanade.tachiyomi.network.newCallWithProgress +import eu.kanade.tachiyomi.util.registerLocalReceiver import eu.kanade.tachiyomi.util.saveTo +import eu.kanade.tachiyomi.util.sendLocalBroadcastSync +import eu.kanade.tachiyomi.util.unregisterLocalReceiver import timber.log.Timber import uy.kohesive.injekt.injectLazy import java.io.File class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.java.name) { + /** + * Network helper + */ + private val network: NetworkHelper by injectLazy() + + /** + * Local [BroadcastReceiver] that runs on UI thread + */ + private val updaterNotificationReceiver = UpdateDownloaderReceiver(this) + + + override fun onCreate() { + super.onCreate() + // Register receiver + registerLocalReceiver(updaterNotificationReceiver, IntentFilter(INTENT_FILTER_NAME)) + } + + override fun onDestroy() { + // Unregister receiver + unregisterLocalReceiver(updaterNotificationReceiver) + super.onDestroy() + } + + override fun onHandleIntent(intent: Intent?) { + if (intent == null) return + + val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return + downloadApk(url) + } + + /** + * Called to start downloading apk of new update + * + * @param url url location of file + */ + fun downloadApk(url: String) { + // Show notification download starting. + sendInitialBroadcast() + // Progress of the download + var savedProgress = 0 + + val progressListener = object : ProgressListener { + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + val progress = (100 * bytesRead / contentLength).toInt() + if (progress > savedProgress) { + savedProgress = progress + sendProgressBroadcast(progress) + } + } + } + + try { + // Download the new update. + val response = network.client.newCallWithProgress(GET(url), progressListener).execute() + + // File where the apk will be saved. + val apkFile = File(externalCacheDir, "update.apk") + + if (response.isSuccessful) { + response.body().source().saveTo(apkFile) + } else { + response.close() + throw Exception("Unsuccessful response") + } + sendInstallBroadcast(apkFile.absolutePath) + } catch (error: Exception) { + Timber.e(error) + sendErrorBroadcast(url) + } + } + + /** + * Show notification download starting. + */ + private fun sendInitialBroadcast() { + val intent = Intent(INTENT_FILTER_NAME).apply { + putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INITIAL) + } + sendLocalBroadcastSync(intent) + } + + /** + * Show notification progress changed + * + * @param progress progress of download + */ + private fun sendProgressBroadcast(progress: Int) { + val intent = Intent(INTENT_FILTER_NAME).apply { + putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_PROGRESS) + putExtra(UpdateDownloaderReceiver.EXTRA_PROGRESS, progress) + } + // Prevents not showing of install notification TODO weird Android N bug. Find out what goes wrong + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || progress <= 95) { + // Show download progress notification. + sendLocalBroadcastSync(intent) + } + } + + /** + * Show install notification. + * + * @param path location of file + */ + private fun sendInstallBroadcast(path: String){ + val intent = Intent(INTENT_FILTER_NAME).apply { + putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INSTALL) + putExtra(UpdateDownloaderReceiver.EXTRA_APK_PATH, path) + } + sendLocalBroadcastSync(intent) + } + + /** + * Show error notification. + * + * @param url url of file + */ + private fun sendErrorBroadcast(url: String){ + val intent = Intent(INTENT_FILTER_NAME).apply { + putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_ERROR) + putExtra(UpdateDownloaderReceiver.EXTRA_APK_URL, url) + } + sendLocalBroadcastSync(intent) + } companion object { + /** + * Name of Local BroadCastReceiver. + */ + private val INTENT_FILTER_NAME = UpdateDownloaderService::class.java.name + /** * Download url. */ - const val EXTRA_DOWNLOAD_URL = "eu.kanade.APP_DOWNLOAD_URL" + internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdateDownloaderService.DOWNLOAD_URL" /** * Downloads a new update and let the user install the new version from a notification. @@ -35,102 +167,20 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav } context.startService(intent) } - } - /** - * Network helper - */ - private val network: NetworkHelper by injectLazy() - - override fun onHandleIntent(intent: Intent?) { - if (intent == null) return - - val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return - downloadApk(url) - } - - fun downloadApk(url: String) { - val progressNotification = NotificationCompat.Builder(this) - - progressNotification.update { - setContentTitle(getString(R.string.app_name)) - setContentText(getString(R.string.update_check_notification_download_in_progress)) - setSmallIcon(android.R.drawable.stat_sys_download) - setOngoing(true) - } - - // Progress of the download - var savedProgress = 0 - - val progressListener = object : ProgressListener { - override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { - val progress = (100 * bytesRead / contentLength).toInt() - if (progress > savedProgress) { - savedProgress = progress - - progressNotification.update { setProgress(100, progress, false) } - } - } - } - - // Reference the context for later usage inside apply blocks. - val ctx = this - - try { - // Download the new update. - val response = network.client.newCallWithProgress(GET(url), progressListener).execute() - - // File where the apk will be saved - val apkFile = File(externalCacheDir, "update.apk") - - if (response.isSuccessful) { - response.body().source().saveTo(apkFile) - } else { - response.close() - throw Exception("Unsuccessful response") - } - - val installIntent = UpdateNotificationReceiver.installApkIntent(ctx, apkFile) - - // Prompt the user to install the new update. - NotificationCompat.Builder(this).update { - setContentTitle(getString(R.string.app_name)) - setContentText(getString(R.string.update_check_notification_download_complete)) - setSmallIcon(android.R.drawable.stat_sys_download_done) - // Install action - setContentIntent(installIntent) - addAction(R.drawable.ic_system_update_grey_24dp_img, - getString(R.string.action_install), - installIntent) - // Cancel action - addAction(R.drawable.ic_clear_grey_24dp_img, - getString(R.string.action_cancel), - UpdateNotificationReceiver.cancelNotificationIntent(ctx)) - } - - } catch (error: Exception) { - Timber.e(error) - - // Prompt the user to retry the download. - NotificationCompat.Builder(this).update { - setContentTitle(getString(R.string.app_name)) - setContentText(getString(R.string.update_check_notification_download_error)) - setSmallIcon(android.R.drawable.stat_sys_download_done) - // Retry action - addAction(R.drawable.ic_refresh_grey_24dp_img, - getString(R.string.action_retry), - UpdateNotificationReceiver.downloadApkIntent(ctx, url)) - // Cancel action - addAction(R.drawable.ic_clear_grey_24dp_img, - getString(R.string.action_cancel), - UpdateNotificationReceiver.cancelNotificationIntent(ctx)) + /** + * Returns [PendingIntent] that starts a service which downloads the apk specified in url. + * + * @param url the url to the new update. + * @return [PendingIntent] + */ + internal fun downloadApkPendingService(context: Context, url: String): PendingIntent { + val intent = Intent(context, UpdateDownloaderService::class.java).apply { + putExtra(EXTRA_DOWNLOAD_URL, url) } + return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } } +} - fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) { - block() - notificationManager.notify(NOTIFICATION_UPDATER_ID, build()) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateNotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateNotificationReceiver.kt deleted file mode 100644 index 74c5c9e2c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateNotificationReceiver.kt +++ /dev/null @@ -1,70 +0,0 @@ -package eu.kanade.tachiyomi.data.updater - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.support.v4.content.FileProvider -import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID -import eu.kanade.tachiyomi.util.notificationManager -import java.io.File - -class UpdateNotificationReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - ACTION_CANCEL_NOTIFICATION -> cancelNotification(context) - } - } - - companion object { - // Cancel notification action - const val ACTION_CANCEL_NOTIFICATION = "eu.kanade.CANCEL_NOTIFICATION" - - fun cancelNotificationIntent(context: Context): PendingIntent { - val intent = Intent(context, UpdateNotificationReceiver::class.java).apply { - action = ACTION_CANCEL_NOTIFICATION - } - return PendingIntent.getBroadcast(context, 0, intent, 0) - } - - /** - * Prompt user with apk install intent - * - * @param context context - * @param file file of apk that is installed - */ - fun installApkIntent(context: Context, file: File): PendingIntent { - val intent = Intent(Intent.ACTION_VIEW).apply { - val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file) - else Uri.fromFile(file) - setDataAndType(uri, "application/vnd.android.package-archive") - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - } - cancelNotification(context) - return PendingIntent.getActivity(context, 0, intent, 0) - } - - /** - * Downloads a new update and let the user install the new version from a notification. - * - * @param context the application context. - * @param url the url to the new update. - */ - fun downloadApkIntent(context: Context, url: String): PendingIntent { - val intent = Intent(context, UpdateDownloaderService::class.java).apply { - putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url) - } - return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) - } - - fun cancelNotification(context: Context) { - context.notificationManager.cancel(NOTIFICATION_UPDATER_ID) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/CloudflareInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt similarity index 70% rename from app/src/main/java/eu/kanade/tachiyomi/data/network/CloudflareInterceptor.kt rename to app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt index 503b779a3..3a1840b06 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/CloudflareInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt @@ -1,80 +1,77 @@ -package eu.kanade.tachiyomi.data.network - -import com.squareup.duktape.Duktape -import okhttp3.HttpUrl -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response - -class CloudflareInterceptor(private val cookies: PersistentCookieStore) : Interceptor { - - //language=RegExp - private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""") - - //language=RegExp - private val passPattern = Regex("""name="pass" value="(.+?)"""") - - //language=RegExp - private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""") - - override fun intercept(chain: Interceptor.Chain): Response { - val response = chain.proceed(chain.request()) - - // Check if we already solved a challenge - if (response.code() != 503 && - cookies.get(response.request().url()).any { it.name() == "cf_clearance" }) { - return response - } - - // Check if Cloudflare anti-bot is on - if ("URL=/cdn-cgi/" in response.header("Refresh", "") - && response.header("Server", "") == "cloudflare-nginx") { - return chain.proceed(resolveChallenge(response)) - } - - return response - } - - private fun resolveChallenge(response: Response): Request { - val duktape = Duktape.create() - try { - val originalRequest = response.request() - val domain = originalRequest.url().host() - val content = response.body().string() - - // CloudFlare requires waiting 4 seconds before resolving the challenge - Thread.sleep(4000) - - val operation = operationPattern.find(content)?.groups?.get(1)?.value - val challenge = challengePattern.find(content)?.groups?.get(1)?.value - val pass = passPattern.find(content)?.groups?.get(1)?.value - - if (operation == null || challenge == null || pass == null) { - throw RuntimeException("Failed resolving Cloudflare challenge") - } - - val js = operation - //language=RegExp - .replace(Regex("""a\.value =(.+?) \+.*"""), "$1") - //language=RegExp - .replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "") - .replace("\n", "") - - val result = (duktape.evaluate(js) as Double).toInt() - - val answer = "${result + domain.length}" - - val url = HttpUrl.parse("http://$domain/cdn-cgi/l/chk_jschl").newBuilder() - .addQueryParameter("jschl_vc", challenge) - .addQueryParameter("pass", pass) - .addQueryParameter("jschl_answer", answer) - .toString() - - val referer = originalRequest.url().toString() - return GET(url, originalRequest.headers().newBuilder().add("Referer", referer).build()) - } finally { - duktape.close() - } - } - +package eu.kanade.tachiyomi.network + +import com.squareup.duktape.Duktape +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +class CloudflareInterceptor : Interceptor { + + //language=RegExp + private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""") + + //language=RegExp + private val passPattern = Regex("""name="pass" value="(.+?)"""") + + //language=RegExp + private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""") + + @Synchronized + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + + // Check if Cloudflare anti-bot is on + if (response.code() == 503 && "cloudflare-nginx" == response.header("Server")) { + return chain.proceed(resolveChallenge(response)) + } + + return response + } + + private fun resolveChallenge(response: Response): Request { + Duktape.create().use { duktape -> + val originalRequest = response.request() + val url = originalRequest.url() + val domain = url.host() + val content = response.body().string() + + // CloudFlare requires waiting 4 seconds before resolving the challenge + Thread.sleep(4000) + + val operation = operationPattern.find(content)?.groups?.get(1)?.value + val challenge = challengePattern.find(content)?.groups?.get(1)?.value + val pass = passPattern.find(content)?.groups?.get(1)?.value + + if (operation == null || challenge == null || pass == null) { + throw RuntimeException("Failed resolving Cloudflare challenge") + } + + val js = operation + //language=RegExp + .replace(Regex("""a\.value =(.+?) \+.*"""), "$1") + //language=RegExp + .replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "") + .replace("\n", "") + + val result = (duktape.evaluate(js) as Double).toInt() + + val answer = "${result + domain.length}" + + val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl") + .newBuilder() + .addQueryParameter("jschl_vc", challenge) + .addQueryParameter("pass", pass) + .addQueryParameter("jschl_answer", answer) + .toString() + + val cloudflareHeaders = originalRequest.headers() + .newBuilder() + .add("Referer", url.toString()) + .build() + + return GET(cloudflareUrl, cloudflareHeaders) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt similarity index 88% rename from app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.kt rename to app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index 4e95ed564..e48174de4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -1,38 +1,38 @@ -package eu.kanade.tachiyomi.data.network - -import android.content.Context -import okhttp3.Cache -import okhttp3.OkHttpClient -import java.io.File - -class NetworkHelper(context: Context) { - - private val cacheDir = File(context.cacheDir, "network_cache") - - private val cacheSize = 5L * 1024 * 1024 // 5 MiB - - private val cookieManager = PersistentCookieJar(context) - - val client = OkHttpClient.Builder() - .cookieJar(cookieManager) - .cache(Cache(cacheDir, cacheSize)) - .build() - - val forceCacheClient = client.newBuilder() - .addNetworkInterceptor { chain -> - val originalResponse = chain.proceed(chain.request()) - originalResponse.newBuilder() - .removeHeader("Pragma") - .header("Cache-Control", "max-age=600") - .build() - } - .build() - - val cloudflareClient = client.newBuilder() - .addInterceptor(CloudflareInterceptor(cookies)) - .build() - - val cookies: PersistentCookieStore - get() = cookieManager.store - -} +package eu.kanade.tachiyomi.network + +import android.content.Context +import okhttp3.Cache +import okhttp3.OkHttpClient +import java.io.File + +class NetworkHelper(context: Context) { + + private val cacheDir = File(context.cacheDir, "network_cache") + + private val cacheSize = 5L * 1024 * 1024 // 5 MiB + + private val cookieManager = PersistentCookieJar(context) + + val client = OkHttpClient.Builder() + .cookieJar(cookieManager) + .cache(Cache(cacheDir, cacheSize)) + .build() + + val forceCacheClient = client.newBuilder() + .addNetworkInterceptor { chain -> + val originalResponse = chain.proceed(chain.request()) + originalResponse.newBuilder() + .removeHeader("Pragma") + .header("Cache-Control", "max-age=600") + .build() + } + .build() + + val cloudflareClient = client.newBuilder() + .addInterceptor(CloudflareInterceptor()) + .build() + + val cookies: PersistentCookieStore + get() = cookieManager.store + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/OkHttpExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt similarity index 95% rename from app/src/main/java/eu/kanade/tachiyomi/data/network/OkHttpExtensions.kt rename to app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 7b75bdd0b..8016b1ac8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/OkHttpExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -1,70 +1,70 @@ -package eu.kanade.tachiyomi.data.network - -import okhttp3.Call -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import rx.Observable -import rx.Producer -import rx.Subscription -import java.util.concurrent.atomic.AtomicBoolean - -fun Call.asObservable(): Observable { - return Observable.create { subscriber -> - // Since Call is a one-shot type, clone it for each new subscriber. - val call = clone() - - // Wrap the call in a helper which handles both unsubscription and backpressure. - val requestArbiter = object : AtomicBoolean(), Producer, Subscription { - override fun request(n: Long) { - if (n == 0L || !compareAndSet(false, true)) return - - try { - val response = call.execute() - if (!subscriber.isUnsubscribed) { - subscriber.onNext(response) - subscriber.onCompleted() - } - } catch (error: Exception) { - if (!subscriber.isUnsubscribed) { - subscriber.onError(error) - } - } - } - - override fun unsubscribe() { - call.cancel() - } - - override fun isUnsubscribed(): Boolean { - return call.isCanceled - } - } - - subscriber.add(requestArbiter) - subscriber.setProducer(requestArbiter) - } -} - -fun Call.asObservableSuccess(): Observable { - return asObservable().doOnNext { response -> - if (!response.isSuccessful) { - response.close() - throw Exception("HTTP error ${response.code()}") - } - } -} - -fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { - val progressClient = newBuilder() - .cache(null) - .addNetworkInterceptor { chain -> - val originalResponse = chain.proceed(chain.request()) - originalResponse.newBuilder() - .body(ProgressResponseBody(originalResponse.body(), listener)) - .build() - } - .build() - - return progressClient.newCall(request) +package eu.kanade.tachiyomi.network + +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import rx.Producer +import rx.Subscription +import java.util.concurrent.atomic.AtomicBoolean + +fun Call.asObservable(): Observable { + return Observable.create { subscriber -> + // Since Call is a one-shot type, clone it for each new subscriber. + val call = clone() + + // Wrap the call in a helper which handles both unsubscription and backpressure. + val requestArbiter = object : AtomicBoolean(), Producer, Subscription { + override fun request(n: Long) { + if (n == 0L || !compareAndSet(false, true)) return + + try { + val response = call.execute() + if (!subscriber.isUnsubscribed) { + subscriber.onNext(response) + subscriber.onCompleted() + } + } catch (error: Exception) { + if (!subscriber.isUnsubscribed) { + subscriber.onError(error) + } + } + } + + override fun unsubscribe() { + call.cancel() + } + + override fun isUnsubscribed(): Boolean { + return call.isCanceled + } + } + + subscriber.add(requestArbiter) + subscriber.setProducer(requestArbiter) + } +} + +fun Call.asObservableSuccess(): Observable { + return asObservable().doOnNext { response -> + if (!response.isSuccessful) { + response.close() + throw Exception("HTTP error ${response.code()}") + } + } +} + +fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { + val progressClient = newBuilder() + .cache(null) + .addNetworkInterceptor { chain -> + val originalResponse = chain.proceed(chain.request()) + originalResponse.newBuilder() + .body(ProgressResponseBody(originalResponse.body(), listener)) + .build() + } + .build() + + return progressClient.newCall(request) } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/PersistentCookieJar.kt b/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieJar.kt similarity index 88% rename from app/src/main/java/eu/kanade/tachiyomi/data/network/PersistentCookieJar.kt rename to app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieJar.kt index a53bc39c0..fda979978 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/PersistentCookieJar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieJar.kt @@ -1,19 +1,19 @@ -package eu.kanade.tachiyomi.data.network - -import android.content.Context -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl - -class PersistentCookieJar(context: Context) : CookieJar { - - val store = PersistentCookieStore(context) - - override fun saveFromResponse(url: HttpUrl, cookies: List) { - store.addAll(url, cookies) - } - - override fun loadForRequest(url: HttpUrl): List { - return store.get(url) - } +package eu.kanade.tachiyomi.network + +import android.content.Context +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +class PersistentCookieJar(context: Context) : CookieJar { + + val store = PersistentCookieStore(context) + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + store.addAll(url, cookies) + } + + override fun loadForRequest(url: HttpUrl): List { + return store.get(url) + } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/PersistentCookieStore.kt b/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieStore.kt similarity index 54% rename from app/src/main/java/eu/kanade/tachiyomi/data/network/PersistentCookieStore.kt rename to app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieStore.kt index 73690e7ff..4664b22f4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/PersistentCookieStore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieStore.kt @@ -1,75 +1,73 @@ -package eu.kanade.tachiyomi.data.network - -import android.content.Context -import okhttp3.Cookie -import okhttp3.HttpUrl -import java.net.URI -import java.util.concurrent.ConcurrentHashMap - -class PersistentCookieStore(context: Context) { - - private val cookieMap = ConcurrentHashMap>() - private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE) - - init { - for ((key, value) in prefs.all) { - @Suppress("UNCHECKED_CAST") - val cookies = value as? Set - if (cookies != null) { - try { - val url = HttpUrl.parse("http://$key") - val nonExpiredCookies = cookies.map { Cookie.parse(url, it) } - .filter { !it.hasExpired() } - cookieMap.put(key, nonExpiredCookies) - } catch (e: Exception) { - // Ignore - } - } - } - } - - fun addAll(url: HttpUrl, cookies: List) { - synchronized(this) { - val key = url.uri().host - - // Append or replace the cookies for this domain. - val cookiesForDomain = cookieMap[key].orEmpty().toMutableList() - for (cookie in cookies) { - // Find a cookie with the same name. Replace it if found, otherwise add a new one. - val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() } - if (pos == -1) { - cookiesForDomain.add(cookie) - } else { - cookiesForDomain[pos] = cookie - } - } - cookieMap.put(key, cookiesForDomain) - - // Get cookies to be stored in disk - val newValues = cookiesForDomain.asSequence() - .filter { it.persistent() && !it.hasExpired() } - .map { it.toString() } - .toSet() - - prefs.edit().putStringSet(key, newValues).apply() - } - } - - fun removeAll() { - synchronized(this) { - prefs.edit().clear().apply() - cookieMap.clear() - } - } - - fun get(url: HttpUrl) = get(url.uri().host) - - fun get(uri: URI) = get(uri.host) - - private fun get(url: String): List { - return cookieMap[url].orEmpty().filter { !it.hasExpired() } - } - - private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt() - +package eu.kanade.tachiyomi.network + +import android.content.Context +import okhttp3.Cookie +import okhttp3.HttpUrl +import java.net.URI +import java.util.concurrent.ConcurrentHashMap + +class PersistentCookieStore(context: Context) { + + private val cookieMap = ConcurrentHashMap>() + private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE) + + init { + for ((key, value) in prefs.all) { + @Suppress("UNCHECKED_CAST") + val cookies = value as? Set + if (cookies != null) { + try { + val url = HttpUrl.parse("http://$key") + val nonExpiredCookies = cookies.map { Cookie.parse(url, it) } + .filter { !it.hasExpired() } + cookieMap.put(key, nonExpiredCookies) + } catch (e: Exception) { + // Ignore + } + } + } + } + + @Synchronized + fun addAll(url: HttpUrl, cookies: List) { + val key = url.uri().host + + // Append or replace the cookies for this domain. + val cookiesForDomain = cookieMap[key].orEmpty().toMutableList() + for (cookie in cookies) { + // Find a cookie with the same name. Replace it if found, otherwise add a new one. + val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() } + if (pos == -1) { + cookiesForDomain.add(cookie) + } else { + cookiesForDomain[pos] = cookie + } + } + cookieMap.put(key, cookiesForDomain) + + // Get cookies to be stored in disk + val newValues = cookiesForDomain.asSequence() + .filter { it.persistent() && !it.hasExpired() } + .map(Cookie::toString) + .toSet() + + prefs.edit().putStringSet(key, newValues).apply() + } + + @Synchronized + fun removeAll() { + prefs.edit().clear().apply() + cookieMap.clear() + } + + fun get(url: HttpUrl) = get(url.uri().host) + + fun get(uri: URI) = get(uri.host) + + private fun get(url: String): List { + return cookieMap[url].orEmpty().filter { !it.hasExpired() } + } + + private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt() + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.kt b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt similarity index 70% rename from app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.kt rename to app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt index f624e2b62..113f99763 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt @@ -1,5 +1,5 @@ -package eu.kanade.tachiyomi.data.network - -interface ProgressListener { - fun update(bytesRead: Long, contentLength: Long, done: Boolean) +package eu.kanade.tachiyomi.network + +interface ProgressListener { + fun update(bytesRead: Long, contentLength: Long, done: Boolean) } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.kt b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt similarity index 94% rename from app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.kt rename to app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt index 67c639b1a..2e2c32f75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt @@ -1,40 +1,40 @@ -package eu.kanade.tachiyomi.data.network - -import okhttp3.MediaType -import okhttp3.ResponseBody -import okio.* -import java.io.IOException - -class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { - - private val bufferedSource: BufferedSource by lazy { - Okio.buffer(source(responseBody.source())) - } - - override fun contentType(): MediaType { - return responseBody.contentType() - } - - override fun contentLength(): Long { - return responseBody.contentLength() - } - - override fun source(): BufferedSource { - return bufferedSource - } - - private fun source(source: Source): Source { - return object : ForwardingSource(source) { - internal var totalBytesRead = 0L - - @Throws(IOException::class) - override fun read(sink: Buffer, byteCount: Long): Long { - val bytesRead = super.read(sink, byteCount) - // read() returns the number of bytes read, or -1 if this source is exhausted. - totalBytesRead += if (bytesRead != -1L) bytesRead else 0 - progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) - return bytesRead - } - } - } +package eu.kanade.tachiyomi.network + +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.* +import java.io.IOException + +class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { + + private val bufferedSource: BufferedSource by lazy { + Okio.buffer(source(responseBody.source())) + } + + override fun contentType(): MediaType { + return responseBody.contentType() + } + + override fun contentLength(): Long { + return responseBody.contentLength() + } + + override fun source(): BufferedSource { + return bufferedSource + } + + private fun source(source: Source): Source { + return object : ForwardingSource(source) { + internal var totalBytesRead = 0L + + @Throws(IOException::class) + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + // read() returns the number of bytes read, or -1 if this source is exhausted. + totalBytesRead += if (bytesRead != -1L) bytesRead else 0 + progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) + return bytesRead + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/Requests.kt b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt similarity index 92% rename from app/src/main/java/eu/kanade/tachiyomi/data/network/Requests.kt rename to app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt index ec53e21a3..3b89d0d88 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/Requests.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt @@ -1,32 +1,32 @@ -package eu.kanade.tachiyomi.data.network - -import okhttp3.* -import java.util.concurrent.TimeUnit.MINUTES - -private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() -private val DEFAULT_HEADERS = Headers.Builder().build() -private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() - -fun GET(url: String, - headers: Headers = DEFAULT_HEADERS, - cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { - - return Request.Builder() - .url(url) - .headers(headers) - .cacheControl(cache) - .build() -} - -fun POST(url: String, - headers: Headers = DEFAULT_HEADERS, - body: RequestBody = DEFAULT_BODY, - cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { - - return Request.Builder() - .url(url) - .post(body) - .headers(headers) - .cacheControl(cache) - .build() -} +package eu.kanade.tachiyomi.network + +import okhttp3.* +import java.util.concurrent.TimeUnit.MINUTES + +private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() +private val DEFAULT_HEADERS = Headers.Builder().build() +private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() + +fun GET(url: String, + headers: Headers = DEFAULT_HEADERS, + cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { + + return Request.Builder() + .url(url) + .headers(headers) + .cacheControl(cache) + .build() +} + +fun POST(url: String, + headers: Headers = DEFAULT_HEADERS, + body: RequestBody = DEFAULT_BODY, + cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { + + return Request.Builder() + .url(url) + .post(body) + .headers(headers) + .cacheControl(cache) + .build() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt new file mode 100644 index 000000000..f8d0ea464 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.source + +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import rx.Observable + +interface CatalogueSource : Source { + + /** + * An ISO 639-1 compliant language code (two letters in lower case). + */ + val lang: String + + /** + * Whether the source has support for latest updates. + */ + val supportsLatest: Boolean + + /** + * Returns an observable containing a page with a list of manga. + * + * @param page the page number to retrieve. + */ + fun fetchPopularManga(page: Int): Observable + + /** + * Returns an observable containing a page with a list of manga. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable + + /** + * Returns an observable containing a page with a list of latest manga updates. + * + * @param page the page number to retrieve. + */ + fun fetchLatestUpdates(page: Int): Observable + + /** + * Returns the list of filters for the source. + */ + fun getFilterList(): FilterList +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt new file mode 100644 index 000000000..d63c90844 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -0,0 +1,318 @@ +package eu.kanade.tachiyomi.source + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.util.ChapterRecognition +import eu.kanade.tachiyomi.util.DiskUtil +import eu.kanade.tachiyomi.util.RarContentProvider +import eu.kanade.tachiyomi.util.ZipContentProvider +import junrar.Archive +import junrar.rarfile.FileHeader +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import rx.Observable +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.util.* +import java.util.concurrent.TimeUnit +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +class LocalSource(private val context: Context) : CatalogueSource { + companion object { + private val COVER_NAME = "cover.jpg" + private val POPULAR_FILTERS = FilterList(OrderBy()) + private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) }) + private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) + val ID = 0L + + fun updateCover(context: Context, manga: SManga, input: InputStream): File? { + val dir = getBaseDirectories(context).firstOrNull() + if (dir == null) { + input.close() + return null + } + val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) + + // It might not exist if using the external SD card + cover.parentFile.mkdirs() + input.use { + cover.outputStream().use { + input.copyTo(it) + } + } + return cover + } + + private fun getBaseDirectories(context: Context): List { + val c = context.getString(R.string.app_name) + File.separator + "local" + return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) } + } + } + + override val id = ID + override val name = "LocalSource" + override val lang = "en" + override val supportsLatest = true + + override fun toString() = context.getString(R.string.local_source) + + override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + val baseDirs = getBaseDirectories(context) + + val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L + var mangaDirs = baseDirs.mapNotNull { it.listFiles()?.toList() } + .flatten() + .filter { it.isDirectory && if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + .distinctBy { it.name } + + val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state + when (state?.index) { + 0 -> { + if (state!!.ascending) + mangaDirs = mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) } + else + mangaDirs = mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) } + } + 1 -> { + if (state!!.ascending) + mangaDirs = mangaDirs.sortedBy(File::lastModified) + else + mangaDirs = mangaDirs.sortedByDescending(File::lastModified) + } + } + + val mangas = mangaDirs.map { mangaDir -> + SManga.create().apply { + title = mangaDir.name + url = mangaDir.name + + // Try to find the cover + for (dir in baseDirs) { + val cover = File("${dir.absolutePath}/$url", COVER_NAME) + if (cover.exists()) { + thumbnail_url = cover.absolutePath + break + } + } + + // Copy the cover from the first chapter found. + if (thumbnail_url == null) { + val chapters = fetchChapterList(this).toBlocking().first() + if (chapters.isNotEmpty()) { + val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri + if (uri != null) { + val input = context.contentResolver.openInputStream(uri) + try { + val dest = updateCover(context, this, input) + thumbnail_url = dest?.absolutePath + } catch (e: Exception) { + Timber.e(e) + } + } + } + } + + initialized = true + } + } + return Observable.just(MangasPage(mangas, false)) + } + + override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) + + override fun fetchMangaDetails(manga: SManga) = Observable.just(manga) + + override fun fetchChapterList(manga: SManga): Observable> { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + val chapters = getBaseDirectories(context) + .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .flatten() + .filter { it.isDirectory || isSupportedFormat(it.extension) } + .map { chapterFile -> + SChapter.create().apply { + url = "${manga.url}/${chapterFile.name}" + val chapName = if (chapterFile.isDirectory) { + chapterFile.name + } else { + chapterFile.nameWithoutExtension + } + val chapNameCut = chapName.replace(manga.title, "", true).trim() + name = if (chapNameCut.isEmpty()) chapName else chapNameCut + date_upload = chapterFile.lastModified() + ChapterRecognition.parseChapterNumber(this, manga) + } + } + .sortedWith(Comparator { c1, c2 -> + val c = c2.chapter_number.compareTo(c1.chapter_number) + if (c == 0) comparator.compare(c2.name, c1.name) else c + }) + + return Observable.just(chapters) + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val baseDirs = getBaseDirectories(context) + + for (dir in baseDirs) { + val chapFile = File(dir, chapter.url) + if (!chapFile.exists()) continue + + return Observable.just(getLoader(chapFile).load()) + } + + return Observable.error(Exception("Chapter not found")) + } + + private fun isSupportedFormat(extension: String): Boolean { + return extension.equals("zip", true) || extension.equals("cbz", true) + || extension.equals("rar", true) || extension.equals("cbr", true) + || extension.equals("epub", true) + } + + private fun getLoader(file: File): Loader { + val extension = file.extension + return if (file.isDirectory) { + DirectoryLoader(file) + } else if (extension.equals("zip", true) || extension.equals("cbz", true)) { + ZipLoader(file) + } else if (extension.equals("epub", true)) { + EpubLoader(file) + } else if (extension.equals("rar", true) || extension.equals("cbr", true)) { + RarLoader(file) + } else { + throw Exception("Invalid chapter format") + } + } + + private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true)) + + override fun getFilterList() = FilterList(OrderBy()) + + interface Loader { + fun load(): List + } + + class DirectoryLoader(val file: File) : Loader { + override fun load(): List { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + return file.listFiles() + .filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) } + .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) + .map { Uri.fromFile(it) } + .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } + } + } + + class ZipLoader(val file: File) : Loader { + override fun load(): List { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + return ZipFile(file).use { zip -> + zip.entries().toList() + .filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) } + .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) + .map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") } + .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } + } + } + } + + class RarLoader(val file: File) : Loader { + override fun load(): List { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + return Archive(file).use { archive -> + archive.fileHeaders + .filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) } + .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) }) + .map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") } + .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } + } + } + } + + class EpubLoader(val file: File) : Loader { + + override fun load(): List { + ZipFile(file).use { zip -> + val allEntries = zip.entries().toList() + val ref = getPackageHref(zip) + val doc = getPackageDocument(zip, ref) + val pages = getPagesFromDocument(doc) + val hrefs = getHrefMap(ref, allEntries.map { it.name }) + return getImagesFromPages(zip, pages, hrefs) + .map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") } + .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } + } + } + + /** + * Returns the path to the package document. + */ + private fun getPackageHref(zip: ZipFile): String { + val meta = zip.getEntry("META-INF/container.xml") + if (meta != null) { + val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } + val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") + if (path != null) { + return path + } + } + return "OEBPS/content.opf" + } + + /** + * Returns the package document where all the files are listed. + */ + private fun getPackageDocument(zip: ZipFile, ref: String): Document { + val entry = zip.getEntry(ref) + return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + } + + /** + * Returns all the pages from the epub. + */ + private fun getPagesFromDocument(document: Document): List { + val pages = document.select("manifest > item") + .filter { "application/xhtml+xml" == it.attr("media-type") } + .associateBy { it.attr("id") } + + val spine = document.select("spine > itemref").map { it.attr("idref") } + return spine.mapNotNull { pages[it] }.map { it.attr("href") } + } + + /** + * Returns all the images contained in every page from the epub. + */ + private fun getImagesFromPages(zip: ZipFile, pages: List, hrefs: Map): List { + return pages.map { page -> + val entry = zip.getEntry(hrefs[page]) + val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] } + }.flatten() + } + + /** + * Returns a map with a relative url as key and abolute url as path. + */ + private fun getHrefMap(packageHref: String, entries: List): Map { + val lastSlashPos = packageHref.lastIndexOf('/') + if (lastSlashPos < 0) { + return entries.associateBy { it } + } + return entries.associateBy { entry -> + if (entry.isNotBlank() && entry.length > lastSlashPos) { + entry.substring(lastSlashPos + 1) + } else { + entry + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/Source.kt b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt similarity index 54% rename from app/src/main/java/eu/kanade/tachiyomi/data/source/Source.kt rename to app/src/main/java/eu/kanade/tachiyomi/source/Source.kt index ba196a51f..666621bb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -1,50 +1,44 @@ -package eu.kanade.tachiyomi.data.source - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.Page -import rx.Observable - -/** - * A basic interface for creating a source. It could be an online source, a local source, etc... - */ -interface Source { - - /** - * Id for the source. Must be unique. - */ - val id: Int - - /** - * Name of the source. - */ - val name: String - - /** - * Returns an observable with the updated details for a manga. - * - * @param manga the manga to update. - */ - fun fetchMangaDetails(manga: Manga): Observable - - /** - * Returns an observable with all the available chapters for a manga. - * - * @param manga the manga to update. - */ - fun fetchChapterList(manga: Manga): Observable> - - /** - * Returns an observable with the list of pages a chapter has. - * - * @param chapter the chapter. - */ - fun fetchPageList(chapter: Chapter): Observable> - - /** - * Returns an observable with the path of the image. - * - * @param page the page. - */ - fun fetchImage(page: Page): Observable +package eu.kanade.tachiyomi.source + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import rx.Observable + +/** + * A basic interface for creating a source. It could be an online source, a local source, etc... + */ +interface Source { + + /** + * Id for the source. Must be unique. + */ + val id: Long + + /** + * Name of the source. + */ + val name: String + + /** + * Returns an observable with the updated details for a manga. + * + * @param manga the manga to update. + */ + fun fetchMangaDetails(manga: SManga): Observable + + /** + * Returns an observable with all the available chapters for a manga. + * + * @param manga the manga to update. + */ + fun fetchChapterList(manga: SManga): Observable> + + /** + * Returns an observable with the list of pages a chapter has. + * + * @param chapter the chapter. + */ + fun fetchPageList(chapter: SChapter): Observable> + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt new file mode 100644 index 000000000..925353e1c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -0,0 +1,140 @@ +package eu.kanade.tachiyomi.source + +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Environment +import dalvik.system.PathClassLoader +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.source.online.YamlHttpSource +import eu.kanade.tachiyomi.source.online.english.* +import eu.kanade.tachiyomi.source.online.german.WieManga +import eu.kanade.tachiyomi.source.online.russian.Mangachan +import eu.kanade.tachiyomi.source.online.russian.Mintmanga +import eu.kanade.tachiyomi.source.online.russian.Readmanga +import eu.kanade.tachiyomi.util.hasPermission +import org.yaml.snakeyaml.Yaml +import timber.log.Timber +import java.io.File + +open class SourceManager(private val context: Context) { + + private val sourcesMap = mutableMapOf() + + init { + createSources() + } + + open fun get(sourceKey: Long): Source? { + return sourcesMap[sourceKey] + } + + fun getOnlineSources() = sourcesMap.values.filterIsInstance() + + fun getCatalogueSources() = sourcesMap.values.filterIsInstance() + + private fun createSources() { + createExtensionSources().forEach { registerSource(it) } + createYamlSources().forEach { registerSource(it) } + createInternalSources().forEach { registerSource(it) } + } + + private fun registerSource(source: Source, overwrite: Boolean = false) { + if (overwrite || !sourcesMap.containsKey(source.id)) { + sourcesMap.put(source.id, source) + } + } + + private fun createInternalSources(): List = listOf( + LocalSource(context), + Batoto(), + Mangahere(), + Mangafox(), + Kissmanga(), + Readmanga(), + Mintmanga(), + Mangachan(), + Readmangatoday(), + Mangasee(), + WieManga() + ) + + private fun createYamlSources(): List { + val sources = mutableListOf() + + val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath + + File.separator + context.getString(R.string.app_name), "parsers") + + if (parsersDir.exists() && context.hasPermission(READ_EXTERNAL_STORAGE)) { + val yaml = Yaml() + for (file in parsersDir.listFiles().filter { it.extension == "yml" }) { + try { + val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) } + sources.add(YamlHttpSource(map)) + } catch (e: Exception) { + Timber.e("Error loading source from file. Bad format?") + } + } + } + return sources + } + + private fun createExtensionSources(): List { + val pkgManager = context.packageManager + val flags = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES + val installedPkgs = pkgManager.getInstalledPackages(flags) + val extPkgs = installedPkgs.filter { it.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE } } + + val sources = mutableListOf() + for (pkgInfo in extPkgs) { + val appInfo = pkgManager.getApplicationInfo(pkgInfo.packageName, + PackageManager.GET_META_DATA) ?: continue + + val extName = pkgManager.getApplicationLabel(appInfo).toString() + .substringAfter("Tachiyomi: ") + val version = pkgInfo.versionName + var sourceClass = appInfo.metaData.getString(METADATA_SOURCE_CLASS) + if (sourceClass.startsWith(".")) { + sourceClass = pkgInfo.packageName + sourceClass + } + + val extension = Extension(extName, appInfo, version, sourceClass) + try { + val instance = loadExtension(extension) + sources.add(instance) + } catch (e: Exception) { + Timber.e("Extension load error: $extName. Reason: ${e.message}") + } catch (e: LinkageError) { + Timber.e("Extension load error: $extName. Reason: ${e.message}") + } + } + return sources + } + + private fun loadExtension(ext: Extension): Source { + // Validate lib version + val majorLibVersion = ext.version.substringBefore('.').toInt() + if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) { + throw Exception("Lib version is $majorLibVersion, while only versions " + + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed") + } + + val classLoader = PathClassLoader(ext.appInfo.sourceDir, null, context.classLoader) + return Class.forName(ext.sourceClass, false, classLoader).newInstance() as Source + } + + class Extension(val name: String, + val appInfo: ApplicationInfo, + val version: String, + val sourceClass: String) + + private companion object { + const val EXTENSION_FEATURE = "tachiyomi.extension" + const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" + const val LIB_VERSION_MIN = 1 + const val LIB_VERSION_MAX = 1 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt new file mode 100644 index 000000000..1664d67eb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.source.model + +sealed class Filter(val name: String, var state: T) { + open class Header(name: String) : Filter(name, 0) + open class Separator(name: String = "") : Filter(name, 0) + abstract class Select(name: String, val values: Array, state: Int = 0) : Filter(name, state) + abstract class Text(name: String, state: String = "") : Filter(name, state) + abstract class CheckBox(name: String, state: Boolean = false) : Filter(name, state) + abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter(name, state) { + fun isIgnored() = state == STATE_IGNORE + fun isIncluded() = state == STATE_INCLUDE + fun isExcluded() = state == STATE_EXCLUDE + + companion object { + const val STATE_IGNORE = 0 + const val STATE_INCLUDE = 1 + const val STATE_EXCLUDE = 2 + } + } + abstract class Group(name: String, state: List): Filter>(name, state) + + abstract class Sort(name: String, val values: Array, state: Selection? = null) + : Filter(name, state) { + data class Selection(val index: Int, val ascending: Boolean) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Filter<*>) return false + + return name == other.name && state == other.state + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + (state?.hashCode() ?: 0) + return result + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt new file mode 100644 index 000000000..36d8e144a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.source.model + +data class FilterList(val list: List>) : List> by list { + + constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList()) + +} \ No newline at end of file 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 new file mode 100644 index 000000000..e359619fb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.source.model + +data class MangasPage(val mangas: List, val hasNextPage: Boolean) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/data/source/model/Page.kt rename to app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt index f8fef147d..618684d11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/model/Page.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt @@ -1,7 +1,7 @@ -package eu.kanade.tachiyomi.data.source.model +package eu.kanade.tachiyomi.source.model import android.net.Uri -import eu.kanade.tachiyomi.data.network.ProgressListener +import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.ui.reader.ReaderChapter import rx.subjects.Subject 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 new file mode 100644 index 000000000..a54a36b40 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.source.model + +import java.io.Serializable + +interface SChapter : Serializable { + + var url: String + + var name: String + + var date_upload: Long + + var chapter_number: Float + + fun copyFrom(other: SChapter) { + name = other.name + url = other.url + date_upload = other.date_upload + chapter_number = other.chapter_number + } + + companion object { + fun create(): SChapter { + return SChapterImpl() + } + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..026d437e0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.source.model + +class SChapterImpl : SChapter { + + override lateinit var url: String + + override lateinit var name: String + + override var date_upload: Long = 0 + + override var chapter_number: Float = -1f + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt new file mode 100644 index 000000000..8a1ba1af0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.source.model + +import java.io.Serializable + +interface SManga : Serializable { + + var url: String + + var title: String + + var artist: String? + + var author: String? + + var description: String? + + var genre: String? + + var status: Int + + var thumbnail_url: String? + + var initialized: Boolean + + fun copyFrom(other: SManga) { + if (other.author != null) + author = other.author + + if (other.artist != null) + artist = other.artist + + if (other.description != null) + description = other.description + + if (other.genre != null) + genre = other.genre + + if (other.thumbnail_url != null) + thumbnail_url = other.thumbnail_url + + status = other.status + + if (!initialized) + initialized = other.initialized + } + + companion object { + const val UNKNOWN = 0 + const val ONGOING = 1 + const val COMPLETED = 2 + const val LICENSED = 3 + + fun create(): SManga { + return SMangaImpl() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt new file mode 100644 index 000000000..30635897b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.source.model + +class SMangaImpl : SManga { + + override lateinit var url: String + + override lateinit var title: String + + override var artist: String? = null + + override var author: String? = null + + override var description: String? = null + + override var genre: String? = null + + override var status: Int = 0 + + override var thumbnail_url: String? = null + + override var initialized: Boolean = false + +} \ No newline at end of file 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 new file mode 100644 index 000000000..5698a3dfd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -0,0 +1,361 @@ +package eu.kanade.tachiyomi.source.online + +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.newCallWithProgress +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.* +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.net.URI +import java.net.URISyntaxException +import java.security.MessageDigest + +/** + * A simple implementation for sources from a website. + */ +abstract class HttpSource : CatalogueSource { + + /** + * Network service. + */ + protected val network: NetworkHelper by injectLazy() + + /** + * Preferences helper. + */ + protected val preferences: PreferencesHelper by injectLazy() + + /** + * Base url of the website without the trailing slash, like: http://mysite.com + */ + abstract val baseUrl: String + + /** + * Version id used to generate the source id. If the site completely changes and urls are + * incompatible, you may increase this value and it'll be considered as a new source. + */ + open val versionId = 1 + + /** + * Id of the source. By default it uses a generated id using the first 16 characters (64 bits) + * of the MD5 of the string: sourcename/language/versionId + * Note the generated id sets the sign bit to 0. + */ + override val id by lazy { + val key = "${name.toLowerCase()}/$lang/$versionId" + val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) + (0..7).map { bytes[it].toLong() and 0xff shl 8*(7-it) }.reduce(Long::or) and Long.MAX_VALUE + } + + /** + * Headers used for requests. + */ + val headers: Headers by lazy { headersBuilder().build() } + + /** + * Default network client for doing requests. + */ + open val client: OkHttpClient + get() = network.client + + /** + * Headers builder for requests. Implementations can override this method for custom headers. + */ + open protected fun headersBuilder() = Headers.Builder().apply { + add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") + } + + /** + * Visible name of the source. + */ + override fun toString() = "$name (${lang.toUpperCase()})" + + /** + * Returns an observable containing a page with a list of manga. Normally it's not needed to + * override this method. + * + * @param page the page number to retrieve. + */ + override fun fetchPopularManga(page: Int): Observable { + return client.newCall(popularMangaRequest(page)) + .asObservableSuccess() + .map { response -> + popularMangaParse(response) + } + } + + /** + * Returns the request for the popular manga given the page. + * + * @param page the page number to retrieve. + */ + abstract protected fun popularMangaRequest(page: Int): Request + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + abstract protected fun popularMangaParse(response: Response): MangasPage + + /** + * Returns an observable containing a page with a list of manga. Normally it's not needed to + * override this method. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { response -> + searchMangaParse(response) + } + } + + /** + * Returns the request for the search manga given the page. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + abstract protected fun searchMangaParse(response: Response): MangasPage + + /** + * Returns an observable containing a page with a list of latest manga updates. + * + * @param page the page number to retrieve. + */ + override fun fetchLatestUpdates(page: Int): Observable { + return client.newCall(latestUpdatesRequest(page)) + .asObservableSuccess() + .map { response -> + latestUpdatesParse(response) + } + } + + /** + * Returns the request for latest manga given the page. + * + * @param page the page number to retrieve. + */ + abstract protected fun latestUpdatesRequest(page: Int): Request + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + abstract protected fun latestUpdatesParse(response: Response): MangasPage + + /** + * Returns an observable with the updated details for a manga. Normally it's not needed to + * override this method. + * + * @param manga the manga to be updated. + */ + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(mangaDetailsRequest(manga)) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + /** + * Returns the request for the details of a manga. Override only if it's needed to change the + * url, send different headers or request method like POST. + * + * @param manga the manga to be updated. + */ + open fun mangaDetailsRequest(manga: SManga): Request { + return GET(baseUrl + manga.url, headers) + } + + /** + * Parses the response from the site and returns the details of a manga. + * + * @param response the response from the site. + */ + abstract protected fun mangaDetailsParse(response: Response): SManga + + /** + * Returns an observable with the updated chapter list for a manga. Normally it's not needed to + * override this method. + * + * @param manga the manga to look for chapters. + */ + override fun fetchChapterList(manga: SManga): Observable> { + return client.newCall(chapterListRequest(manga)) + .asObservableSuccess() + .map { response -> + chapterListParse(response) + } + } + + /** + * Returns the request for updating the chapter list. Override only if it's needed to override + * the url, send different headers or request method like POST. + * + * @param manga the manga to look for chapters. + */ + open protected fun chapterListRequest(manga: SManga): Request { + return GET(baseUrl + manga.url, headers) + } + + /** + * Parses the response from the site and returns a list of chapters. + * + * @param response the response from the site. + */ + abstract protected fun chapterListParse(response: Response): List + + /** + * Returns an observable with the page list for a chapter. + * + * @param chapter the chapter whose page list has to be fetched. + */ + override fun fetchPageList(chapter: SChapter): Observable> { + return client.newCall(pageListRequest(chapter)) + .asObservableSuccess() + .map { response -> + pageListParse(response) + } + } + + /** + * Returns the request for getting the page list. Override only if it's needed to override the + * url, send different headers or request method like POST. + * + * @param chapter the chapter whose page list has to be fetched. + */ + open protected fun pageListRequest(chapter: SChapter): Request { + return GET(baseUrl + chapter.url, headers) + } + + /** + * Parses the response from the site and returns a list of pages. + * + * @param response the response from the site. + */ + abstract protected fun pageListParse(response: Response): List + + /** + * Returns an observable with the page containing the source url of the image. If there's any + * error, it will return null instead of throwing an exception. + * + * @param page the page whose source image has to be fetched. + */ + open fun fetchImageUrl(page: Page): Observable { + return client.newCall(imageUrlRequest(page)) + .asObservableSuccess() + .map { imageUrlParse(it) } + } + + /** + * Returns the request for getting the url to the source image. Override only if it's needed to + * override the url, send different headers or request method like POST. + * + * @param page the chapter whose page list has to be fetched + */ + open protected fun imageUrlRequest(page: Page): Request { + return GET(page.url, headers) + } + + /** + * Parses the response from the site and returns the absolute url to the source image. + * + * @param response the response from the site. + */ + abstract protected fun imageUrlParse(response: Response): String + + /** + * Returns an observable with the response of the source image. + * + * @param page the page whose source image has to be downloaded. + */ + fun fetchImage(page: Page): Observable { + return client.newCallWithProgress(imageRequest(page), page) + .asObservableSuccess() + } + + /** + * Returns the request for getting the source image. Override only if it's needed to override + * the url, send different headers or request method like POST. + * + * @param page the chapter whose page list has to be fetched + */ + open protected fun imageRequest(page: Page): Request { + return GET(page.imageUrl!!, headers) + } + + /** + * Assigns the url of the chapter without the scheme and domain. It saves some redundancy from + * database and the urls could still work after a domain change. + * + * @param url the full url to the chapter. + */ + fun SChapter.setUrlWithoutDomain(url: String) { + this.url = getUrlWithoutDomain(url) + } + + /** + * Assigns the url of the manga without the scheme and domain. It saves some redundancy from + * database and the urls could still work after a domain change. + * + * @param url the full url to the manga. + */ + fun SManga.setUrlWithoutDomain(url: String) { + this.url = getUrlWithoutDomain(url) + } + + /** + * Returns the url of the given string without the scheme and domain. + * + * @param orig the full url. + */ + private fun getUrlWithoutDomain(orig: String): String { + try { + val uri = URI(orig) + var out = uri.path + if (uri.query != null) + out += "?" + uri.query + if (uri.fragment != null) + out += "#" + uri.fragment + return out + } catch (e: URISyntaxException) { + return orig + } + } + + /** + * Called before inserting a new chapter into database. Use it if you need to override chapter + * fields, like the title or the chapter number. Do not change anything to [manga]. + * + * @param chapter the chapter to be added. + * @param manga the manga of the chapter. + */ + open fun prepareNewChapter(chapter: SChapter, manga: SManga) { + } + + /** + * Returns the list of filters for the source. + */ + override fun getFilterList() = FilterList() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt new file mode 100644 index 000000000..02810c7bd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt @@ -0,0 +1,98 @@ +package eu.kanade.tachiyomi.source.online + +import android.net.Uri +import eu.kanade.tachiyomi.data.cache.ChapterCache +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.source.model.Page +import rx.Observable +import uy.kohesive.injekt.injectLazy + + +// TODO: this should be handled with a different approach. + +/** + * Chapter cache. + */ +private val chapterCache: ChapterCache by injectLazy() + +/** + * Returns an observable with the page list for a chapter. It tries to return the page list from + * the local cache, otherwise fallbacks to network. + * + * @param chapter the chapter whose page list has to be fetched. + */ +fun HttpSource.fetchPageListFromCacheThenNet(chapter: Chapter): Observable> { + return chapterCache + .getPageListFromCache(chapter) + .onErrorResumeNext { fetchPageList(chapter) } +} + +/** + * Returns an observable of the page with the downloaded image. + * + * @param page the page whose source image has to be downloaded. + */ +fun HttpSource.fetchImageFromCacheThenNet(page: Page): Observable { + return if (page.imageUrl.isNullOrEmpty()) + getImageUrl(page).flatMap { getCachedImage(it) } + else + getCachedImage(page) +} + +fun HttpSource.getImageUrl(page: Page): Observable { + page.status = Page.LOAD_PAGE + return fetchImageUrl(page) + .doOnError { page.status = Page.ERROR } + .onErrorReturn { null } + .doOnNext { page.imageUrl = it } + .map { page } +} + +/** + * Returns an observable of the page that gets the image from the chapter or fallbacks to + * network and copies it to the cache calling [cacheImage]. + * + * @param page the page. + */ +fun HttpSource.getCachedImage(page: Page): Observable { + val imageUrl = page.imageUrl ?: return Observable.just(page) + + return Observable.just(page) + .flatMap { + if (!chapterCache.isImageInCache(imageUrl)) { + cacheImage(page) + } else { + Observable.just(page) + } + } + .doOnNext { + page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl)) + page.status = Page.READY + } + .doOnError { page.status = Page.ERROR } + .onErrorReturn { page } +} + +/** + * Returns an observable of the page that downloads the image to [ChapterCache]. + * + * @param page the page. + */ +private fun HttpSource.cacheImage(page: Page): Observable { + page.status = Page.DOWNLOAD_IMAGE + return fetchImage(page) + .doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) } + .map { page } +} + +fun HttpSource.fetchAllImageUrlsFromPageList(pages: List): Observable { + return Observable.from(pages) + .filter { !it.imageUrl.isNullOrEmpty() } + .mergeWith(fetchRemainingImageUrlsFromPageList(pages)) +} + +fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List): Observable { + return Observable.from(pages) + .filter { it.imageUrl.isNullOrEmpty() } + .concatMap { getImageUrl(it) } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/LoginSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt similarity index 71% rename from app/src/main/java/eu/kanade/tachiyomi/data/source/online/LoginSource.kt rename to app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt index 0c9917f7c..61ec4fd35 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/LoginSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt @@ -1,15 +1,15 @@ -package eu.kanade.tachiyomi.data.source.online - -import eu.kanade.tachiyomi.data.source.Source -import okhttp3.Response -import rx.Observable - -interface LoginSource : Source { - - fun isLogged(): Boolean - - fun login(username: String, password: String): Observable - - fun isAuthenticationSuccessful(response: Response): Boolean - +package eu.kanade.tachiyomi.source.online + +import eu.kanade.tachiyomi.source.Source +import okhttp3.Response +import rx.Observable + +interface LoginSource : Source { + + fun isLogged(): Boolean + + fun login(username: String, password: String): Observable + + fun isAuthenticationSuccessful(response: Response): Boolean + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt new file mode 100644 index 000000000..6053fc2b6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt @@ -0,0 +1,200 @@ +package eu.kanade.tachiyomi.source.online + +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +/** + * A simple implementation for sources from a website using Jsoup, an HTML parser. + */ +abstract class ParsedHttpSource : HttpSource() { + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(popularMangaSelector()).map { element -> + popularMangaFromElement(element) + } + + val hasNextPage = popularMangaNextPageSelector()?.let { selector -> + document.select(selector).first() + } != null + + return MangasPage(mangas, hasNextPage) + } + + /** + * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. + */ + abstract protected fun popularMangaSelector(): String + + /** + * Returns a manga from the given [element]. Most sites only show the title and the url, it's + * totally fine to fill only those two values. + * + * @param element an element obtained from [popularMangaSelector]. + */ + abstract protected fun popularMangaFromElement(element: Element): SManga + + /** + * Returns the Jsoup selector that returns the tag linking to the next page, or null if + * there's no next page. + */ + abstract protected fun popularMangaNextPageSelector(): String? + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(searchMangaSelector()).map { element -> + searchMangaFromElement(element) + } + + val hasNextPage = searchMangaNextPageSelector()?.let { selector -> + document.select(selector).first() + } != null + + return MangasPage(mangas, hasNextPage) + } + + /** + * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. + */ + abstract protected fun searchMangaSelector(): String + + /** + * Returns a manga from the given [element]. Most sites only show the title and the url, it's + * totally fine to fill only those two values. + * + * @param element an element obtained from [searchMangaSelector]. + */ + abstract protected fun searchMangaFromElement(element: Element): SManga + + /** + * Returns the Jsoup selector that returns the tag linking to the next page, or null if + * there's no next page. + */ + abstract protected fun searchMangaNextPageSelector(): String? + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(latestUpdatesSelector()).map { element -> + latestUpdatesFromElement(element) + } + + val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> + document.select(selector).first() + } != null + + return MangasPage(mangas, hasNextPage) + } + + /** + * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. + */ + abstract protected fun latestUpdatesSelector(): String + + /** + * Returns a manga from the given [element]. Most sites only show the title and the url, it's + * totally fine to fill only those two values. + * + * @param element an element obtained from [latestUpdatesSelector]. + */ + abstract protected fun latestUpdatesFromElement(element: Element): SManga + + /** + * Returns the Jsoup selector that returns the tag linking to the next page, or null if + * there's no next page. + */ + abstract protected fun latestUpdatesNextPageSelector(): String? + + /** + * Parses the response from the site and returns the details of a manga. + * + * @param response the response from the site. + */ + override fun mangaDetailsParse(response: Response): SManga { + return mangaDetailsParse(response.asJsoup()) + } + + /** + * Returns the details of the manga from the given [document]. + * + * @param document the parsed document. + */ + abstract protected fun mangaDetailsParse(document: Document): SManga + + /** + * Parses the response from the site and returns a list of chapters. + * + * @param response the response from the site. + */ + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + return document.select(chapterListSelector()).map { chapterFromElement(it) } + } + + /** + * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. + */ + abstract protected fun chapterListSelector(): String + + /** + * Returns a chapter from the given element. + * + * @param element an element obtained from [chapterListSelector]. + */ + abstract protected fun chapterFromElement(element: Element): SChapter + + /** + * Parses the response from the site and returns the page list. + * + * @param response the response from the site. + */ + override fun pageListParse(response: Response): List { + return pageListParse(response.asJsoup()) + } + + /** + * Returns a page list from the given document. + * + * @param document the parsed document. + */ + abstract protected fun pageListParse(document: Document): List + + /** + * Parse the response from the site and returns the absolute url to the source image. + * + * @param response the response from the site. + */ + override fun imageUrlParse(response: Response): String { + return imageUrlParse(response.asJsoup()) + } + + /** + * Returns the absolute url to the source image from the document. + * + * @param document the parsed document. + */ + abstract protected fun imageUrlParse(document: Document): String +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/YamlOnlineSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt similarity index 59% rename from app/src/main/java/eu/kanade/tachiyomi/data/source/online/YamlOnlineSource.kt rename to app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt index 4b2f3bcb2..2d907a53b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/YamlOnlineSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt @@ -1,212 +1,232 @@ -package eu.kanade.tachiyomi.data.source.online - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.network.GET -import eu.kanade.tachiyomi.data.network.POST -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.util.asJsoup -import eu.kanade.tachiyomi.util.attrOrText -import okhttp3.Request -import okhttp3.Response -import org.jsoup.Jsoup -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.* - -class YamlOnlineSource(mappings: Map<*, *>) : OnlineSource() { - - val map = YamlSourceNode(mappings) - - override val name: String - get() = map.name - - override val baseUrl = map.host.let { - if (it.endsWith("/")) it.dropLast(1) else it - } - - override val lang = map.lang.toLowerCase() - - override val supportsLatest = map.latestupdates != null - - override val client = when(map.client) { - "cloudflare" -> network.cloudflareClient - else -> network.client - } - - override val id = map.id.let { - if (it is Int) it else (lang.toUpperCase().hashCode() + 31 * it.hashCode()) and 0x7fffffff - } - - override fun popularMangaRequest(page: MangasPage): Request { - if (page.page == 1) { - page.url = popularMangaInitialUrl() - } - return when (map.popular.method?.toLowerCase()) { - "post" -> POST(page.url, headers, map.popular.createForm()) - else -> GET(page.url, headers) - } - } - - override fun popularMangaInitialUrl() = map.popular.url - - override fun popularMangaParse(response: Response, page: MangasPage) { - val document = response.asJsoup() - for (element in document.select(map.popular.manga_css)) { - Manga.create(id).apply { - title = element.text() - setUrlWithoutDomain(element.attr("href")) - page.mangas.add(this) - } - } - - map.popular.next_url_css?.let { selector -> - page.nextPageUrl = document.select(selector).first()?.absUrl("href") - } - } - - override fun searchMangaRequest(page: MangasPage, query: String, filters: List): Request { - if (page.page == 1) { - page.url = searchMangaInitialUrl(query, filters) - } - return when (map.search.method?.toLowerCase()) { - "post" -> POST(page.url, headers, map.search.createForm()) - else -> GET(page.url, headers) - } - } - - override fun searchMangaInitialUrl(query: String, filters: List) = map.search.url.replace("\$query", query) - - override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List) { - val document = response.asJsoup() - for (element in document.select(map.search.manga_css)) { - Manga.create(id).apply { - title = element.text() - setUrlWithoutDomain(element.attr("href")) - page.mangas.add(this) - } - } - - map.search.next_url_css?.let { selector -> - page.nextPageUrl = document.select(selector).first()?.absUrl("href") - } - } - - override fun latestUpdatesRequest(page: MangasPage): Request { - if (page.page == 1) { - page.url = latestUpdatesInitialUrl() - } - return when (map.latestupdates!!.method?.toLowerCase()) { - "post" -> POST(page.url, headers, map.latestupdates.createForm()) - else -> GET(page.url, headers) - } - } - - override fun latestUpdatesInitialUrl() = map.latestupdates!!.url - - override fun latestUpdatesParse(response: Response, page: MangasPage) { - val document = response.asJsoup() - for (element in document.select(map.latestupdates!!.manga_css)) { - Manga.create(id).apply { - title = element.text() - setUrlWithoutDomain(element.attr("href")) - page.mangas.add(this) - } - } - - map.latestupdates.next_url_css?.let { selector -> - page.nextPageUrl = document.select(selector).first()?.absUrl("href") - } - } - - override fun mangaDetailsParse(response: Response, manga: Manga) { - val document = response.asJsoup() - with(map.manga) { - val pool = parts.get(document) - - manga.author = author?.process(document, pool) - manga.artist = artist?.process(document, pool) - manga.description = summary?.process(document, pool) - manga.thumbnail_url = cover?.process(document, pool) - manga.genre = genres?.process(document, pool) - manga.status = status?.getStatus(document, pool) ?: Manga.UNKNOWN - } - } - - override fun chapterListParse(response: Response, chapters: MutableList) { - val document = response.asJsoup() - with(map.chapters) { - val pool = emptyMap() - val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH) - - for (element in document.select(chapter_css)) { - val chapter = Chapter.create() - element.select(title).first().let { - chapter.name = it.text() - chapter.setUrlWithoutDomain(it.attr("href")) - } - val dateElement = element.select(date?.select).first() - chapter.date_upload = date?.getDate(dateElement, pool, dateFormat)?.time ?: 0 - chapters.add(chapter) - } - } - } - - override fun pageListParse(response: Response, pages: MutableList) { - val body = response.body().string() - val url = response.request().url().toString() - - // TODO lazy initialization in Kotlin 1.1 - val document = Jsoup.parse(body, url) - - with(map.pages) { - // Capture a list of values where page urls will be resolved. - val capturedPages = if (pages_regex != null) - pages_regex!!.toRegex().findAll(body).map { it.value }.toList() - else if (pages_css != null) - document.select(pages_css).map { it.attrOrText(pages_attr!!) } - else - null - - // For each captured value, obtain the url and create a new page. - capturedPages?.forEach { value -> - // If the captured value isn't an url, we have to use replaces with the chapter url. - val pageUrl = if (replace != null && replacement != null) - url.replace(replace!!.toRegex(), replacement!!.replace("\$value", value)) - else - value - - pages.add(Page(pages.size, pageUrl)) - } - - // Capture a list of images. - val capturedImages = if (image_regex != null) - image_regex!!.toRegex().findAll(body).map { it.groups[1]?.value }.toList() - else if (image_css != null) - document.select(image_css).map { it.absUrl(image_attr) } - else - null - - // Assign the image url to each page - capturedImages?.forEachIndexed { i, url -> - val page = pages.getOrElse(i) { Page(i, "").apply { pages.add(this) } } - page.imageUrl = url - } - } - } - - override fun imageUrlParse(response: Response): String { - val body = response.body().string() - val url = response.request().url().toString() - - with(map.pages) { - return if (image_regex != null) - image_regex!!.toRegex().find(body)!!.groups[1]!!.value - else if (image_css != null) - Jsoup.parse(body, url).select(image_css).first().absUrl(image_attr) - else - throw Exception("image_regex and image_css are null") - } - } -} +package eu.kanade.tachiyomi.source.online + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.util.attrOrText +import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.* + +class YamlHttpSource(mappings: Map<*, *>) : HttpSource() { + + val map = YamlSourceNode(mappings) + + override val name: String + get() = map.name + + override val baseUrl = map.host.let { + if (it.endsWith("/")) it.dropLast(1) else it + } + + override val lang = map.lang.toLowerCase() + + override val supportsLatest = map.latestupdates != null + + override val client = when (map.client) { + "cloudflare" -> network.cloudflareClient + else -> network.client + } + + override val id = map.id.let { + (it as? Int ?: (lang.toUpperCase().hashCode() + 31 * it.hashCode()) and 0x7fffffff).toLong() + } + + // Ugly, but needed after the changes + var popularNextPage: String? = null + var searchNextPage: String? = null + var latestNextPage: String? = null + + override fun popularMangaRequest(page: Int): Request { + val url = if (page == 1) { + popularNextPage = null + map.popular.url + } else { + popularNextPage!! + } + return when (map.popular.method?.toLowerCase()) { + "post" -> POST(url, headers, map.popular.createForm()) + else -> GET(url, headers) + } + } + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(map.popular.manga_css).map { element -> + SManga.create().apply { + title = element.text() + setUrlWithoutDomain(element.attr("href")) + } + } + + popularNextPage = map.popular.next_url_css?.let { selector -> + document.select(selector).first()?.absUrl("href") + } + + return MangasPage(mangas, popularNextPage != null) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = if (page == 1) { + searchNextPage = null + map.search.url.replace("\$query", query) + } else { + searchNextPage!! + } + return when (map.search.method?.toLowerCase()) { + "post" -> POST(url, headers, map.search.createForm()) + else -> GET(url, headers) + } + } + + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(map.search.manga_css).map { element -> + SManga.create().apply { + title = element.text() + setUrlWithoutDomain(element.attr("href")) + } + } + + searchNextPage = map.search.next_url_css?.let { selector -> + document.select(selector).first()?.absUrl("href") + } + + return MangasPage(mangas, searchNextPage != null) + } + + override fun latestUpdatesRequest(page: Int): Request { + val url = if (page == 1) { + latestNextPage = null + map.latestupdates!!.url + } else { + latestNextPage!! + } + return when (map.latestupdates!!.method?.toLowerCase()) { + "post" -> POST(url, headers, map.latestupdates.createForm()) + else -> GET(url, headers) + } + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(map.latestupdates!!.manga_css).map { element -> + SManga.create().apply { + title = element.text() + setUrlWithoutDomain(element.attr("href")) + } + } + + popularNextPage = map.latestupdates.next_url_css?.let { selector -> + document.select(selector).first()?.absUrl("href") + } + + return MangasPage(mangas, popularNextPage != null) + } + + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + + val manga = SManga.create() + with(map.manga) { + val pool = parts.get(document) + + manga.author = author?.process(document, pool) + manga.artist = artist?.process(document, pool) + manga.description = summary?.process(document, pool) + manga.thumbnail_url = cover?.process(document, pool) + manga.genre = genres?.process(document, pool) + manga.status = status?.getStatus(document, pool) ?: SManga.UNKNOWN + } + return manga + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + + val chapters = mutableListOf() + with(map.chapters) { + val pool = emptyMap() + val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH) + + for (element in document.select(chapter_css)) { + val chapter = SChapter.create() + element.select(title).first().let { + chapter.name = it.text() + chapter.setUrlWithoutDomain(it.attr("href")) + } + val dateElement = element.select(date?.select).first() + chapter.date_upload = date?.getDate(dateElement, pool, dateFormat)?.time ?: 0 + chapters.add(chapter) + } + } + return chapters + } + + override fun pageListParse(response: Response): List { + val body = response.body().string() + val url = response.request().url().toString() + + val pages = mutableListOf() + + // TODO lazy initialization in Kotlin 1.1 + val document = Jsoup.parse(body, url) + + with(map.pages) { + // Capture a list of values where page urls will be resolved. + val capturedPages = if (pages_regex != null) + pages_regex!!.toRegex().findAll(body).map { it.value }.toList() + else if (pages_css != null) + document.select(pages_css).map { it.attrOrText(pages_attr!!) } + else + null + + // For each captured value, obtain the url and create a new page. + capturedPages?.forEach { value -> + // If the captured value isn't an url, we have to use replaces with the chapter url. + val pageUrl = if (replace != null && replacement != null) + url.replace(replace!!.toRegex(), replacement!!.replace("\$value", value)) + else + value + + pages.add(Page(pages.size, pageUrl)) + } + + // Capture a list of images. + val capturedImages = if (image_regex != null) + image_regex!!.toRegex().findAll(body).map { it.groups[1]?.value }.toList() + else if (image_css != null) + document.select(image_css).map { it.absUrl(image_attr) } + else + null + + // Assign the image url to each page + capturedImages?.forEachIndexed { i, url -> + val page = pages.getOrElse(i) { Page(i, "").apply { pages.add(this) } } + page.imageUrl = url + } + } + return pages + } + + override fun imageUrlParse(response: Response): String { + val body = response.body().string() + val url = response.request().url().toString() + + with(map.pages) { + return if (image_regex != null) + image_regex!!.toRegex().find(body)!!.groups[1]!!.value + else if (image_css != null) + Jsoup.parse(body, url).select(image_css).first().absUrl(image_attr) + else + throw Exception("image_regex and image_css are null") + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/YamlOnlineSourceMappings.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/data/source/online/YamlOnlineSourceMappings.kt rename to app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt index e4b643481..ba07594c3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/YamlOnlineSourceMappings.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt @@ -1,234 +1,234 @@ -@file:Suppress("UNCHECKED_CAST") - -package eu.kanade.tachiyomi.data.source.online - -import eu.kanade.tachiyomi.data.database.models.Manga -import okhttp3.FormBody -import okhttp3.RequestBody -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.ParseException -import java.text.SimpleDateFormat -import java.util.* - -private fun toMap(map: Any?) = map as? Map - -class YamlSourceNode(uncheckedMap: Map<*, *>) { - - val map = toMap(uncheckedMap)!! - - val id: Any by map - - val name: String by map - - val host: String by map - - val lang: String by map - - val client: String? - get() = map["client"] as? String - - val popular = PopularNode(toMap(map["popular"])!!) - - val latestupdates = toMap(map["latest_updates"])?.let { LatestUpdatesNode(it) } - - val search = SearchNode(toMap(map["search"])!!) - - val manga = MangaNode(toMap(map["manga"])!!) - - val chapters = ChaptersNode(toMap(map["chapters"])!!) - - val pages = PagesNode(toMap(map["pages"])!!) -} - -interface RequestableNode { - - val map: Map - - val url: String - get() = map["url"] as String - - val method: String? - get() = map["method"] as? String - - val payload: Map? - get() = map["payload"] as? Map - - fun createForm(): RequestBody { - return FormBody.Builder().apply { - payload?.let { - for ((key, value) in it) { - add(key, value) - } - } - }.build() - } - -} - -class PopularNode(override val map: Map): RequestableNode { - - val manga_css: String by map - - val next_url_css: String? - get() = map["next_url_css"] as? String - -} - - -class LatestUpdatesNode(override val map: Map): RequestableNode { - - val manga_css: String by map - - val next_url_css: String? - get() = map["next_url_css"] as? String - -} - - -class SearchNode(override val map: Map): RequestableNode { - - val manga_css: String by map - - val next_url_css: String? - get() = map["next_url_css"] as? String -} - -class MangaNode(private val map: Map) { - - val parts = CacheNode(toMap(map["parts"]) ?: emptyMap()) - - val artist = toMap(map["artist"])?.let { SelectableNode(it) } - - val author = toMap(map["author"])?.let { SelectableNode(it) } - - val summary = toMap(map["summary"])?.let { SelectableNode(it) } - - val status = toMap(map["status"])?.let { StatusNode(it) } - - val genres = toMap(map["genres"])?.let { SelectableNode(it) } - - val cover = toMap(map["cover"])?.let { CoverNode(it) } - -} - -class ChaptersNode(private val map: Map) { - - val chapter_css: String by map - - val title: String by map - - val date = toMap(toMap(map["date"]))?.let { DateNode(it) } -} - -class CacheNode(private val map: Map) { - - fun get(document: Document) = map.mapValues { document.select(it.value as String).first() } -} - -open class SelectableNode(private val map: Map) { - - val select: String by map - - val from: String? - get() = map["from"] as? String - - open val attr: String? - get() = map["attr"] as? String - - val capture: String? - get() = map["capture"] as? String - - fun process(document: Element, cache: Map): String { - val parent = from?.let { cache[it] } ?: document - val node = parent.select(select).first() - var text = attr?.let { node.attr(it) } ?: node.text() - capture?.let { - text = Regex(it).find(text)?.groupValues?.get(1) ?: text - } - return text - } -} - -class StatusNode(private val map: Map) : SelectableNode(map) { - - val complete: String? - get() = map["complete"] as? String - - val ongoing: String? - get() = map["ongoing"] as? String - - val licensed: String? - get() = map["licensed"] as? String - - fun getStatus(document: Element, cache: Map): Int { - val text = process(document, cache) - complete?.let { - if (text.contains(it)) return Manga.COMPLETED - } - ongoing?.let { - if (text.contains(it)) return Manga.ONGOING - } - licensed?.let { - if (text.contains(it)) return Manga.LICENSED - } - return Manga.UNKNOWN - } -} - -class CoverNode(private val map: Map) : SelectableNode(map) { - - override val attr: String? - get() = map["attr"] as? String ?: "src" -} - -class DateNode(private val map: Map) : SelectableNode(map) { - - val format: String by map - - fun getDate(document: Element, cache: Map, formatter: SimpleDateFormat): Date { - val text = process(document, cache) - try { - return formatter.parse(text) - } catch (exception: ParseException) {} - - for (i in 0..7) { - (map["day$i"] as? List)?.let { - it.find { it.toRegex().containsMatchIn(text) }?.let { - return Calendar.getInstance().apply { add(Calendar.DATE, -i) }.time - } - } - } - - return Date(0) - } - -} - -class PagesNode(private val map: Map) { - - val pages_regex: String? - get() = map["pages_regex"] as? String - - val pages_css: String? - get() = map["pages_css"] as? String - - val pages_attr: String? - get() = map["pages_attr"] as? String ?: "value" - - val replace: String? - get() = map["url_replace"] as? String - - val replacement: String? - get() = map["url_replacement"] as? String - - val image_regex: String? - get() = map["image_regex"] as? String - - val image_css: String? - get() = map["image_css"] as? String - - val image_attr: String - get() = map["image_attr"] as? String ?: "src" - +@file:Suppress("UNCHECKED_CAST") + +package eu.kanade.tachiyomi.source.online + +import eu.kanade.tachiyomi.source.model.SManga +import okhttp3.FormBody +import okhttp3.RequestBody +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* + +private fun toMap(map: Any?) = map as? Map + +class YamlSourceNode(uncheckedMap: Map<*, *>) { + + val map = toMap(uncheckedMap)!! + + val id: Any by map + + val name: String by map + + val host: String by map + + val lang: String by map + + val client: String? + get() = map["client"] as? String + + val popular = PopularNode(toMap(map["popular"])!!) + + val latestupdates = toMap(map["latest_updates"])?.let { LatestUpdatesNode(it) } + + val search = SearchNode(toMap(map["search"])!!) + + val manga = MangaNode(toMap(map["manga"])!!) + + val chapters = ChaptersNode(toMap(map["chapters"])!!) + + val pages = PagesNode(toMap(map["pages"])!!) +} + +interface RequestableNode { + + val map: Map + + val url: String + get() = map["url"] as String + + val method: String? + get() = map["method"] as? String + + val payload: Map? + get() = map["payload"] as? Map + + fun createForm(): RequestBody { + return FormBody.Builder().apply { + payload?.let { + for ((key, value) in it) { + add(key, value) + } + } + }.build() + } + +} + +class PopularNode(override val map: Map): RequestableNode { + + val manga_css: String by map + + val next_url_css: String? + get() = map["next_url_css"] as? String + +} + + +class LatestUpdatesNode(override val map: Map): RequestableNode { + + val manga_css: String by map + + val next_url_css: String? + get() = map["next_url_css"] as? String + +} + + +class SearchNode(override val map: Map): RequestableNode { + + val manga_css: String by map + + val next_url_css: String? + get() = map["next_url_css"] as? String +} + +class MangaNode(private val map: Map) { + + val parts = CacheNode(toMap(map["parts"]) ?: emptyMap()) + + val artist = toMap(map["artist"])?.let { SelectableNode(it) } + + val author = toMap(map["author"])?.let { SelectableNode(it) } + + val summary = toMap(map["summary"])?.let { SelectableNode(it) } + + val status = toMap(map["status"])?.let { StatusNode(it) } + + val genres = toMap(map["genres"])?.let { SelectableNode(it) } + + val cover = toMap(map["cover"])?.let { CoverNode(it) } + +} + +class ChaptersNode(private val map: Map) { + + val chapter_css: String by map + + val title: String by map + + val date = toMap(toMap(map["date"]))?.let { DateNode(it) } +} + +class CacheNode(private val map: Map) { + + fun get(document: Document) = map.mapValues { document.select(it.value as String).first() } +} + +open class SelectableNode(private val map: Map) { + + val select: String by map + + val from: String? + get() = map["from"] as? String + + open val attr: String? + get() = map["attr"] as? String + + val capture: String? + get() = map["capture"] as? String + + fun process(document: Element, cache: Map): String { + val parent = from?.let { cache[it] } ?: document + val node = parent.select(select).first() + var text = attr?.let { node.attr(it) } ?: node.text() + capture?.let { + text = Regex(it).find(text)?.groupValues?.get(1) ?: text + } + return text + } +} + +class StatusNode(private val map: Map) : SelectableNode(map) { + + val complete: String? + get() = map["complete"] as? String + + val ongoing: String? + get() = map["ongoing"] as? String + + val licensed: String? + get() = map["licensed"] as? String + + fun getStatus(document: Element, cache: Map): Int { + val text = process(document, cache) + complete?.let { + if (text.contains(it)) return SManga.COMPLETED + } + ongoing?.let { + if (text.contains(it)) return SManga.ONGOING + } + licensed?.let { + if (text.contains(it)) return SManga.LICENSED + } + return SManga.UNKNOWN + } +} + +class CoverNode(private val map: Map) : SelectableNode(map) { + + override val attr: String? + get() = map["attr"] as? String ?: "src" +} + +class DateNode(private val map: Map) : SelectableNode(map) { + + val format: String by map + + fun getDate(document: Element, cache: Map, formatter: SimpleDateFormat): Date { + val text = process(document, cache) + try { + return formatter.parse(text) + } catch (exception: ParseException) {} + + for (i in 0..7) { + (map["day$i"] as? List)?.let { + it.find { it.toRegex().containsMatchIn(text) }?.let { + return Calendar.getInstance().apply { add(Calendar.DATE, -i) }.time + } + } + } + + return Date(0) + } + +} + +class PagesNode(private val map: Map) { + + val pages_regex: String? + get() = map["pages_regex"] as? String + + val pages_css: String? + get() = map["pages_css"] as? String + + val pages_attr: String? + get() = map["pages_attr"] as? String ?: "value" + + val replace: String? + get() = map["url_replace"] as? String + + val replacement: String? + get() = map["url_replacement"] as? String + + val image_regex: String? + get() = map["image_regex"] as? String + + val image_css: String? + get() = map["image_css"] as? String + + val image_attr: String + get() = map["image_attr"] as? String ?: "src" + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/all/EHentai.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt similarity index 99% rename from app/src/main/java/eu/kanade/tachiyomi/data/source/online/all/EHentai.kt rename to app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt index 832197ea8..0cf7c336d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/all/EHentai.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.source.online.all +package eu.kanade.tachiyomi.source.online.all import android.content.Context import android.net.Uri diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/all/EHentaiMetadata.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentaiMetadata.kt similarity index 98% rename from app/src/main/java/eu/kanade/tachiyomi/data/source/online/all/EHentaiMetadata.kt rename to app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentaiMetadata.kt index 506995c0b..158b9fe1f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/all/EHentaiMetadata.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentaiMetadata.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.source.online.all +package eu.kanade.tachiyomi.source.online.all import android.content.Context import eu.kanade.tachiyomi.data.database.models.Chapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt new file mode 100644 index 000000000..d8f35cfb2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt @@ -0,0 +1,366 @@ +package eu.kanade.tachiyomi.source.online.english + +import android.text.Html +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.LoginSource +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.util.selectText +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import java.net.URI +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Pattern + +class Batoto : ParsedHttpSource(), LoginSource { + + override val id: Long = 1 + + override val name = "Batoto" + + override val baseUrl = "http://bato.to" + + override val lang = "en" + + override val supportsLatest = true + + private val datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*") + + private val dateFields = HashMap().apply { + put("second", Calendar.SECOND) + put("minute", Calendar.MINUTE) + put("hour", Calendar.HOUR) + put("day", Calendar.DATE) + put("week", Calendar.WEEK_OF_YEAR) + put("month", Calendar.MONTH) + put("year", Calendar.YEAR) + } + + private val staffNotice = Pattern.compile("=+Batoto Staff Notice=+([^=]+)==+", Pattern.CASE_INSENSITIVE) + + override fun headersBuilder() = super.headersBuilder() + .add("Cookie", "lang_option=English") + + private val pageHeaders = super.headersBuilder() + .add("Referer", "http://bato.to/reader") + .build() + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/search_ajax?order_cond=views&order=desc&p=$page", headers) + } + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/search_ajax?order_cond=update&order=desc&p=$page", headers) + } + + override fun popularMangaSelector() = "tr:has(a)" + + override fun latestUpdatesSelector() = "tr:has(a)" + + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("a[href^=http://bato.to]").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text().trim() + } + return manga + } + + override fun latestUpdatesFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = "#show_more_row" + + override fun latestUpdatesNextPageSelector() = "#show_more_row" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = HttpUrl.parse("$baseUrl/search_ajax").newBuilder() + if (!query.isEmpty()) url.addQueryParameter("name", query).addQueryParameter("name_cond", "c") + var genres = "" + filters.forEach { filter -> + when (filter) { + is Status -> if (!filter.isIgnored()) { + url.addQueryParameter("completed", if (filter.isExcluded()) "i" else "c") + } + is GenreList -> { + filter.state.forEach { filter -> + when (filter) { + is Genre -> if (!filter.isIgnored()) { + genres += (if (filter.isExcluded()) ";e" else ";i") + filter.id + } + is SelectField -> { + val sel = filter.values[filter.state].value + if (!sel.isEmpty()) url.addQueryParameter(filter.key, sel) + } + } + } + } + is TextField -> { + if (!filter.state.isEmpty()) url.addQueryParameter(filter.key, filter.state) + } + is SelectField -> { + val sel = filter.values[filter.state].value + if (!sel.isEmpty()) url.addQueryParameter(filter.key, sel) + } + is Flag -> { + val sel = if (filter.state) filter.valTrue else filter.valFalse + if (!sel.isEmpty()) url.addQueryParameter(filter.key, sel) + } + is OrderBy -> { + url.addQueryParameter("order_cond", arrayOf("title", "author", "artist", "rating", "views", "update")[filter.state!!.index]) + url.addQueryParameter("order", if (filter.state?.ascending == true) "asc" else "desc") + } + } + } + if (!genres.isEmpty()) url.addQueryParameter("genres", genres) + url.addQueryParameter("p", page.toString()) + return GET(url.toString(), headers) + } + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() + + override fun mangaDetailsRequest(manga: SManga): Request { + val mangaId = manga.url.substringAfterLast("r") + return GET("$baseUrl/comic_pop?id=$mangaId", headers) + } + + override fun mangaDetailsParse(document: Document): SManga { + val tbody = document.select("tbody").first() + val artistElement = tbody.select("tr:contains(Author/Artist:)").first() + + val manga = SManga.create() + manga.author = artistElement.selectText("td:eq(1)") + manga.artist = artistElement.selectText("td:eq(2)") ?: manga.author + manga.description = tbody.selectText("tr:contains(Description:) > td:eq(1)") + manga.thumbnail_url = document.select("img[src^=http://img.bato.to/forums/uploads/]").first()?.attr("src") + manga.status = parseStatus(document.selectText("tr:contains(Status:) > td:eq(1)")) + manga.genre = tbody.select("tr:contains(Genres:) img").map { it.attr("alt") }.joinToString(", ") + return manga + } + + private fun parseStatus(status: String?) = when (status) { + "Ongoing" -> SManga.ONGOING + "Complete" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + override fun chapterListParse(response: Response): List { + val body = response.body().string() + val matcher = staffNotice.matcher(body) + if (matcher.find()) { + @Suppress("DEPRECATION") + val notice = Html.fromHtml(matcher.group(1)).toString().trim() + throw Exception(notice) + } + + val document = response.asJsoup(body) + return document.select(chapterListSelector()).map { chapterFromElement(it) } + } + + override fun chapterListSelector() = "tr.row.lang_English.chapter_row" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select("a[href^=http://bato.to/reader").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href")) + chapter.name = urlElement.text() + chapter.date_upload = element.select("td").getOrNull(4)?.let { + parseDateFromElement(it) + } ?: 0 + return chapter + } + + private fun parseDateFromElement(dateElement: Element): Long { + val dateAsString = dateElement.text() + + var date: Date + try { + date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString) + } catch (e: ParseException) { + val m = datePattern.matcher(dateAsString) + + if (m.matches()) { + val number = m.group(1) + val amount = if (number.contains("A")) 1 else Integer.parseInt(m.group(1)) + val unit = m.group(2) + + date = Calendar.getInstance().apply { + add(dateFields[unit]!!, -amount) + }.time + } else { + return 0 + } + } + + return date.time + } + + override fun pageListRequest(chapter: SChapter): Request { + val id = chapter.url.substringAfterLast("#") + return GET("$baseUrl/areader?id=$id&p=1", pageHeaders) + } + + override fun pageListParse(document: Document): List { + val pages = mutableListOf() + val selectElement = document.select("#page_select").first() + if (selectElement != null) { + for ((i, element) in selectElement.select("option").withIndex()) { + pages.add(Page(i, element.attr("value"))) + } + pages.getOrNull(0)?.imageUrl = imageUrlParse(document) + } else { + // For webtoons in one page + for ((i, element) in document.select("div > img").withIndex()) { + pages.add(Page(i, "", element.attr("src"))) + } + } + return pages + } + + override fun imageUrlRequest(page: Page): Request { + val pageUrl = page.url + val start = pageUrl.indexOf("#") + 1 + val end = pageUrl.indexOf("_", start) + val id = pageUrl.substring(start, end) + return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end + 1)}", pageHeaders) + } + + override fun imageUrlParse(document: Document): String { + return document.select("#comic_page").first().attr("src") + } + + override fun login(username: String, password: String) = + client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global§ion=login", headers)) + .asObservable() + .flatMap { doLogin(it, username, password) } + .map { isAuthenticationSuccessful(it) } + + private fun doLogin(response: Response, username: String, password: String): Observable { + val doc = response.asJsoup() + val form = doc.select("#login").first() + val url = form.attr("action") + val authKey = form.select("input[name=auth_key]").first() + + val payload = FormBody.Builder().apply { + add(authKey.attr("name"), authKey.attr("value")) + add("ips_username", username) + add("ips_password", password) + add("invisible", "1") + add("rememberMe", "1") + }.build() + + return client.newCall(POST(url, headers, payload)).asObservable() + } + + override fun isAuthenticationSuccessful(response: Response) = + response.priorResponse() != null && response.priorResponse().code() == 302 + + override fun isLogged(): Boolean { + return network.cookies.get(URI(baseUrl)).any { it.name() == "pass_hash" } + } + + override fun fetchChapterList(manga: SManga): Observable> { + if (!isLogged()) { + val username = preferences.sourceUsername(this) + val password = preferences.sourcePassword(this) + + if (username.isNullOrEmpty() || password.isNullOrEmpty()) { + return Observable.error(Exception("User not logged")) + } else { + return login(username, password).flatMap { super.fetchChapterList(manga) } + } + + } else { + return super.fetchChapterList(manga) + } + } + + private data class ListValue(val name: String, val value: String) { + override fun toString(): String = name + } + + private class Status : Filter.TriState("Completed") + private class Genre(name: String, val id: Int) : Filter.TriState(name) + private class TextField(name: String, val key: String) : Filter.Text(name) + private class SelectField(name: String, val key: String, values: Array, state: Int = 0) : Filter.Select(name, values, state) + private class Flag(name: String, val key: String, val valTrue: String, val valFalse: String) : Filter.CheckBox(name) + private class GenreList(genres: List>) : Filter.Group>("Genres", genres) + private class OrderBy : Filter.Sort("Order by", + arrayOf("Title", "Author", "Artist", "Rating", "Views", "Last Update"), + Filter.Sort.Selection(4, false)) + + override fun getFilterList() = FilterList( + TextField("Author", "artist_name"), + SelectField("Type", "type", arrayOf(ListValue("Any", ""), ListValue("Manga (Jp)", "jp"), ListValue("Manhwa (Kr)", "kr"), ListValue("Manhua (Cn)", "cn"), ListValue("Artbook", "ar"), ListValue("Other", "ot"))), + Status(), + Flag("Exclude mature", "mature", "m", ""), + OrderBy(), + GenreList(getGenreList()) + ) + + // [...document.querySelectorAll("#advanced_options div.genre_buttons")].map((el,i) => { + // const onClick=el.getAttribute('onclick');const id=onClick.substr(14,onClick.length-16);return `Genre("${el.textContent.trim()}", ${id})` + // }).join(',\n') + // on https://bato.to/search + private fun getGenreList() = listOf( + SelectField("Inclusion mode", "genre_cond", arrayOf(ListValue("And (all selected genres)", "and"), ListValue("Or (any selected genres) ", "or"))), + Genre("4-Koma", 40), + Genre("Action", 1), + Genre("Adventure", 2), + Genre("Award Winning", 39), + Genre("Comedy", 3), + Genre("Cooking", 41), + Genre("Doujinshi", 9), + Genre("Drama", 10), + Genre("Ecchi", 12), + Genre("Fantasy", 13), + Genre("Gender Bender", 15), + Genre("Harem", 17), + Genre("Historical", 20), + Genre("Horror", 22), + Genre("Josei", 34), + Genre("Martial Arts", 27), + Genre("Mecha", 30), + Genre("Medical", 42), + Genre("Music", 37), + Genre("Mystery", 4), + Genre("Oneshot", 38), + Genre("Psychological", 5), + Genre("Romance", 6), + Genre("School Life", 7), + Genre("Sci-fi", 8), + Genre("Seinen", 32), + Genre("Shoujo", 35), + Genre("Shoujo Ai", 16), + Genre("Shounen", 33), + Genre("Shounen Ai", 19), + Genre("Slice of Life", 21), + Genre("Smut", 23), + Genre("Sports", 25), + Genre("Supernatural", 26), + Genre("Tragedy", 28), + Genre("Webtoon", 36), + Genre("Yaoi", 29), + Genre("Yuri", 31), + Genre("[no chapters]", 44) + ) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Kissmanga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Kissmanga.kt new file mode 100644 index 000000000..1ebff79d8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Kissmanga.kt @@ -0,0 +1,197 @@ +package eu.kanade.tachiyomi.source.online.english + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.regex.Pattern + +class Kissmanga : ParsedHttpSource() { + + override val id: Long = 4 + + override val name = "Kissmanga" + + override val baseUrl = "http://kissmanga.com" + + override val lang = "en" + + override val supportsLatest = true + + override val client: OkHttpClient = network.cloudflareClient + + override fun popularMangaSelector() = "table.listing tr:gt(1)" + + override fun latestUpdatesSelector() = "table.listing tr:gt(1)" + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/MangaList/MostPopular?page=$page", headers) + } + + override fun latestUpdatesRequest(page: Int): Request { + return GET("http://kissmanga.com/MangaList/LatestUpdate?page=$page", headers) + } + + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("td a:eq(0)").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text() + } + return manga + } + + override fun latestUpdatesFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = "li > a:contains(› Next)" + + override fun latestUpdatesNextPageSelector(): String = "ul.pager > li > a:contains(Next)" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val form = FormBody.Builder().apply { + add("mangaName", query) + + for (filter in if (filters.isEmpty()) getFilterList() else filters) { + when (filter) { + is Author -> add("authorArtist", filter.state) + is Status -> add("status", arrayOf("", "Completed", "Ongoing")[filter.state]) + is GenreList -> filter.state.forEach { genre -> add("genres", genre.state.toString()) } + } + } + } + return POST("$baseUrl/AdvanceSearch", headers, form.build()) + } + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun searchMangaNextPageSelector() = null + + override fun mangaDetailsParse(document: Document): SManga { + val infoElement = document.select("div.barContent").first() + + val manga = SManga.create() + manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text() + manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text() + manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text() + manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) } + manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src") + return manga + } + + fun parseStatus(status: String) = when { + status.contains("Ongoing") -> SManga.ONGOING + status.contains("Completed") -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + override fun chapterListSelector() = "table.listing tr:gt(1)" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select("a").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href")) + chapter.name = urlElement.text() + chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let { + SimpleDateFormat("MM/dd/yyyy").parse(it).time + } ?: 0 + return chapter + } + + override fun pageListRequest(chapter: SChapter) = POST(baseUrl + chapter.url, headers) + + override fun pageListParse(response: Response): List { + val pages = mutableListOf() + //language=RegExp + val p = Pattern.compile("""lstImages.push\("(.+?)"""") + val m = p.matcher(response.body().string()) + + var i = 0 + while (m.find()) { + pages.add(Page(i++, "", m.group(1))) + } + return pages + } + + override fun pageListParse(document: Document): List { + throw Exception("Not used") + } + + override fun imageUrlRequest(page: Page) = GET(page.url) + + override fun imageUrlParse(document: Document) = "" + + private class Status : Filter.TriState("Completed") + private class Author : Filter.Text("Author") + private class Genre(name: String) : Filter.TriState(name) + private class GenreList(genres: List) : Filter.Group("Genres", genres) + + override fun getFilterList() = FilterList( + Author(), + Status(), + GenreList(getGenreList()) + ) + + // $("select[name=\"genres\"]").map((i,el) => `Genre("${$(el).next().text().trim()}", ${i})`).get().join(',\n') + // on http://kissmanga.com/AdvanceSearch + private fun getGenreList() = listOf( + Genre("4-Koma"), + Genre("Action"), + Genre("Adult"), + Genre("Adventure"), + Genre("Comedy"), + Genre("Comic"), + Genre("Cooking"), + Genre("Doujinshi"), + Genre("Drama"), + Genre("Ecchi"), + Genre("Fantasy"), + Genre("Gender Bender"), + Genre("Harem"), + Genre("Historical"), + Genre("Horror"), + Genre("Josei"), + Genre("Lolicon"), + Genre("Manga"), + Genre("Manhua"), + Genre("Manhwa"), + Genre("Martial Arts"), + Genre("Mature"), + Genre("Mecha"), + Genre("Medical"), + Genre("Music"), + Genre("Mystery"), + Genre("One shot"), + Genre("Psychological"), + Genre("Romance"), + Genre("School Life"), + Genre("Sci-fi"), + Genre("Seinen"), + Genre("Shotacon"), + Genre("Shoujo"), + Genre("Shoujo Ai"), + Genre("Shounen"), + Genre("Shounen Ai"), + Genre("Slice of Life"), + Genre("Smut"), + Genre("Sports"), + Genre("Supernatural"), + Genre("Tragedy"), + Genre("Webtoon"), + Genre("Yaoi"), + Genre("Yuri") + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangafox.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangafox.kt new file mode 100644 index 000000000..5ea36d8e0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangafox.kt @@ -0,0 +1,223 @@ +package eu.kanade.tachiyomi.source.online.english + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.HttpUrl +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* + +class Mangafox : ParsedHttpSource() { + + override val id: Long = 3 + + override val name = "Mangafox" + + override val baseUrl = "http://mangafox.me" + + override val lang = "en" + + override val supportsLatest = true + + override fun popularMangaSelector() = "div#mangalist > ul.list > li" + + override fun popularMangaRequest(page: Int): Request { + val pageStr = if (page != 1) "$page.htm" else "" + return GET("$baseUrl/directory/$pageStr", headers) + } + + override fun latestUpdatesSelector() = "div#mangalist > ul.list > li" + + override fun latestUpdatesRequest(page: Int): Request { + val pageStr = if (page != 1) "$page.htm" else "" + return GET("$baseUrl/directory/$pageStr?latest") + } + + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("a.title").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text() + } + return manga + } + + override fun latestUpdatesFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = "a:has(span.next)" + + override fun latestUpdatesNextPageSelector() = "a:has(span.next)" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = HttpUrl.parse("$baseUrl/search.php?name_method=cw&author_method=cw&artist_method=cw&advopts=1").newBuilder().addQueryParameter("name", query) + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> + when (filter) { + is Status -> url.addQueryParameter(filter.id, filter.state.toString()) + is GenreList -> filter.state.forEach { genre -> url.addQueryParameter(genre.id, genre.state.toString()) } + is TextField -> url.addQueryParameter(filter.key, filter.state) + is Type -> url.addQueryParameter("type", if(filter.state == 0) "" else filter.state.toString()) + is OrderBy -> { + url.addQueryParameter("sort", arrayOf("name", "rating", "views", "total_chapters", "last_chapter_time")[filter.state!!.index]) + url.addQueryParameter("order", if (filter.state?.ascending == true) "az" else "za") + } + } + } + url.addQueryParameter("page", page.toString()) + return GET(url.toString(), headers) + } + + override fun searchMangaSelector() = "div#mangalist > ul.list > li" + + override fun searchMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("a.title").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text() + } + return manga + } + + override fun searchMangaNextPageSelector() = "a:has(span.next)" + + override fun mangaDetailsParse(document: Document): SManga { + val infoElement = document.select("div#title").first() + val rowElement = infoElement.select("table > tbody > tr:eq(1)").first() + val sideInfoElement = document.select("#series_info").first() + + val manga = SManga.create() + manga.author = rowElement.select("td:eq(1)").first()?.text() + manga.artist = rowElement.select("td:eq(2)").first()?.text() + manga.genre = rowElement.select("td:eq(3)").first()?.text() + manga.description = infoElement.select("p.summary").first()?.text() + manga.status = sideInfoElement.select(".data").first()?.text().orEmpty().let { parseStatus(it) } + manga.thumbnail_url = sideInfoElement.select("div.cover > img").first()?.attr("src") + return manga + } + + private fun parseStatus(status: String) = when { + status.contains("Ongoing") -> SManga.ONGOING + status.contains("Completed") -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + override fun chapterListSelector() = "div#chapters li div" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select("a.tips").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href")) + chapter.name = urlElement.text() + chapter.date_upload = element.select("span.date").first()?.text()?.let { parseChapterDate(it) } ?: 0 + return chapter + } + + private fun parseChapterDate(date: String): Long { + return if ("Today" in date || " ago" in date) { + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } else if ("Yesterday" in date) { + Calendar.getInstance().apply { + add(Calendar.DATE, -1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } else { + try { + SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time + } catch (e: ParseException) { + 0L + } + } + } + + override fun pageListParse(document: Document): List { + val url = document.baseUri().substringBeforeLast('/') + + val pages = mutableListOf() + document.select("select.m").first()?.select("option:not([value=0])")?.forEach { + pages.add(Page(pages.size, "$url/${it.attr("value")}.html")) + } + return pages + } + + override fun imageUrlParse(document: Document): String { + val url = document.getElementById("image").attr("src") + return if ("compressed?token=" !in url) { + url + } else { + "http://mangafox.me/media/logo.png" + } + } + + private class Status(val id: String = "is_completed") : Filter.TriState("Completed") + private class Genre(name: String, val id: String = "genres[$name]") : Filter.TriState(name) + private class TextField(name: String, val key: String) : Filter.Text(name) + private class Type : Filter.Select("Type", arrayOf("Any", "Japanese Manga", "Korean Manhwa", "Chinese Manhua")) + private class OrderBy : Filter.Sort("Order by", + arrayOf("Series name", "Rating", "Views", "Total chapters", "Last chapter"), + Filter.Sort.Selection(2, false)) + private class GenreList(genres: List) : Filter.Group("Genres", genres) + + override fun getFilterList() = FilterList( + TextField("Author", "author"), + TextField("Artist", "artist"), + Type(), + Status(), + OrderBy(), + GenreList(getGenreList()) + ) + + // $('select.genres').map((i,el)=>`Genre("${$(el).next().text().trim()}", "${$(el).attr('name')}")`).get().join(',\n') + // on http://mangafox.me/search.php + private fun getGenreList() = listOf( + Genre("Action"), + Genre("Adult"), + Genre("Adventure"), + Genre("Comedy"), + Genre("Doujinshi"), + Genre("Drama"), + Genre("Ecchi"), + Genre("Fantasy"), + Genre("Gender Bender"), + Genre("Harem"), + Genre("Historical"), + Genre("Horror"), + Genre("Josei"), + Genre("Martial Arts"), + Genre("Mature"), + Genre("Mecha"), + Genre("Mystery"), + Genre("One Shot"), + Genre("Psychological"), + Genre("Romance"), + Genre("School Life"), + Genre("Sci-fi"), + Genre("Seinen"), + Genre("Shoujo"), + Genre("Shoujo Ai"), + Genre("Shounen"), + Genre("Shounen Ai"), + Genre("Slice of Life"), + Genre("Smut"), + Genre("Sports"), + Genre("Supernatural"), + Genre("Tragedy"), + Genre("Webtoons"), + Genre("Yaoi"), + Genre("Yuri") + ) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangahere.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangahere.kt new file mode 100644 index 000000000..74b4411e3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangahere.kt @@ -0,0 +1,220 @@ +package eu.kanade.tachiyomi.source.online.english + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.HttpUrl +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* + +class Mangahere : ParsedHttpSource() { + + override val id: Long = 2 + + override val name = "Mangahere" + + override val baseUrl = "http://www.mangahere.co" + + override val lang = "en" + + override val supportsLatest = true + + override fun popularMangaSelector() = "div.directory_list > ul > li" + + override fun latestUpdatesSelector() = "div.directory_list > ul > li" + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/directory/$page.htm?views.za", headers) + } + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/directory/$page.htm?last_chapter_time.za", headers) + } + + private fun mangaFromElement(query: String, element: Element): SManga { + val manga = SManga.create() + element.select(query).first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = if (it.hasAttr("title")) it.attr("title") else if (it.hasAttr("rel")) it.attr("rel") else it.text() + } + return manga + } + + override fun popularMangaFromElement(element: Element): SManga { + return mangaFromElement("div.title > a", element) + } + + override fun latestUpdatesFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = "div.next-page > a.next" + + override fun latestUpdatesNextPageSelector() = "div.next-page > a.next" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = HttpUrl.parse("$baseUrl/search.php?name_method=cw&author_method=cw&artist_method=cw&advopts=1").newBuilder().addQueryParameter("name", query) + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> + when (filter) { + is Status -> url.addQueryParameter("is_completed", arrayOf("", "1", "0")[filter.state]) + is GenreList -> filter.state.forEach { genre -> url.addQueryParameter(genre.id, genre.state.toString()) } + is TextField -> url.addQueryParameter(filter.key, filter.state) + is Type -> url.addQueryParameter("direction", arrayOf("", "rl", "lr")[filter.state]) + is OrderBy -> { + url.addQueryParameter("sort", arrayOf("name", "rating", "views", "total_chapters", "last_chapter_time")[filter.state!!.index]) + url.addQueryParameter("order", if (filter.state?.ascending == true) "az" else "za") + } + } + } + url.addQueryParameter("page", page.toString()) + return GET(url.toString(), headers) + } + + override fun searchMangaSelector() = "div.result_search > dl:has(dt)" + + override fun searchMangaFromElement(element: Element): SManga { + return mangaFromElement("a.manga_info", element) + } + + override fun searchMangaNextPageSelector() = "div.next-page > a.next" + + override fun mangaDetailsParse(document: Document): SManga { + val detailElement = document.select(".manga_detail_top").first() + val infoElement = detailElement.select(".detail_topText").first() + + val manga = SManga.create() + manga.author = infoElement.select("a[href^=http://www.mangahere.co/author/]").first()?.text() + manga.artist = infoElement.select("a[href^=http://www.mangahere.co/artist/]").first()?.text() + manga.genre = infoElement.select("li:eq(3)").first()?.text()?.substringAfter("Genre(s):") + manga.description = infoElement.select("#show").first()?.text()?.substringBeforeLast("Show less") + manga.status = infoElement.select("li:eq(6)").first()?.text().orEmpty().let { parseStatus(it) } + manga.thumbnail_url = detailElement.select("img.img").first()?.attr("src") + return manga + } + + private fun parseStatus(status: String) = when { + status.contains("Ongoing") -> SManga.ONGOING + status.contains("Completed") -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + override fun chapterListSelector() = ".detail_list > ul:not([class]) > li" + + override fun chapterFromElement(element: Element): SChapter { + val parentEl = element.select("span.left").first() + + val urlElement = parentEl.select("a").first() + + var volume = parentEl.select("span.mr6")?.first()?.text()?.trim() ?: "" + if (volume.length > 0) { + volume = " - " + volume + } + + var title = parentEl?.textNodes()?.last()?.text()?.trim() ?: "" + if (title.length > 0) { + title = " - " + title + } + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href")) + chapter.name = urlElement.text() + volume + title + chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0 + return chapter + } + + private fun parseChapterDate(date: String): Long { + return if ("Today" in date) { + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } else if ("Yesterday" in date) { + Calendar.getInstance().apply { + add(Calendar.DATE, -1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } else { + try { + SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time + } catch (e: ParseException) { + 0L + } + } + } + + override fun pageListParse(document: Document): List { + val pages = mutableListOf() + document.select("select.wid60").first()?.getElementsByTag("option")?.forEach { + pages.add(Page(pages.size, it.attr("value"))) + } + pages.getOrNull(0)?.imageUrl = imageUrlParse(document) + return pages + } + + override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src") + + private class Status : Filter.TriState("Completed") + private class Genre(name: String, val id: String = "genres[$name]") : Filter.TriState(name) + private class TextField(name: String, val key: String) : Filter.Text(name) + private class Type : Filter.Select("Type", arrayOf("Any", "Japanese Manga (read from right to left)", "Korean Manhwa (read from left to right)")) + private class OrderBy : Filter.Sort("Order by", + arrayOf("Series name", "Rating", "Views", "Total chapters", "Last chapter"), + Filter.Sort.Selection(2, false)) + private class GenreList(genres: List) : Filter.Group("Genres", genres) + + override fun getFilterList() = FilterList( + TextField("Author", "author"), + TextField("Artist", "artist"), + Type(), + Status(), + OrderBy(), + GenreList(getGenreList()) + ) + + // [...document.querySelectorAll("select[id^='genres'")].map((el,i) => `Genre("${el.nextSibling.nextSibling.textContent.trim()}", "${el.getAttribute('name')}")`).join(',\n') + // http://www.mangahere.co/advsearch.htm + private fun getGenreList() = listOf( + Genre("Action"), + Genre("Adventure"), + Genre("Comedy"), + Genre("Doujinshi"), + Genre("Drama"), + Genre("Ecchi"), + Genre("Fantasy"), + Genre("Gender Bender"), + Genre("Harem"), + Genre("Historical"), + Genre("Horror"), + Genre("Josei"), + Genre("Martial Arts"), + Genre("Mature"), + Genre("Mecha"), + Genre("Mystery"), + Genre("One Shot"), + Genre("Psychological"), + Genre("Romance"), + Genre("School Life"), + Genre("Sci-fi"), + Genre("Seinen"), + Genre("Shoujo"), + Genre("Shoujo Ai"), + Genre("Shounen"), + Genre("Shounen Ai"), + Genre("Slice of Life"), + Genre("Sports"), + Genre("Supernatural"), + Genre("Tragedy"), + Genre("Yaoi"), + Genre("Yuri") + ) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangasee.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangasee.kt new file mode 100644 index 000000000..abac69d58 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangasee.kt @@ -0,0 +1,243 @@ +package eu.kanade.tachiyomi.source.online.english + +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.regex.Pattern + +class Mangasee : ParsedHttpSource() { + + override val id: Long = 9 + + override val name = "Mangasee" + + override val baseUrl = "http://mangaseeonline.net" + + override val lang = "en" + + override val supportsLatest = true + + private val recentUpdatesPattern = Pattern.compile("(.*?)\\s(\\d+\\.?\\d*)\\s?(Completed)?") + + private val indexPattern = Pattern.compile("-index-(.*?)-") + + override fun popularMangaSelector() = "div.requested > div.row" + + override fun popularMangaRequest(page: Int): Request { + val (body, requestUrl) = convertQueryToPost(page, "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending") + return POST(requestUrl, headers, body.build()) + } + + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("a.resultLink").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text() + } + return manga + } + + override fun popularMangaNextPageSelector() = "button.requestMore" + + override fun searchMangaSelector() = "div.requested > div.row" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = HttpUrl.parse("$baseUrl/search/request.php").newBuilder() + if (!query.isEmpty()) url.addQueryParameter("keyword", query) + val genres = mutableListOf() + val genresNo = mutableListOf() + for (filter in if (filters.isEmpty()) getFilterList() else filters) { + when (filter) { + is Sort -> { + if (filter.state?.index != 0) + url.addQueryParameter("sortBy", if (filter.state?.index == 1) "dateUpdated" else "popularity") + if (filter.state?.ascending != true) + url.addQueryParameter("sortOrder", "descending") + } + is SelectField -> if (filter.state != 0) url.addQueryParameter(filter.key, filter.values[filter.state]) + is TextField -> if (!filter.state.isEmpty()) url.addQueryParameter(filter.key, filter.state) + is GenreList -> filter.state.forEach { genre -> + when (genre.state) { + Filter.TriState.STATE_INCLUDE -> genres.add(genre.name) + Filter.TriState.STATE_EXCLUDE -> genresNo.add(genre.name) + } + } + } + } + if (genres.isNotEmpty()) url.addQueryParameter("genre", genres.joinToString(",")) + if (genresNo.isNotEmpty()) url.addQueryParameter("genreNo", genresNo.joinToString(",")) + + val (body, requestUrl) = convertQueryToPost(page, url.toString()) + return POST(requestUrl, headers, body.build()) + } + + private fun convertQueryToPost(page: Int, url: String): Pair { + val url = HttpUrl.parse(url) + val body = FormBody.Builder().add("page", page.toString()) + for (i in 0..url.querySize() - 1) { + body.add(url.queryParameterName(i), url.queryParameterValue(i)) + } + val requestUrl = url.scheme() + "://" + url.host() + url.encodedPath() + return Pair(body, requestUrl) + } + + override fun searchMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("a.resultLink").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text() + } + return manga + } + + override fun searchMangaNextPageSelector() = "button.requestMore" + + override fun mangaDetailsParse(document: Document): SManga { + val detailElement = document.select("div.well > div.row").first() + + val manga = SManga.create() + manga.author = detailElement.select("a[href^=/search/?author=]").first()?.text() + manga.genre = detailElement.select("span.details > div.row > div:has(b:contains(Genre(s))) > a").map { it.text() }.joinToString() + manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text() + manga.status = detailElement.select("a[href^=/search/?status=]").first()?.text().orEmpty().let { parseStatus(it) } + manga.thumbnail_url = detailElement.select("div > img").first()?.absUrl("src") + return manga + } + + private fun parseStatus(status: String) = when { + status.contains("Ongoing (Scan)") -> SManga.ONGOING + status.contains("Complete (Scan)") -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + override fun chapterListSelector() = "div.chapter-list > a" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select("a").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href")) + chapter.name = element.select("span.chapterLabel").first().text()?.let { it } ?: "" + chapter.date_upload = element.select("time").first()?.attr("datetime")?.let { parseChapterDate(it) } ?: 0 + return chapter + } + + private fun parseChapterDate(dateAsString: String): Long { + return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(dateAsString).time + } + + override fun pageListParse(document: Document): List { + val fullUrl = document.baseUri() + val url = fullUrl.substringBeforeLast('/') + + val pages = mutableListOf() + + val series = document.select("input.IndexName").first().attr("value") + val chapter = document.select("span.CurChapter").first().text() + var index = "" + + val m = indexPattern.matcher(fullUrl) + if (m.find()) { + val indexNumber = m.group(1) + index = "-index-$indexNumber" + } + + document.select("div.ContainerNav").first().select("select.PageSelect > option").forEach { + pages.add(Page(pages.size, "$url/$series-chapter-$chapter$index-page-${pages.size + 1}.html")) + } + pages.getOrNull(0)?.imageUrl = imageUrlParse(document) + return pages + } + + override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src") + + override fun latestUpdatesNextPageSelector() = "button.requestMore" + + override fun latestUpdatesSelector(): String = "a.latestSeries" + + override fun latestUpdatesRequest(page: Int): Request { + val url = "http://mangaseeonline.net/home/latest.request.php" + val (body, requestUrl) = convertQueryToPost(page, url) + return POST(requestUrl, headers, body.build()) + } + + override fun latestUpdatesFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("a.latestSeries").first().let { + val chapterUrl = it.attr("href") + val indexOfMangaUrl = chapterUrl.indexOf("-chapter-") + val indexOfLastPath = chapterUrl.lastIndexOf("/") + val mangaUrl = chapterUrl.substring(indexOfLastPath, indexOfMangaUrl) + val defaultText = it.select("p.clamp2").text() + val m = recentUpdatesPattern.matcher(defaultText) + val title = if (m.matches()) m.group(1) else defaultText + manga.setUrlWithoutDomain("/manga" + mangaUrl) + manga.title = title + } + return manga + } + + private class Sort : Filter.Sort("Sort", arrayOf("Alphabetically", "Date updated", "Popularity"), Filter.Sort.Selection(2, false)) + private class Genre(name: String) : Filter.TriState(name) + private class TextField(name: String, val key: String) : Filter.Text(name) + private class SelectField(name: String, val key: String, values: Array, state: Int = 0) : Filter.Select(name, values, state) + private class GenreList(genres: List) : Filter.Group("Genres", genres) + + override fun getFilterList() = FilterList( + TextField("Years", "year"), + TextField("Author", "author"), + SelectField("Scan Status", "status", arrayOf("Any", "Complete", "Discontinued", "Hiatus", "Incomplete", "Ongoing")), + SelectField("Publish Status", "pstatus", arrayOf("Any", "Cancelled", "Complete", "Discontinued", "Hiatus", "Incomplete", "Ongoing", "Unfinished")), + SelectField("Type", "type", arrayOf("Any", "Doujinshi", "Manga", "Manhua", "Manhwa", "OEL", "One-shot")), + Sort(), + GenreList(getGenreList()) + ) + + // [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n') + // http://mangasee.co/advanced-search/ + private fun getGenreList() = listOf( + Genre("Action"), + Genre("Adult"), + Genre("Adventure"), + Genre("Comedy"), + Genre("Doujinshi"), + Genre("Drama"), + Genre("Ecchi"), + Genre("Fantasy"), + Genre("Gender Bender"), + Genre("Harem"), + Genre("Hentai"), + Genre("Historical"), + Genre("Horror"), + Genre("Josei"), + Genre("Lolicon"), + Genre("Martial Arts"), + Genre("Mature"), + Genre("Mecha"), + Genre("Mystery"), + Genre("Psychological"), + Genre("Romance"), + Genre("School Life"), + Genre("Sci-fi"), + Genre("Seinen"), + Genre("Shotacon"), + Genre("Shoujo"), + Genre("Shoujo Ai"), + Genre("Shounen"), + Genre("Shounen Ai"), + Genre("Slice of Life"), + Genre("Smut"), + Genre("Sports"), + Genre("Supernatural"), + Genre("Tragedy"), + Genre("Yaoi"), + Genre("Yuri") + ) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Readmangatoday.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Readmangatoday.kt new file mode 100644 index 000000000..c4c476a04 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Readmangatoday.kt @@ -0,0 +1,219 @@ +package eu.kanade.tachiyomi.source.online.english + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.util.* + +class Readmangatoday : ParsedHttpSource() { + + override val id: Long = 8 + + override val name = "ReadMangaToday" + + override val baseUrl = "http://www.readmanga.today" + + override val lang = "en" + + override val supportsLatest = true + + override val client: OkHttpClient get() = network.cloudflareClient + + /** + * Search only returns data with this set + */ + override fun headersBuilder() = Headers.Builder().apply { + add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") + add("X-Requested-With", "XMLHttpRequest") + } + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/hot-manga/$page", headers) + } + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/latest-releases/$page", headers) + } + + override fun popularMangaSelector() = "div.hot-manga > div.style-list > div.box" + + override fun latestUpdatesSelector() = "div.hot-manga > div.style-grid > div.box" + + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("div.title > h2 > a").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.attr("title") + } + return manga + } + + override fun latestUpdatesFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)" + + override fun latestUpdatesNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val builder = okhttp3.FormBody.Builder() + builder.add("manga-name", query) + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> + when (filter) { + is TextField -> builder.add(filter.key, filter.state) + is Type -> builder.add("type", arrayOf("all", "japanese", "korean", "chinese")[filter.state]) + is Status -> builder.add("status", arrayOf("both", "completed", "ongoing")[filter.state]) + is GenreList -> filter.state.forEach { genre -> + when (genre.state) { + Filter.TriState.STATE_INCLUDE -> builder.add("include[]", genre.id.toString()) + Filter.TriState.STATE_EXCLUDE -> builder.add("exclude[]", genre.id.toString()) + } + } + } + } + return POST("$baseUrl/service/advanced_search", headers, builder.build()) + } + + override fun searchMangaSelector() = "div.style-list > div.box" + + override fun searchMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("div.title > h2 > a").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.attr("title") + } + return manga + } + + override fun searchMangaNextPageSelector() = "div.next-page > a.next" + + override fun mangaDetailsParse(document: Document): SManga { + val detailElement = document.select("div.movie-meta").first() + + val manga = SManga.create() + manga.author = document.select("ul.cast-list li.director > ul a").first()?.text() + manga.artist = document.select("ul.cast-list li:not(.director) > ul a").first()?.text() + manga.genre = detailElement.select("dl.dl-horizontal > dd:eq(5)").first()?.text() + manga.description = detailElement.select("li.movie-detail").first()?.text() + manga.status = detailElement.select("dl.dl-horizontal > dd:eq(3)").first()?.text().orEmpty().let { parseStatus(it) } + manga.thumbnail_url = detailElement.select("img.img-responsive").first()?.attr("src") + return manga + } + + private fun parseStatus(status: String) = when { + status.contains("Ongoing") -> SManga.ONGOING + status.contains("Completed") -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + override fun chapterListSelector() = "ul.chp_lst > li" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select("a").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href")) + chapter.name = urlElement.select("span.val").text() + chapter.date_upload = element.select("span.dte").first()?.text()?.let { parseChapterDate(it) } ?: 0 + return chapter + } + + private fun parseChapterDate(date: String): Long { + val dateWords: List = date.split(" ") + + if (dateWords.size == 3) { + val timeAgo = Integer.parseInt(dateWords[0]) + val date: Calendar = Calendar.getInstance() + + if (dateWords[1].contains("Minute")) { + date.add(Calendar.MINUTE, -timeAgo) + } else if (dateWords[1].contains("Hour")) { + date.add(Calendar.HOUR_OF_DAY, -timeAgo) + } else if (dateWords[1].contains("Day")) { + date.add(Calendar.DAY_OF_YEAR, -timeAgo) + } else if (dateWords[1].contains("Week")) { + date.add(Calendar.WEEK_OF_YEAR, -timeAgo) + } else if (dateWords[1].contains("Month")) { + date.add(Calendar.MONTH, -timeAgo) + } else if (dateWords[1].contains("Year")) { + date.add(Calendar.YEAR, -timeAgo) + } + + return date.timeInMillis + } + + return 0L + } + + override fun pageListParse(document: Document): List { + val pages = mutableListOf() + document.select("ul.list-switcher-2 > li > select.jump-menu").first().getElementsByTag("option").forEach { + pages.add(Page(pages.size, it.attr("value"))) + } + pages.getOrNull(0)?.imageUrl = imageUrlParse(document) + return pages + } + + override fun imageUrlParse(document: Document) = document.select("img.img-responsive-2").first().attr("src") + + private class Status : Filter.TriState("Completed") + private class Genre(name: String, val id: Int) : Filter.TriState(name) + private class TextField(name: String, val key: String) : Filter.Text(name) + private class Type : Filter.Select("Type", arrayOf("All", "Japanese Manga", "Korean Manhwa", "Chinese Manhua")) + private class GenreList(genres: List) : Filter.Group("Genres", genres) + + override fun getFilterList() = FilterList( + TextField("Author", "author-name"), + TextField("Artist", "artist-name"), + Type(), + Status(), + GenreList(getGenreList()) + ) + + // [...document.querySelectorAll("ul.manga-cat span")].map(el => `Genre("${el.nextSibling.textContent.trim()}", ${el.getAttribute('data-id')})`).join(',\n') + // http://www.readmanga.today/advanced-search + private fun getGenreList() = listOf( + Genre("Action", 2), + Genre("Adventure", 4), + Genre("Comedy", 5), + Genre("Doujinshi", 6), + Genre("Drama", 7), + Genre("Ecchi", 8), + Genre("Fantasy", 9), + Genre("Gender Bender", 10), + Genre("Harem", 11), + Genre("Historical", 12), + Genre("Horror", 13), + Genre("Josei", 14), + Genre("Lolicon", 15), + Genre("Martial Arts", 16), + Genre("Mature", 17), + Genre("Mecha", 18), + Genre("Mystery", 19), + Genre("One shot", 20), + Genre("Psychological", 21), + Genre("Romance", 22), + Genre("School Life", 23), + Genre("Sci-fi", 24), + Genre("Seinen", 25), + Genre("Shotacon", 26), + Genre("Shoujo", 27), + Genre("Shoujo Ai", 28), + Genre("Shounen", 29), + Genre("Shounen Ai", 30), + Genre("Slice of Life", 31), + Genre("Smut", 32), + Genre("Sports", 33), + Genre("Supernatural", 34), + Genre("Tragedy", 35), + Genre("Yaoi", 36), + Genre("Yuri", 37) + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/german/WieManga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/german/WieManga.kt similarity index 57% rename from app/src/main/java/eu/kanade/tachiyomi/data/source/online/german/WieManga.kt rename to app/src/main/java/eu/kanade/tachiyomi/source/online/german/WieManga.kt index 9958792dc..b680ae905 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/german/WieManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/german/WieManga.kt @@ -1,106 +1,122 @@ -package eu.kanade.tachiyomi.data.source.online.german - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat - -class WieManga(override val id: Int) : ParsedOnlineSource() { - - override val name = "Wie Manga!" - - override val baseUrl = "http://www.wiemanga.com" - - override val lang = "de" - - override val supportsLatest = true - - override fun popularMangaInitialUrl() = "$baseUrl/list/Hot-Book/" - - override fun latestUpdatesInitialUrl() = "$baseUrl/list/New-Update/" - - override fun popularMangaSelector() = ".booklist td > div" - - override fun latestUpdatesSelector() = ".booklist td > div" - - override fun popularMangaFromElement(element: Element, manga: Manga) { - val image = element.select("dt img") - val title = element.select("dd a:first-child") - - manga.setUrlWithoutDomain(title.attr("href")) - manga.title = title.text() - manga.thumbnail_url = image.attr("src") - } - - override fun latestUpdatesFromElement(element: Element, manga: Manga) { - popularMangaFromElement(element, manga) - } - - override fun popularMangaNextPageSelector() = null - - override fun latestUpdatesNextPageSelector() = null - - override fun searchMangaInitialUrl(query: String, filters: List) = "$baseUrl/search/?wd=$query" - - override fun searchMangaSelector() = ".searchresult td > div" - - override fun searchMangaFromElement(element: Element, manga: Manga) { - val image = element.select(".resultimg img") - val title = element.select(".resultbookname") - - manga.setUrlWithoutDomain(title.attr("href")) - manga.title = title.text() - manga.thumbnail_url = image.attr("src") - } - - override fun searchMangaNextPageSelector() = ".pagetor a.l" - - override fun mangaDetailsParse(document: Document, manga: Manga) { - val imageElement = document.select(".bookmessgae tr > td:nth-child(1)").first() - val infoElement = document.select(".bookmessgae tr > td:nth-child(2)").first() - - manga.author = infoElement.select("dd:nth-of-type(2) a").first()?.text() - manga.artist = infoElement.select("dd:nth-of-type(3) a").first()?.text() - manga.description = infoElement.select("dl > dt:last-child").first()?.text()?.replaceFirst("Beschreibung", "") - manga.thumbnail_url = imageElement.select("img").first()?.attr("src") - - if (manga.author == "RSS") - manga.author = null - - if (manga.artist == "RSS") - manga.artist = null - } - - override fun chapterListSelector() = ".chapterlist tr:not(:first-child)" - - override fun chapterFromElement(element: Element, chapter: Chapter) { - val urlElement = element.select(".col1 a").first() - val dateElement = element.select(".col3 a").first() - - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = urlElement.text() - chapter.date_upload = dateElement?.text()?.let { parseChapterDate(it) } ?: 0 - } - - private fun parseChapterDate(date: String): Long { - return SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(date).time - } - - override fun pageListParse(response: Response, pages: MutableList) { - val document = response.asJsoup() - - document.select("select#page").first().select("option").forEach { - pages.add(Page(pages.size, it.attr("value"))) - } - } - - override fun pageListParse(document: Document, pages: MutableList) {} - - override fun imageUrlParse(document: Document) = document.select("img#comicpic").first().attr("src") - +package eu.kanade.tachiyomi.source.online.german + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat + +class WieManga : ParsedHttpSource() { + + override val id: Long = 10 + + override val name = "Wie Manga!" + + override val baseUrl = "http://www.wiemanga.com" + + override val lang = "de" + + override val supportsLatest = true + + override fun popularMangaSelector() = ".booklist td > div" + + override fun latestUpdatesSelector() = ".booklist td > div" + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/list/Hot-Book/", headers) + } + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/list/New-Update/", headers) + } + + override fun popularMangaFromElement(element: Element): SManga { + val image = element.select("dt img") + val title = element.select("dd a:first-child") + + val manga = SManga.create() + manga.setUrlWithoutDomain(title.attr("href")) + manga.title = title.text() + manga.thumbnail_url = image.attr("src") + return manga + } + + override fun latestUpdatesFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = null + + override fun latestUpdatesNextPageSelector() = null + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + return GET("$baseUrl/search/?wd=$query", headers) + } + + override fun searchMangaSelector() = ".searchresult td > div" + + override fun searchMangaFromElement(element: Element): SManga { + val image = element.select(".resultimg img") + val title = element.select(".resultbookname") + + val manga = SManga.create() + manga.setUrlWithoutDomain(title.attr("href")) + manga.title = title.text() + manga.thumbnail_url = image.attr("src") + return manga + } + + override fun searchMangaNextPageSelector() = ".pagetor a.l" + + override fun mangaDetailsParse(document: Document): SManga { + val imageElement = document.select(".bookmessgae tr > td:nth-child(1)").first() + val infoElement = document.select(".bookmessgae tr > td:nth-child(2)").first() + + val manga = SManga.create() + manga.author = infoElement.select("dd:nth-of-type(2) a").first()?.text() + manga.artist = infoElement.select("dd:nth-of-type(3) a").first()?.text() + manga.description = infoElement.select("dl > dt:last-child").first()?.text()?.replaceFirst("Beschreibung", "") + manga.thumbnail_url = imageElement.select("img").first()?.attr("src") + + if (manga.author == "RSS") + manga.author = null + + if (manga.artist == "RSS") + manga.artist = null + return manga + } + + override fun chapterListSelector() = ".chapterlist tr:not(:first-child)" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select(".col1 a").first() + val dateElement = element.select(".col3 a").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href")) + chapter.name = urlElement.text() + chapter.date_upload = dateElement?.text()?.let { parseChapterDate(it) } ?: 0 + return chapter + } + + private fun parseChapterDate(date: String): Long { + return SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(date).time + } + + override fun pageListParse(document: Document): List { + val pages = mutableListOf() + + document.select("select#page").first().select("option").forEach { + pages.add(Page(pages.size, it.attr("value"))) + } + return pages + } + + override fun imageUrlParse(document: Document) = document.select("img#comicpic").first().attr("src") + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Mangachan.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Mangachan.kt new file mode 100644 index 000000000..0868e4cdc --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Mangachan.kt @@ -0,0 +1,236 @@ +package eu.kanade.tachiyomi.source.online.russian + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.* + +class Mangachan : ParsedHttpSource() { + + override val id: Long = 7 + + override val name = "Mangachan" + + override val baseUrl = "http://mangachan.me" + + override val lang = "ru" + + override val supportsLatest = true + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/mostfavorites?offset=${20 * (page - 1)}", headers) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + var pageNum = 1 + when { + page < 1 -> pageNum = 1 + page >= 1 -> pageNum = page + } + val url = if (query.isNotEmpty()) { + "$baseUrl/?do=search&subaction=search&story=$query&search_start=$pageNum" + } else { + val filt = filters.filterIsInstance().filter { !it.isIgnored() } + if (filt.isNotEmpty()) { + var genres = "" + filt.forEach { genres += (if (it.isExcluded()) "-" else "") + it.id + '+' } + "$baseUrl/tags/${genres.dropLast(1)}?offset=${20 * (pageNum - 1)}" + } else { + "$baseUrl/?do=search&subaction=search&story=$query&search_start=$pageNum" + } + } + return GET(url, headers) + } + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/newestch?page=$page") + } + + override fun popularMangaSelector() = "div.content_row" + + override fun latestUpdatesSelector() = "ul.area_rightNews li" + + override fun searchMangaSelector() = popularMangaSelector() + + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("h2 > a").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text() + } + return manga + } + + override fun latestUpdatesFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("a:nth-child(1)").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text() + } + return manga + } + + override fun searchMangaFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = "a:contains(Вперед)" + + override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() + + override fun searchMangaNextPageSelector() = "a:contains(Далее)" + + private fun searchGenresNextPageSelector() = popularMangaNextPageSelector() + + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + var hasNextPage = false + + val mangas = document.select(searchMangaSelector()).map { element -> + searchMangaFromElement(element) + } + + val nextSearchPage = document.select(searchMangaNextPageSelector()) + if (nextSearchPage.isNotEmpty()) { + val query = document.select("input#searchinput").first().attr("value") + val pageNum = nextSearchPage.let { selector -> + val onClick = selector.attr("onclick") + onClick?.split("""\\d+""") + } + nextSearchPage.attr("href", "$baseUrl/?do=search&subaction=search&story=$query&search_start=$pageNum") + hasNextPage = true + } + + val nextGenresPage = document.select(searchGenresNextPageSelector()) + if (nextGenresPage.isNotEmpty()) { + hasNextPage = true + } + + return MangasPage(mangas, hasNextPage) + } + + override fun mangaDetailsParse(document: Document): SManga { + val infoElement = document.select("table.mangatitle").first() + val descElement = document.select("div#description").first() + val imgElement = document.select("img#cover").first() + + val manga = SManga.create() + manga.author = infoElement.select("tr:eq(2) > td:eq(1)").text() + manga.genre = infoElement.select("tr:eq(5) > td:eq(1)").text() + manga.status = parseStatus(infoElement.select("tr:eq(4) > td:eq(1)").text()) + manga.description = descElement.textNodes().first().text() + manga.thumbnail_url = baseUrl + imgElement.attr("src") + return manga + } + + private fun parseStatus(element: String): Int { + when { + element.contains("перевод завершен") -> return SManga.COMPLETED + element.contains("перевод продолжается") -> return SManga.ONGOING + else -> return SManga.UNKNOWN + } + } + + override fun chapterListSelector() = "table.table_cha tr:gt(1)" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select("a").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href")) + chapter.name = urlElement.text() + chapter.date_upload = element.select("div.date").first()?.text()?.let { + SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(it).time + } ?: 0 + return chapter + } + + override fun pageListParse(response: Response): List { + val html = response.body().string() + val beginIndex = html.indexOf("fullimg\":[") + 10 + val endIndex = html.indexOf(",]", beginIndex) + val trimmedHtml = html.substring(beginIndex, endIndex).replace("\"", "") + val pageUrls = trimmedHtml.split(',') + + return pageUrls.mapIndexed { i, url -> Page(i, "", url) } + } + + override fun pageListParse(document: Document): List { + throw Exception("Not used") + } + + override fun imageUrlParse(document: Document) = "" + + private class Genre(name: String, val id: String = name.replace(' ', '_')) : Filter.TriState(name) + + /* [...document.querySelectorAll("li.sidetag > a:nth-child(1)")].map((el,i) => + * { const link=el.getAttribute('href');const id=link.substr(6,link.length); + * return `Genre("${id.replace("_", " ")}")` }).join(',\n') + * on http://mangachan.me/ + */ + override fun getFilterList() = FilterList( + Genre("18 плюс"), + Genre("bdsm"), + Genre("арт"), + Genre("биография"), + Genre("боевик"), + Genre("боевые искусства"), + Genre("вампиры"), + Genre("веб"), + Genre("гарем"), + Genre("гендерная интрига"), + Genre("героическое фэнтези"), + Genre("детектив"), + Genre("дзёсэй"), + Genre("додзинси"), + Genre("драма"), + Genre("игра"), + Genre("инцест"), + Genre("искусство"), + Genre("история"), + Genre("киберпанк"), + Genre("кодомо"), + Genre("комедия"), + Genre("литРПГ"), + Genre("магия"), + Genre("махо-сёдзё"), + Genre("меха"), + Genre("мистика"), + Genre("музыка"), + Genre("научная фантастика"), + Genre("повседневность"), + Genre("постапокалиптика"), + Genre("приключения"), + Genre("психология"), + Genre("романтика"), + Genre("самурайский боевик"), + Genre("сборник"), + Genre("сверхъестественное"), + Genre("сказка"), + Genre("спорт"), + Genre("супергерои"), + Genre("сэйнэн"), + Genre("сёдзё"), + Genre("сёдзё-ай"), + Genre("сёнэн"), + Genre("сёнэн-ай"), + Genre("тентакли"), + Genre("трагедия"), + Genre("триллер"), + Genre("ужасы"), + Genre("фантастика"), + Genre("фурри"), + Genre("фэнтези"), + Genre("школа"), + Genre("эротика"), + Genre("юри"), + Genre("яой"), + Genre("ёнкома") + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Mintmanga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Mintmanga.kt new file mode 100644 index 000000000..6097859fa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Mintmanga.kt @@ -0,0 +1,198 @@ +package eu.kanade.tachiyomi.source.online.russian + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Pattern + +class Mintmanga : ParsedHttpSource() { + + override val id: Long = 6 + + override val name = "Mintmanga" + + override val baseUrl = "http://mintmanga.com" + + override val lang = "ru" + + override val supportsLatest = true + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/list?sortType=rate&offset=${70 * (page - 1)}&max=70", headers) + } + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/list?sortType=updated&offset=${70 * (page - 1)}&max=70", headers) + } + + override fun popularMangaSelector() = "div.desc" + + override fun latestUpdatesSelector() = "div.desc" + + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("h3 > a").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.attr("title") + } + return manga + } + + override fun latestUpdatesFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = "a.nextLink" + + override fun latestUpdatesNextPageSelector() = "a.nextLink" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val genres = filters.filterIsInstance().map { it.id + arrayOf("=", "=in", "=ex")[it.state] }.joinToString("&") + return GET("$baseUrl/search?q=$query&$genres", headers) + } + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + // max 200 results + override fun searchMangaNextPageSelector() = null + + override fun mangaDetailsParse(document: Document): SManga { + val infoElement = document.select("div.leftContent").first() + + val manga = SManga.create() + manga.author = infoElement.select("span.elem_author").first()?.text() + manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",") + manga.description = infoElement.select("div.manga-description").text() + manga.status = parseStatus(infoElement.html()) + manga.thumbnail_url = infoElement.select("img").attr("data-full") + return manga + } + + private fun parseStatus(element: String): Int { + when { + element.contains("

Запрещена публикация произведения по копирайту

") -> return SManga.LICENSED + element.contains("

Сингл") || element.contains("Перевод: завершен") -> return SManga.COMPLETED + element.contains("Перевод: продолжается") -> return SManga.ONGOING + else -> return SManga.UNKNOWN + } + } + + override fun chapterListSelector() = "div.chapters-link tbody tr" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select("a").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mature=1") + chapter.name = urlElement.text().replace(" новое", "") + chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let { + SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time + } ?: 0 + return chapter + } + + override fun prepareNewChapter(chapter: SChapter, manga: SManga) { + val basic = Regex("""\s([0-9]+)(\s-\s)([0-9]+)\s*""") + val extra = Regex("""\s([0-9]+\sЭкстра)\s*""") + val single = Regex("""\sСингл\s*""") + when { + basic.containsMatchIn(chapter.name) -> { + basic.find(chapter.name)?.let { + val number = it.groups[3]?.value!! + chapter.chapter_number = number.toFloat() + } + } + extra.containsMatchIn(chapter.name) -> // Extra chapters doesn't contain chapter number + chapter.chapter_number = -2f + single.containsMatchIn(chapter.name) -> // Oneshoots, doujinshi and other mangas with one chapter + chapter.chapter_number = 1f + } + } + + override fun pageListParse(response: Response): List { + val html = response.body().string() + val beginIndex = html.indexOf("rm_h.init( [") + val endIndex = html.indexOf("], 0, false);", beginIndex) + val trimmedHtml = html.substring(beginIndex, endIndex) + + val p = Pattern.compile("'.+?','.+?',\".+?\"") + val m = p.matcher(trimmedHtml) + + val pages = mutableListOf() + + var i = 0 + while (m.find()) { + val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',') + pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2])) + } + return pages + } + + override fun pageListParse(document: Document): List { + throw Exception("Not used") + } + + override fun imageUrlParse(document: Document) = "" + + private class Genre(name: String, val id: String) : Filter.TriState(name) + + /* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => { + * const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33); + * return `Genre("${el.textContent.trim()}", "${id}")` }).join(',\n') + * on http://mintmanga.com/search + */ + override fun getFilterList() = FilterList( + Genre("арт", "el_2220"), + Genre("бара", "el_1353"), + Genre("боевик", "el_1346"), + Genre("боевые искусства", "el_1334"), + Genre("вампиры", "el_1339"), + Genre("гарем", "el_1333"), + Genre("гендерная интрига", "el_1347"), + Genre("героическое фэнтези", "el_1337"), + Genre("детектив", "el_1343"), + Genre("дзёсэй", "el_1349"), + Genre("додзинси", "el_1332"), + Genre("драма", "el_1310"), + Genre("игра", "el_5229"), + Genre("история", "el_1311"), + Genre("киберпанк", "el_1351"), + Genre("комедия", "el_1328"), + Genre("меха", "el_1318"), + Genre("мистика", "el_1324"), + Genre("научная фантастика", "el_1325"), + Genre("повседневность", "el_1327"), + Genre("постапокалиптика", "el_1342"), + Genre("приключения", "el_1322"), + Genre("психология", "el_1335"), + Genre("романтика", "el_1313"), + Genre("самурайский боевик", "el_1316"), + Genre("сверхъестественное", "el_1350"), + Genre("сёдзё", "el_1314"), + Genre("сёдзё-ай", "el_1320"), + Genre("сёнэн", "el_1326"), + Genre("сёнэн-ай", "el_1330"), + Genre("спорт", "el_1321"), + Genre("сэйнэн", "el_1329"), + Genre("трагедия", "el_1344"), + Genre("триллер", "el_1341"), + Genre("ужасы", "el_1317"), + Genre("фантастика", "el_1331"), + Genre("фэнтези", "el_1323"), + Genre("школа", "el_1319"), + Genre("эротика", "el_1340"), + Genre("этти", "el_1354"), + Genre("юри", "el_1315"), + Genre("яой", "el_1336") + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Readmanga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Readmanga.kt new file mode 100644 index 000000000..6ac6e9c28 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/russian/Readmanga.kt @@ -0,0 +1,197 @@ +package eu.kanade.tachiyomi.source.online.russian + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Pattern + +class Readmanga : ParsedHttpSource() { + + override val id: Long = 5 + + override val name = "Readmanga" + + override val baseUrl = "http://readmanga.me" + + override val lang = "ru" + + override val supportsLatest = true + + override fun popularMangaSelector() = "div.desc" + + override fun latestUpdatesSelector() = "div.desc" + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/list?sortType=rate&offset=${70 * (page - 1)}&max=70", headers) + } + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/list?sortType=updated&offset=${70 * (page - 1)}&max=70", headers) + } + + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("h3 > a").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.attr("title") + } + return manga + } + + override fun latestUpdatesFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + override fun popularMangaNextPageSelector() = "a.nextLink" + + override fun latestUpdatesNextPageSelector() = "a.nextLink" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val genres = filters.filterIsInstance().map { it.id + arrayOf("=", "=in", "=ex")[it.state] }.joinToString("&") + return GET("$baseUrl/search?q=$query&$genres", headers) + } + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element): SManga { + return popularMangaFromElement(element) + } + + // max 200 results + override fun searchMangaNextPageSelector() = null + + override fun mangaDetailsParse(document: Document): SManga { + val infoElement = document.select("div.leftContent").first() + + val manga = SManga.create() + manga.author = infoElement.select("span.elem_author").first()?.text() + manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",") + manga.description = infoElement.select("div.manga-description").text() + manga.status = parseStatus(infoElement.html()) + manga.thumbnail_url = infoElement.select("img").attr("data-full") + return manga + } + + private fun parseStatus(element: String): Int { + when { + element.contains("

Запрещена публикация произведения по копирайту

") -> return SManga.LICENSED + element.contains("

Сингл") || element.contains("Перевод: завершен") -> return SManga.COMPLETED + element.contains("Перевод: продолжается") -> return SManga.ONGOING + else -> return SManga.UNKNOWN + } + } + + override fun chapterListSelector() = "div.chapters-link tbody tr" + + override fun chapterFromElement(element: Element): SChapter { + val urlElement = element.select("a").first() + + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mature=1") + chapter.name = urlElement.text().replace(" новое", "") + chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let { + SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time + } ?: 0 + return chapter + } + + override fun prepareNewChapter(chapter: SChapter, manga: SManga) { + val basic = Regex("""\s([0-9]+)(\s-\s)([0-9]+)\s*""") + val extra = Regex("""\s([0-9]+\sЭкстра)\s*""") + val single = Regex("""\sСингл\s*""") + when { + basic.containsMatchIn(chapter.name) -> { + basic.find(chapter.name)?.let { + val number = it.groups[3]?.value!! + chapter.chapter_number = number.toFloat() + } + } + extra.containsMatchIn(chapter.name) -> // Extra chapters doesn't contain chapter number + chapter.chapter_number = -2f + single.containsMatchIn(chapter.name) -> // Oneshoots, doujinshi and other mangas with one chapter + chapter.chapter_number = 1f + } + } + + override fun pageListParse(response: Response): List { + val html = response.body().string() + val beginIndex = html.indexOf("rm_h.init( [") + val endIndex = html.indexOf("], 0, false);", beginIndex) + val trimmedHtml = html.substring(beginIndex, endIndex) + + val p = Pattern.compile("'.+?','.+?',\".+?\"") + val m = p.matcher(trimmedHtml) + + val pages = mutableListOf() + + var i = 0 + while (m.find()) { + val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',') + pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2])) + } + return pages + } + + override fun pageListParse(document: Document): List { + throw Exception("Not used") + } + + override fun imageUrlParse(document: Document) = "" + + private class Genre(name: String, val id: String) : Filter.TriState(name) + + /* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => { + * const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33); + * return `Genre("${el.textContent.trim()}", "${id}")` }).join(',\n') + * on http://readmanga.me/search + */ + override fun getFilterList() = FilterList( + Genre("арт", "el_5685"), + Genre("боевик", "el_2155"), + Genre("боевые искусства", "el_2143"), + Genre("вампиры", "el_2148"), + Genre("гарем", "el_2142"), + Genre("гендерная интрига", "el_2156"), + Genre("героическое фэнтези", "el_2146"), + Genre("детектив", "el_2152"), + Genre("дзёсэй", "el_2158"), + Genre("додзинси", "el_2141"), + Genre("драма", "el_2118"), + Genre("игра", "el_2154"), + Genre("история", "el_2119"), + Genre("киберпанк", "el_8032"), + Genre("кодомо", "el_2137"), + Genre("комедия", "el_2136"), + Genre("махо-сёдзё", "el_2147"), + Genre("меха", "el_2126"), + Genre("мистика", "el_2132"), + Genre("научная фантастика", "el_2133"), + Genre("повседневность", "el_2135"), + Genre("постапокалиптика", "el_2151"), + Genre("приключения", "el_2130"), + Genre("психология", "el_2144"), + Genre("романтика", "el_2121"), + Genre("самурайский боевик", "el_2124"), + Genre("сверхъестественное", "el_2159"), + Genre("сёдзё", "el_2122"), + Genre("сёдзё-ай", "el_2128"), + Genre("сёнэн", "el_2134"), + Genre("сёнэн-ай", "el_2139"), + Genre("спорт", "el_2129"), + Genre("сэйнэн", "el_2138"), + Genre("трагедия", "el_2153"), + Genre("триллер", "el_2150"), + Genre("ужасы", "el_2125"), + Genre("фантастика", "el_2140"), + Genre("фэнтези", "el_2131"), + Genre("школа", "el_2127"), + Genre("этти", "el_2149"), + Genre("юри", "el_2123") + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt index 64b311b4c..44065233e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt @@ -4,6 +4,7 @@ import android.os.Bundle import eu.kanade.tachiyomi.data.backup.BackupManager import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.isNullOrUnsubscribed import exh.ui.migration.UrlMigrator import rx.Observable import rx.Subscription @@ -49,13 +50,13 @@ class BackupPresenter : BasePresenter() { * @param file the path where the file will be saved. */ fun createBackup(file: File) { - if (isUnsubscribed(backupSubscription)) { + if (backupSubscription.isNullOrUnsubscribed()) { backupSubscription = getBackupObservable(file) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeFirst( { view, result -> view.onBackupCompleted(file) }, - { view, error -> view.onBackupError(error) }) + BackupFragment::onBackupError) } } @@ -65,13 +66,13 @@ class BackupPresenter : BasePresenter() { * @param stream the input stream of the backup file. */ fun restoreBackup(stream: InputStream) { - if (isUnsubscribed(restoreSubscription)) { + if (restoreSubscription.isNullOrUnsubscribed()) { restoreSubscription = getRestoreObservable(stream) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeFirst( { view, result -> view.onRestoreCompleted() }, - { view, error -> view.onRestoreError(error) }) + BackupFragment::onRestoreError) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/ActivityMixin.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/ActivityMixin.kt index a259c3adb..ea1da77e9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/ActivityMixin.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/ActivityMixin.kt @@ -15,11 +15,17 @@ import uy.kohesive.injekt.api.get interface ActivityMixin { + var resumed: Boolean + fun setupToolbar(toolbar: Toolbar, backNavigation: Boolean = true) { setSupportActionBar(toolbar) getSupportActionBar()?.setDisplayHomeAsUpEnabled(true) if (backNavigation) { - toolbar.setNavigationOnClickListener { onBackPressed() } + toolbar.setNavigationOnClickListener { + if (resumed) { + onBackPressed() + } + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt index 6282d010f..38a4568d0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt @@ -5,15 +5,14 @@ import eu.kanade.tachiyomi.util.LocaleHelper abstract class BaseActivity : AppCompatActivity(), ActivityMixin { + override var resumed = false + init { LocaleHelper.updateConfiguration(this) } override fun getActivity() = this - var resumed = false - private set - override fun onResume() { super.onResume() resumed = true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseRxActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseRxActivity.kt index 03420a510..71e598ded 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseRxActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseRxActivity.kt @@ -8,6 +8,8 @@ import nucleus.view.NucleusAppCompatActivity abstract class BaseRxActivity

> : NucleusAppCompatActivity

(), ActivityMixin { + override var resumed = false + init { LocaleHelper.updateConfiguration(this) } @@ -25,9 +27,6 @@ abstract class BaseRxActivity

> : NucleusAppCompatActivity

, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/ItemTouchHelperAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/ItemTouchHelperAdapter.kt deleted file mode 100644 index 8fbb68fc3..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/ItemTouchHelperAdapter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.adapter - -/** - * Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback]. - * - * @author Paul Burke (ipaulpro) - */ -interface ItemTouchHelperAdapter { - - /** - * Called when an item has been dragged far enough to trigger a move. This is called every time - * an item is shifted, and **not** at the end of a "drop" event. - * - * Implementations should call [RecyclerView.Adapter.notifyItemMoved] after - * adjusting the underlying data to reflect this move. - * - * @param fromPosition The start position of the moved item. - * @param toPosition Then resolved position of the moved item. - * @see [RecyclerView.getAdapterPositionFor] - * @see [RecyclerView.ViewHolder.getAdapterPosition] - */ - fun onItemMove(fromPosition: Int, toPosition: Int) - - - /** - * Called when an item has been dismissed by a swipe. - * - * Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after - * adjusting the underlying data to reflect this removal. - * - * @param position The position of the item dismissed. - * @see RecyclerView.getAdapterPositionFor - * @see RecyclerView.ViewHolder.getAdapterPosition - */ - fun onItemDismiss(position: Int) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/OnStartDragListener.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/OnStartDragListener.kt deleted file mode 100644 index 3589c1201..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/OnStartDragListener.kt +++ /dev/null @@ -1,13 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.adapter - -import android.support.v7.widget.RecyclerView - -interface OnStartDragListener { - - /** - * Called when a view is requesting a start of a drag. - * - * @param viewHolder The holder of the view to drag. - */ - fun onStartDrag(viewHolder: RecyclerView.ViewHolder) -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/SimpleItemTouchHelperCallback.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/SimpleItemTouchHelperCallback.kt deleted file mode 100644 index bfbbecc08..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/adapter/SimpleItemTouchHelperCallback.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.adapter - -import android.support.v7.widget.RecyclerView -import android.support.v7.widget.helper.ItemTouchHelper - -open class SimpleItemTouchHelperCallback(private val adapter: ItemTouchHelperAdapter) : ItemTouchHelper.Callback() { - - override fun isLongPressDragEnabled() = true - - override fun isItemViewSwipeEnabled() = true - - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN - val swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END - return ItemTouchHelper.Callback.makeMovementFlags(dragFlags, swipeFlags) - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder): Boolean { - adapter.onItemMove(viewHolder.adapterPosition, target.adapterPosition) - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - adapter.onItemDismiss(viewHolder.adapterPosition) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt index dc91615b3..fbf756a5b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.base.presenter import android.content.Context +import nucleus.presenter.RxPresenter import nucleus.view.ViewWithPresenter import rx.Observable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/RxPresenter.java b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/RxPresenter.java deleted file mode 100644 index 370ab64f9..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/RxPresenter.java +++ /dev/null @@ -1,492 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.presenter; - -import android.os.Bundle; -import android.support.annotation.CallSuper; -import android.support.annotation.Nullable; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; - -import nucleus.presenter.Presenter; -import nucleus.presenter.delivery.DeliverFirst; -import nucleus.presenter.delivery.DeliverLatestCache; -import nucleus.presenter.delivery.DeliverReplay; -import nucleus.presenter.delivery.Delivery; -import rx.Observable; -import rx.Subscription; -import rx.functions.Action1; -import rx.functions.Action2; -import rx.functions.Func0; -import rx.internal.util.SubscriptionList; -import rx.subjects.BehaviorSubject; - -/** - * This is an extension of {@link Presenter} which provides RxJava functionality. - * - * @param a type of view. - */ -public class RxPresenter extends Presenter { - - private static final String REQUESTED_KEY = RxPresenter.class.getName() + "#requested"; - - private final BehaviorSubject views = BehaviorSubject.create(); - private final SubscriptionList subscriptions = new SubscriptionList(); - - private final HashMap> restartables = new HashMap<>(); - private final HashMap restartableSubscriptions = new HashMap<>(); - private final ArrayList requested = new ArrayList<>(); - - /** - * Returns an {@link rx.Observable} that emits the current attached view or null. - * See {@link BehaviorSubject} for more information. - * - * @return an observable that emits the current attached view or null. - */ - public Observable view() { - return views; - } - - /** - * Registers a subscription to automatically unsubscribe it during onDestroy. - * See {@link SubscriptionList#add(Subscription) for details.} - * - * @param subscription a subscription to add. - */ - public void add(Subscription subscription) { - subscriptions.add(subscription); - } - - /** - * Removes and unsubscribes a subscription that has been registered with {@link #add} previously. - * See {@link SubscriptionList#remove(Subscription)} for details. - * - * @param subscription a subscription to remove. - */ - public void remove(Subscription subscription) { - subscriptions.remove(subscription); - } - - /** - * A restartable is any RxJava observable that can be started (subscribed) and - * should be automatically restarted (re-subscribed) after a process restart if - * it was still subscribed at the moment of saving presenter's state. - * - * Registers a factory. Re-subscribes the restartable after the process restart. - * - * @param restartableId id of the restartable - * @param factory factory of the restartable - */ - public void restartable(int restartableId, Func0 factory) { - restartables.put(restartableId, factory); - if (requested.contains(restartableId)) - start(restartableId); - } - - /** - * Starts the given restartable. - * - * @param restartableId id of the restartable - */ - public void start(int restartableId) { - stop(restartableId); - requested.add(restartableId); - restartableSubscriptions.put(restartableId, restartables.get(restartableId).call()); - } - - /** - * Unsubscribes a restartable - * - * @param restartableId id of a restartable. - */ - public void stop(int restartableId) { - requested.remove((Integer) restartableId); - Subscription subscription = restartableSubscriptions.get(restartableId); - if (subscription != null) - subscription.unsubscribe(); - } - - /** - * Checks if a restartable is unsubscribed. - * - * @param restartableId id of the restartable. - * @return true if the subscription is null or unsubscribed, false otherwise. - */ - public boolean isUnsubscribed(int restartableId) { - return isUnsubscribed(restartableSubscriptions.get(restartableId)); - } - - /** - * Checks if a subscription is unsubscribed. - * - * @param subscription the subscription to check. - * @return true if the subscription is null or unsubscribed, false otherwise. - */ - public boolean isUnsubscribed(@Nullable Subscription subscription) { - return subscription == null || subscription.isUnsubscribed(); - } - - /** - * This is a shortcut that can be used instead of combining together - * {@link #restartable(int, Func0)}, - * {@link #deliverFirst()}, - * {@link #split(Action2, Action2)}. - * - * @param restartableId an id of the restartable. - * @param observableFactory a factory that should return an Observable when the restartable should run. - * @param onNext a callback that will be called when received data should be delivered to view. - * @param onError a callback that will be called if the source observable emits onError. - * @param the type of the observable. - */ - public void restartableFirst(int restartableId, final Func0> observableFactory, - final Action2 onNext, @Nullable final Action2 onError) { - - restartable(restartableId, new Func0() { - @Override - public Subscription call() { - return observableFactory.call() - .compose(RxPresenter.this.deliverFirst()) - .subscribe(split(onNext, onError)); - } - }); - } - - /** - * This is a shortcut for calling {@link #restartableFirst(int, Func0, Action2, Action2)} with the last parameter = null. - */ - public void restartableFirst(int restartableId, final Func0> observableFactory, final Action2 onNext) { - restartableFirst(restartableId, observableFactory, onNext, null); - } - - /** - * This is a shortcut that can be used instead of combining together - * {@link #restartable(int, Func0)}, - * {@link #deliverLatestCache()}, - * {@link #split(Action2, Action2)}. - * - * @param restartableId an id of the restartable. - * @param observableFactory a factory that should return an Observable when the restartable should run. - * @param onNext a callback that will be called when received data should be delivered to view. - * @param onError a callback that will be called if the source observable emits onError. - * @param the type of the observable. - */ - public void restartableLatestCache(int restartableId, final Func0> observableFactory, - final Action2 onNext, @Nullable final Action2 onError) { - - restartable(restartableId, new Func0() { - @Override - public Subscription call() { - return observableFactory.call() - .compose(RxPresenter.this.deliverLatestCache()) - .subscribe(split(onNext, onError)); - } - }); - } - - /** - * This is a shortcut for calling {@link #restartableLatestCache(int, Func0, Action2, Action2)} with the last parameter = null. - */ - public void restartableLatestCache(int restartableId, final Func0> observableFactory, final Action2 onNext) { - restartableLatestCache(restartableId, observableFactory, onNext, null); - } - - /** - * This is a shortcut that can be used instead of combining together - * {@link #restartable(int, Func0)}, - * {@link #deliverReplay()}, - * {@link #split(Action2, Action2)}. - * - * @param restartableId an id of the restartable. - * @param observableFactory a factory that should return an Observable when the restartable should run. - * @param onNext a callback that will be called when received data should be delivered to view. - * @param onError a callback that will be called if the source observable emits onError. - * @param the type of the observable. - */ - public void restartableReplay(int restartableId, final Func0> observableFactory, - final Action2 onNext, @Nullable final Action2 onError) { - - restartable(restartableId, new Func0() { - @Override - public Subscription call() { - return observableFactory.call() - .compose(RxPresenter.this.deliverReplay()) - .subscribe(split(onNext, onError)); - } - }); - } - - /** - * This is a shortcut for calling {@link #restartableReplay(int, Func0, Action2, Action2)} with the last parameter = null. - */ - public void restartableReplay(int restartableId, final Func0> observableFactory, final Action2 onNext) { - restartableReplay(restartableId, observableFactory, onNext, null); - } - - /** - * A startable behaves the same as a restartable but it does not resubscribe on process restart - * - * @param startableId an id of the restartable. - * @param observableFactory a factory that should return an Observable when the startable should run. - */ - public void startable(int startableId, final Func0> observableFactory) { - restartables.put(startableId, new Func0() { - @Override - public Subscription call() {return observableFactory.call().subscribe();} - }); - } - - /** - * A startable behaves the same as a restartable but it does not resubscribe on process restart - * - * @param startableId an id of the restartable. - * @param observableFactory a factory that should return an Observable when the startable should run. - * @param onNext a callback that will be called when received data should be delivered to view. - * @param onError a callback that will be called if the source observable emits onError. - */ - public void startable(int startableId, final Func0> observableFactory, - final Action1 onNext, final Action1 onError) { - - restartables.put(startableId, new Func0() { - @Override - public Subscription call() {return observableFactory.call().subscribe(onNext, onError);} - }); - } - - /** - * A startable behaves the same as a restartable but it does not resubscribe on process restart - * - * @param startableId an id of the restartable. - * @param observableFactory a factory that should return an Observable when the startable should run. - * @param onNext a callback that will be called when received data should be delivered to view. - */ - public void startable(int startableId, final Func0> observableFactory, final Action1 onNext) { - restartables.put(startableId, new Func0() { - @Override - public Subscription call() {return observableFactory.call().subscribe(onNext);} - }); - } - - /** - * This is a shortcut that can be used instead of combining together - * {@link #startable(int, Func0)}, - * {@link #deliverFirst()}, - * {@link #split(Action2, Action2)}. - * - * @param startableId an id of the startable. - * @param observableFactory a factory that should return an Observable when the startable should run. - * @param onNext a callback that will be called when received data should be delivered to view. - * @param onError a callback that will be called if the source observable emits onError. - * @param the type of the observable. - */ - public void startableFirst(int startableId, final Func0> observableFactory, - final Action2 onNext, @Nullable final Action2 onError) { - - restartables.put(startableId, new Func0() { - @Override - public Subscription call() { - return observableFactory.call() - .compose(RxPresenter.this.deliverFirst()) - .subscribe(split(onNext, onError)); - } - }); - } - - /** - * This is a shortcut for calling {@link #startableFirst(int, Func0, Action2, Action2)} with the last parameter = null. - */ - public void startableFirst(int startableId, final Func0> observableFactory, final Action2 onNext) { - startableFirst(startableId, observableFactory, onNext, null); - } - - /** - * This is a shortcut that can be used instead of combining together - * {@link #startable(int, Func0)}, - * {@link #deliverLatestCache()}, - * {@link #split(Action2, Action2)}. - * - * @param startableId an id of the startable. - * @param observableFactory a factory that should return an Observable when the startable should run. - * @param onNext a callback that will be called when received data should be delivered to view. - * @param onError a callback that will be called if the source observable emits onError. - * @param the type of the observable. - */ - public void startableLatestCache(int startableId, final Func0> observableFactory, - final Action2 onNext, @Nullable final Action2 onError) { - - restartables.put(startableId, new Func0() { - @Override - public Subscription call() { - return observableFactory.call() - .compose(RxPresenter.this.deliverLatestCache()) - .subscribe(split(onNext, onError)); - } - }); - } - - /** - * This is a shortcut for calling {@link #startableLatestCache(int, Func0, Action2, Action2)} with the last parameter = null. - */ - public void startableLatestCache(int startableId, final Func0> observableFactory, final Action2 onNext) { - startableLatestCache(startableId, observableFactory, onNext, null); - } - - /** - * This is a shortcut that can be used instead of combining together - * {@link #startable(int, Func0)}, - * {@link #deliverReplay()}, - * {@link #split(Action2, Action2)}. - * - * @param startableId an id of the startable. - * @param observableFactory a factory that should return an Observable when the startable should run. - * @param onNext a callback that will be called when received data should be delivered to view. - * @param onError a callback that will be called if the source observable emits onError. - * @param the type of the observable. - */ - public void startableReplay(int startableId, final Func0> observableFactory, - final Action2 onNext, @Nullable final Action2 onError) { - - restartables.put(startableId, new Func0() { - @Override - public Subscription call() { - return observableFactory.call() - .compose(RxPresenter.this.deliverReplay()) - .subscribe(split(onNext, onError)); - } - }); - } - - /** - * This is a shortcut for calling {@link #startableReplay(int, Func0, Action2, Action2)} with the last parameter = null. - */ - public void startableReplay(int startableId, final Func0> observableFactory, final Action2 onNext) { - startableReplay(startableId, observableFactory, onNext, null); - } - - /** - * Returns an {@link rx.Observable.Transformer} that couples views with data that has been emitted by - * the source {@link rx.Observable}. - * - * {@link #deliverLatestCache} keeps the latest onNext value and emits it each time a new view gets attached. - * If a new onNext value appears while a view is attached, it will be delivered immediately. - * - * @param the type of source observable emissions - */ - public DeliverLatestCache deliverLatestCache() { - return new DeliverLatestCache<>(views); - } - - /** - * Returns an {@link rx.Observable.Transformer} that couples views with data that has been emitted by - * the source {@link rx.Observable}. - * - * {@link #deliverFirst} delivers only the first onNext value that has been emitted by the source observable. - * - * @param the type of source observable emissions - */ - public DeliverFirst deliverFirst() { - return new DeliverFirst<>(views); - } - - /** - * Returns an {@link rx.Observable.Transformer} that couples views with data that has been emitted by - * the source {@link rx.Observable}. - * - * {@link #deliverReplay} keeps all onNext values and emits them each time a new view gets attached. - * If a new onNext value appears while a view is attached, it will be delivered immediately. - * - * @param the type of source observable emissions - */ - public DeliverReplay deliverReplay() { - return new DeliverReplay<>(views); - } - - /** - * Returns a method that can be used for manual restartable chain build. It returns an Action1 that splits - * a received {@link Delivery} into two {@link Action2} onNext and onError calls. - * - * @param onNext a method that will be called if the delivery contains an emitted onNext value. - * @param onError a method that will be called if the delivery contains an onError throwable. - * @param a type on onNext value. - * @return an Action1 that splits a received {@link Delivery} into two {@link Action2} onNext and onError calls. - */ - public Action1> split(final Action2 onNext, @Nullable final Action2 onError) { - return new Action1>() { - @Override - public void call(Delivery delivery) { - delivery.split(onNext, onError); - } - }; - } - - /** - * This is a shortcut for calling {@link #split(Action2, Action2)} when the second parameter is null. - */ - public Action1> split(Action2 onNext) { - return split(onNext, null); - } - - /** - * {@inheritDoc} - */ - @CallSuper - @Override - protected void onCreate(Bundle savedState) { - if (savedState != null) - requested.addAll(savedState.getIntegerArrayList(REQUESTED_KEY)); - } - - /** - * {@inheritDoc} - */ - @CallSuper - @Override - protected void onDestroy() { - views.onCompleted(); - subscriptions.unsubscribe(); - for (Map.Entry entry : restartableSubscriptions.entrySet()) - entry.getValue().unsubscribe(); - } - - /** - * {@inheritDoc} - */ - @CallSuper - @Override - protected void onSave(Bundle state) { - for (int i = requested.size() - 1; i >= 0; i--) { - int restartableId = requested.get(i); - Subscription subscription = restartableSubscriptions.get(restartableId); - if (subscription != null && subscription.isUnsubscribed()) - requested.remove(i); - } - state.putIntegerArrayList(REQUESTED_KEY, requested); - } - - /** - * {@inheritDoc} - */ - @CallSuper - @Override - protected void onTakeView(View view) { - views.onNext(view); - } - - /** - * {@inheritDoc} - */ - @CallSuper - @Override - protected void onDropView() { - views.onNext(null); - } - - /** - * Please, use restartableXX and deliverXX methods for pushing data from RxPresenter into View. - */ - @Deprecated - @Nullable - @Override - public View getView() { - return super.getView(); - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt deleted file mode 100644 index 4674ddcf7..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt +++ /dev/null @@ -1,107 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue - -import android.view.Gravity -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.FrameLayout -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.util.inflate -import kotlinx.android.synthetic.main.fragment_catalogue.* -import kotlinx.android.synthetic.main.item_catalogue_grid.view.* -import java.util.* - -/** - * Adapter storing a list of manga from the catalogue. - * - * @param fragment the fragment containing this adapter. - */ -class CatalogueAdapter(val fragment: CatalogueFragment) : FlexibleAdapter() { - - /** - * Property to get the list of manga in the adapter. - */ - val items: List - get() = mItems - - init { - mItems = ArrayList() - setHasStableIds(true) - } - - /** - * Adds a list of manga to the adapter. - * - * @param list the list to add. - */ - fun addItems(list: List) { - if (list.isNotEmpty()) { - val sizeBeforeAdding = mItems.size - mItems.addAll(list) - notifyItemRangeInserted(sizeBeforeAdding, list.size) - } - } - - /** - * Clears the list of manga from the adapter. - */ - fun clear() { - val sizeBeforeRemoving = mItems.size - mItems.clear() - notifyItemRangeRemoved(0, sizeBeforeRemoving) - } - - /** - * Returns the identifier for a manga. - * - * @param position the position in the adapter. - * @return an identifier for the item. - */ - override fun getItemId(position: Int): Long { - return mItems[position].id!! - } - - /** - * Used to filter the list. Required but not used. - */ - override fun updateDataSet(param: String) {} - - /** - * Creates a new view holder. - * - * @param parent the parent view. - * @param viewType the type of the holder. - * @return a new view holder for a manga. - */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CatalogueHolder { - if (parent.id == R.id.catalogue_grid) { - val view = parent.inflate(R.layout.item_catalogue_grid).apply { - card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight) - gradient.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM) - } - return CatalogueGridHolder(view, this, fragment) - } else { - val view = parent.inflate(R.layout.item_catalogue_list) - return CatalogueListHolder(view, this, fragment) - } - } - - /** - * Binds a holder with a new position. - * - * @param holder the holder to bind. - * @param position the position to bind. - */ - override fun onBindViewHolder(holder: CatalogueHolder, position: Int) { - val manga = getItem(position) - holder.onSetValues(manga) - } - - /** - * Property to return the height for the covers based on the width to keep an aspect ratio. - */ - val coverHeight: Int - get() = fragment.catalogue_grid.itemWidth / 3 * 4 - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt index 68f2ee128..15af1ad94 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt @@ -3,32 +3,35 @@ package eu.kanade.tachiyomi.ui.catalogue import android.content.res.Configuration import android.os.Bundle import android.support.design.widget.Snackbar +import android.support.v4.widget.DrawerLayout import android.support.v7.widget.* import android.view.* -import android.view.animation.AnimationUtils import android.widget.ArrayAdapter import android.widget.ProgressBar import android.widget.Spinner import com.afollestad.materialdialogs.MaterialDialog import com.f2prateek.rx.preferences.Preference +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.online.LoginSource -import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder +import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaActivity +import eu.kanade.tachiyomi.util.connectivityManager +import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.util.snack import eu.kanade.tachiyomi.util.toast -import eu.kanade.tachiyomi.widget.EndlessScrollListener +import eu.kanade.tachiyomi.widget.AutofitRecyclerView import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener +import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.fragment_catalogue.* import kotlinx.android.synthetic.main.toolbar.* import nucleus.factory.RequiresPresenter import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.subjects.PublishSubject -import timber.log.Timber import java.util.concurrent.TimeUnit.MILLISECONDS /** @@ -36,7 +39,10 @@ import java.util.concurrent.TimeUnit.MILLISECONDS * Uses R.layout.fragment_catalogue. */ @RequiresPresenter(CataloguePresenter::class) -open class CatalogueFragment : BaseRxFragment(), FlexibleViewHolder.OnListItemClickListener { +open class CatalogueFragment : BaseRxFragment(), + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + FlexibleAdapter.EndlessScrollListener { /** * Spinner shown in the toolbar to change the selected source. @@ -46,17 +52,7 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie /** * Adapter containing the list of manga from the catalogue. */ - private lateinit var adapter: CatalogueAdapter - - /** - * Scroll listener for grid mode. It loads next pages when the end of the list is reached. - */ - private lateinit var gridScrollListener: EndlessScrollListener - - /** - * Scroll listener for list mode. It loads next pages when the end of the list is reached. - */ - private lateinit var listScrollListener: EndlessScrollListener + private lateinit var adapter: FlexibleAdapter> /** * Query of the search box. @@ -100,6 +96,39 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie private val toolbar: Toolbar get() = (activity as MainActivity).toolbar + /** + * Snackbar containing an error message when a request fails. + */ + private var snack: Snackbar? = null + + /** + * Navigation view containing filter items. + */ + private var navView: CatalogueNavigationView? = null + + /** + * Drawer listener to allow swipe only for closing the drawer. + */ + private val drawerListener by lazy { + object : DrawerLayout.SimpleDrawerListener() { + override fun onDrawerClosed(drawerView: View) { + if (drawerView == navView) { + activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView) + } + } + + override fun onDrawerOpened(drawerView: View) { + if (drawerView == navView) { + activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, navView) + } + } + } + } + + lateinit var recycler: RecyclerView + + private var progressItem: ProgressItem? = null + companion object { /** * Creates a new instance of this fragment. @@ -121,42 +150,9 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie } override fun onViewCreated(view: View, savedState: Bundle?) { - // If the source list is empty or it only has unlogged sources, return to main screen. - val sources = presenter.sources - if (sources.isEmpty() || sources.all { it is LoginSource && !it.isLogged() }) { - context.toast(R.string.no_valid_sources) - activity.onBackPressed() - return - } - // Initialize adapter, scroll listener and recycler views - adapter = CatalogueAdapter(this) - - val glm = catalogue_grid.layoutManager as GridLayoutManager - gridScrollListener = EndlessScrollListener(glm, { requestNextPage() }) - catalogue_grid.setHasFixedSize(true) - catalogue_grid.adapter = adapter - catalogue_grid.addOnScrollListener(gridScrollListener) - - val llm = LinearLayoutManager(activity) - listScrollListener = EndlessScrollListener(llm, { requestNextPage() }) - catalogue_list.setHasFixedSize(true) - catalogue_list.adapter = adapter - catalogue_list.layoutManager = llm - catalogue_list.addOnScrollListener(listScrollListener) - catalogue_list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) - if (presenter.isListMode) { - switcher.showNext() - } - - numColumnsSubscription = getColumnsPreferenceForCurrentOrientation().asObservable() - .doOnNext { catalogue_grid.spanCount = it } - .skip(1) - // Set again the adapter to recalculate the covers height - .subscribe { catalogue_grid.adapter = adapter } - - switcher.inAnimation = AnimationUtils.loadAnimation(activity, android.R.anim.fade_in) - switcher.outAnimation = AnimationUtils.loadAnimation(activity, android.R.anim.fade_out) + adapter = FlexibleAdapter(null, this) + setupRecycler() // Create toolbar spinner val themedContext = activity.supportActionBar?.themedContext ?: activity @@ -173,9 +169,9 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie } else if (source != presenter.source) { selectedIndex = position showProgressBar() - glm.scrollToPositionWithOffset(0, 0) - llm.scrollToPositionWithOffset(0, 0) + adapter.clear() presenter.setActiveSource(source) + navView?.setFilters(presenter.filterItems) activity.invalidateOptionsMenu() } } @@ -191,9 +187,82 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie setToolbarTitle("") toolbar.addView(spinner) + // Inflate and prepare drawer + val navView = activity.drawer.inflate(R.layout.catalogue_drawer) as CatalogueNavigationView + this.navView = navView + activity.drawer.addView(navView) + activity.drawer.addDrawerListener(drawerListener) + navView.setFilters(presenter.filterItems) + + navView.post { + if (isAdded && !activity.drawer.isDrawerOpen(navView)) + activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView) + } + + navView.onSearchClicked = { + val allDefault = presenter.sourceFilters == presenter.source.getFilterList() + showProgressBar() + adapter.clear() + presenter.setSourceFilter(if (allDefault) FilterList() else presenter.sourceFilters) + } + + navView.onResetClicked = { + presenter.appliedFilters = FilterList() + val newFilters = presenter.source.getFilterList() + presenter.sourceFilters = newFilters + navView.setFilters(presenter.filterItems) + } + showProgressBar() } + private fun setupRecycler() { + if (!isAdded) return + + numColumnsSubscription?.unsubscribe() + + val oldRecycler = catalogue_view.getChildAt(1) + var oldPosition = RecyclerView.NO_POSITION + if (oldRecycler is RecyclerView) { + oldPosition = (oldRecycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + oldRecycler.adapter = null + + catalogue_view.removeView(oldRecycler) + } + + recycler = if (presenter.isListMode) { + RecyclerView(context).apply { + layoutManager = LinearLayoutManager(context) + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + } + } else { + (catalogue_view.inflate(R.layout.recycler_autofit) as AutofitRecyclerView).apply { + numColumnsSubscription = getColumnsPreferenceForCurrentOrientation().asObservable() + .doOnNext { spanCount = it } + .skip(1) + // Set again the adapter to recalculate the covers height + .subscribe { adapter = this@CatalogueFragment.adapter } + + (layoutManager as GridLayoutManager).spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return when (adapter?.getItemViewType(position)) { + R.layout.item_catalogue_grid, null -> 1 + else -> spanCount + } + } + } + } + } + recycler.setHasFixedSize(true) + recycler.adapter = adapter + + catalogue_view.addView(recycler, 1) + + if (oldPosition != RecyclerView.NO_POSITION) { + recycler.layoutManager.scrollToPosition(oldPosition) + } + } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.catalogue_list, menu) @@ -222,7 +291,7 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie // Setup filters button menu.findItem(R.id.action_set_filter).apply { icon.mutate() - if (presenter.source.filters.isEmpty()) { + if (presenter.sourceFilters.isEmpty()) { isEnabled = false icon.alpha = 128 } else { @@ -244,7 +313,7 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_display_mode -> swapDisplayMode() - R.id.action_set_filter -> showFiltersDialog() + R.id.action_set_filter -> navView?.let { activity.drawer.openDrawer(Gravity.END) } else -> return super.onOptionsItemSelected(item) } return true @@ -263,6 +332,10 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie } override fun onDestroyView() { + navView?.let { + activity.drawer.removeDrawerListener(drawerListener) + activity.drawer.removeView(it) + } numColumnsSubscription?.unsubscribe() searchItem?.let { if (it.isActionViewExpanded) it.collapseActionView() @@ -296,36 +369,24 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie return showProgressBar() - catalogue_grid.layoutManager.scrollToPosition(0) - catalogue_list.layoutManager.scrollToPosition(0) + adapter.clear() presenter.restartPager(newQuery) } - /** - * Requests the next page (if available). Called from scroll listeners when they reach the end. - */ - private fun requestNextPage() { - if (presenter.hasNextPage()) { - showGridProgressBar() - presenter.requestNext() - } - } - /** * Called from the presenter when the network request is received. * * @param page the current page. * @param mangas the list of manga of the page. */ - fun onAddPage(page: Int, mangas: List) { + fun onAddPage(page: Int, mangas: List) { hideProgressBar() if (page == 1) { adapter.clear() - gridScrollListener.resetScroll() - listScrollListener.resetScroll() + resetProgressItem() } - adapter.addItems(mangas) + adapter.onLoadMoreComplete(mangas) } /** @@ -334,17 +395,50 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie * @param error the error received. */ fun onAddPageError(error: Throwable) { + adapter.onLoadMoreComplete(null) hideProgressBar() - Timber.e(error) - catalogue_view.snack(error.message ?: "", Snackbar.LENGTH_INDEFINITE) { + val message = if (error is NoResultsException) "No results found" else (error.message ?: "") + + snack?.dismiss() + snack = catalogue_view.snack(message, Snackbar.LENGTH_INDEFINITE) { setAction(R.string.action_retry) { - showProgressBar() + // If not the first page, show bottom progress bar. + if (adapter.mainItemCount > 0) { + val item = progressItem ?: return@setAction + adapter.addScrollableFooterWithDelay(item, 0, true) + } else { + showProgressBar() + } presenter.requestNext() } } } + /** + * Sets a new progress item and reenables the scroll listener. + */ + private fun resetProgressItem() { + progressItem = ProgressItem() + adapter.endlessTargetCount = 0 + adapter.setEndlessScrollListener(this, progressItem!!) + } + + /** + * Called by the adapter when scrolled near the bottom. + */ + override fun onLoadMore(lastPosition: Int, currentPage: Int) { + if (presenter.hasNextPage()) { + presenter.requestNext() + } else { + adapter.onLoadMoreComplete(null) + adapter.endlessTargetCount = 1 + } + } + + override fun noMoreLoad(newItemsSize: Int) { + } + /** * Called from the presenter when a manga is initialized. * @@ -358,13 +452,18 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie * Swaps the current display mode. */ fun swapDisplayMode() { + if (!isAdded) return + presenter.swapDisplayMode() val isListMode = presenter.isListMode activity.invalidateOptionsMenu() - switcher.showNext() - if (!isListMode) { - // Initialize mangas if going to grid view - presenter.initializeMangas(adapter.items) + setupRecycler() + if (!isListMode || !context.connectivityManager.isActiveNetworkMetered) { + // Initialize mangas if going to grid view or if over wifi when going to list view + val mangas = (0..adapter.itemCount-1).mapNotNull { + (adapter.getItem(it) as? CatalogueItem)?.manga + } + presenter.initializeMangas(mangas) } } @@ -386,8 +485,15 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie * @param manga the manga to find. * @return the holder of the manga or null if it's not bound. */ - private fun getHolder(manga: Manga): CatalogueGridHolder? { - return catalogue_grid.findViewHolderForItemId(manga.id!!) as? CatalogueGridHolder + private fun getHolder(manga: Manga): CatalogueHolder? { + adapter.allBoundViewHolders.forEach { holder -> + val item = adapter.getItem(holder.adapterPosition) as? CatalogueItem + if (item != null && item.manga.id!! == manga.id!!) { + return holder as CatalogueHolder + } + } + + return null } /** @@ -395,13 +501,8 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie */ private fun showProgressBar() { progress.visibility = ProgressBar.VISIBLE - } - - /** - * Shows the progress bar at the end of the screen. - */ - private fun showGridProgressBar() { - progress_grid.visibility = ProgressBar.VISIBLE + snack?.dismiss() + snack = null } /** @@ -409,7 +510,6 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie */ private fun hideProgressBar() { progress.visibility = ProgressBar.GONE - progress_grid.visibility = ProgressBar.GONE } /** @@ -418,10 +518,10 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie * @param position the position of the element clicked. * @return true if the item should be selected, false otherwise. */ - override fun onListItemClick(position: Int): Boolean { - val item = adapter.getItem(position) ?: return false + override fun onItemClick(position: Int): Boolean { + val item = adapter.getItem(position) as? CatalogueItem ?: return false - val intent = MangaActivity.newIntent(activity, item, true) + val intent = MangaActivity.newIntent(activity, item.manga, true) startActivity(intent) return false } @@ -431,8 +531,8 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie * * @param position the position of the element clicked. */ - override fun onListItemLongClick(position: Int) { - val manga = adapter.getItem(position) ?: return + override fun onItemLongClick(position: Int) { + val manga = (adapter.getItem(position) as? CatalogueItem?)?.manga ?: return val textRes = if (manga.favorite) R.string.remove_from_library else R.string.add_to_library @@ -448,27 +548,4 @@ open class CatalogueFragment : BaseRxFragment(), FlexibleVie }.show() } - /** - * Show the filter dialog for the source. - */ - private fun showFiltersDialog() { - val allFilters = presenter.source.filters - val selectedFilters = presenter.filters - .map { filter -> allFilters.indexOf(filter) } - .toTypedArray() - - MaterialDialog.Builder(context) - .title(R.string.action_set_filter) - .items(allFilters.map { it.name }) - .itemsCallbackMultiChoice(selectedFilters) { dialog, positions, text -> - val newFilters = positions.map { allFilters[it] } - showProgressBar() - presenter.setSourceFilter(newFilters) - true - } - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .show() - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.kt index 0dcd4e182..08ce9336b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.kt @@ -3,7 +3,9 @@ package eu.kanade.tachiyomi.ui.catalogue import android.view.View import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.widget.StateImageViewTarget import kotlinx.android.synthetic.main.item_catalogue_grid.view.* /** @@ -12,11 +14,10 @@ import kotlinx.android.synthetic.main.item_catalogue_grid.view.* * * @param view the inflated view for this holder. * @param adapter the adapter handling this holder. - * @param listener a listener to react to single tap and long tap events. * @constructor creates a new catalogue holder. */ -class CatalogueGridHolder(private val view: View, private val adapter: CatalogueAdapter, listener: OnListItemClickListener) : - CatalogueHolder(view, adapter, listener) { +class CatalogueGridHolder(private val view: View, private val adapter: FlexibleAdapter<*>) : + CatalogueHolder(view, adapter) { /** * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this @@ -34,13 +35,7 @@ class CatalogueGridHolder(private val view: View, private val adapter: Catalogue setImage(manga) } - /** - * Updates the image for this holder. Useful to update the image when the manga is initialized - * and the url is now known. - * - * @param manga the manga to bind. - */ - fun setImage(manga: Manga) { + override fun setImage(manga: Manga) { Glide.clear(view.thumbnail) if (!manga.thumbnail_url.isNullOrEmpty()) { Glide.with(view.context) @@ -49,7 +44,7 @@ class CatalogueGridHolder(private val view: View, private val adapter: Catalogue .centerCrop() .skipMemoryCache(true) .placeholder(android.R.color.transparent) - .into(view.thumbnail) + .into(StateImageViewTarget(view.thumbnail, view.progress)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.kt index 1280a0be8..014b7904a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.kt @@ -1,18 +1,18 @@ package eu.kanade.tachiyomi.ui.catalogue import android.view.View +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder /** * Generic class used to hold the displayed data of a manga in the catalogue. * * @param view the inflated view for this holder. * @param adapter the adapter handling this holder. - * @param listener a listener to react to single tap and long tap events. */ -abstract class CatalogueHolder(view: View, adapter: CatalogueAdapter, listener: OnListItemClickListener) : - FlexibleViewHolder(view, adapter, listener) { +abstract class CatalogueHolder(view: View, adapter: FlexibleAdapter<*>) : + FlexibleViewHolder(view, adapter) { /** * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this @@ -21,4 +21,13 @@ abstract class CatalogueHolder(view: View, adapter: CatalogueAdapter, listener: * @param manga the manga to bind. */ abstract fun onSetValues(manga: Manga) + + + /** + * Updates the image for this holder. Useful to update the image when the manga is initialized + * and the url is now known. + * + * @param manga the manga to bind. + */ + abstract fun setImage(manga: Manga) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueItem.kt new file mode 100644 index 000000000..5aa1eecd1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueItem.kt @@ -0,0 +1,53 @@ +package eu.kanade.tachiyomi.ui.catalogue + +import android.view.Gravity +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.FrameLayout +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.widget.AutofitRecyclerView +import kotlinx.android.synthetic.main.item_catalogue_grid.view.* + +class CatalogueItem(val manga: Manga) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.item_catalogue_grid + } + + override fun createViewHolder(adapter: FlexibleAdapter>, inflater: LayoutInflater, parent: ViewGroup): CatalogueHolder { + if (parent is AutofitRecyclerView) { + val view = parent.inflate(R.layout.item_catalogue_grid).apply { + card.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, parent.itemWidth / 3 * 4) + gradient.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, parent.itemWidth / 3 * 4 / 2, Gravity.BOTTOM) + } + return CatalogueGridHolder(view, adapter) + } else { + val view = parent.inflate(R.layout.item_catalogue_list) + return CatalogueListHolder(view, adapter) + } + } + + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: CatalogueHolder, position: Int, payloads: List?) { + holder.onSetValues(manga) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is CatalogueItem) { + return manga.id!! == other.manga.id!! + } + return false + } + + override fun hashCode(): Int { + return manga.id!!.hashCode() + } + + + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.kt index 262311cc2..9f98786b0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.kt @@ -1,6 +1,9 @@ package eu.kanade.tachiyomi.ui.catalogue import android.view.View +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.util.getResourceColor import kotlinx.android.synthetic.main.item_catalogue_list.view.* @@ -11,14 +14,13 @@ import kotlinx.android.synthetic.main.item_catalogue_list.view.* * * @param view the inflated view for this holder. * @param adapter the adapter handling this holder. - * @param listener a listener to react to single tap and long tap events. * @constructor creates a new catalogue holder. */ -class CatalogueListHolder(private val view: View, adapter: CatalogueAdapter, listener: OnListItemClickListener) : - CatalogueHolder(view, adapter, listener) { +class CatalogueListHolder(private val view: View, adapter: FlexibleAdapter<*>) : + CatalogueHolder(view, adapter) { - private val favoriteColor = view.context.theme.getResourceColor(android.R.attr.textColorHint) - private val unfavoriteColor = view.context.theme.getResourceColor(android.R.attr.textColorPrimary) + private val favoriteColor = view.context.getResourceColor(android.R.attr.textColorHint) + private val unfavoriteColor = view.context.getResourceColor(android.R.attr.textColorPrimary) /** * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this @@ -29,5 +31,22 @@ class CatalogueListHolder(private val view: View, adapter: CatalogueAdapter, lis override fun onSetValues(manga: Manga) { view.title.text = manga.title view.title.setTextColor(if (manga.favorite) favoriteColor else unfavoriteColor) + + setImage(manga) } + + override fun setImage(manga: Manga) { + Glide.clear(view.thumbnail) + if (!manga.thumbnail_url.isNullOrEmpty()) { + Glide.with(view.context) + .load(manga) + .diskCacheStrategy(DiskCacheStrategy.SOURCE) + .centerCrop() + .dontAnimate() + .skipMemoryCache(true) + .placeholder(android.R.color.transparent) + .into(view.thumbnail) + } + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueNavigationView.kt new file mode 100644 index 000000000..1bf4d3c0f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueNavigationView.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.ui.catalogue + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.widget.SimpleNavigationView +import kotlinx.android.synthetic.main.catalogue_drawer_content.view.* + + +class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) + : SimpleNavigationView(context, attrs) { + + val adapter: FlexibleAdapter> = FlexibleAdapter>(null) + .setDisplayHeadersAtStartUp(true) + .setStickyHeaders(true) + + var onSearchClicked = {} + + var onResetClicked = {} + + init { + recycler.adapter = adapter + recycler.setHasFixedSize(true) + val view = inflate(R.layout.catalogue_drawer_content) + ((view as ViewGroup).getChildAt(1) as ViewGroup).addView(recycler) + addView(view) + + search_btn.setOnClickListener { onSearchClicked() } + reset_btn.setOnClickListener { onResetClicked() } + } + + fun setFilters(items: List>) { + adapter.updateDataSet(items.toMutableList()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePager.kt index 301960414..b95798a7d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePager.kt @@ -1,28 +1,32 @@ package eu.kanade.tachiyomi.ui.catalogue -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.online.OnlineSource -import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers -open class CataloguePager(val source: OnlineSource, val query: String, val filters: List): Pager() { +open class CataloguePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() { - override fun requestNext(transformer: (Observable) -> Observable): Observable { - val lastPage = lastPage - - val page = if (lastPage == null) - MangasPage(1) - else - MangasPage(lastPage.page + 1).apply { url = lastPage.nextPageUrl!! } + override fun requestNext(): Observable { + val page = currentPage val observable = if (query.isBlank() && filters.isEmpty()) source.fetchPopularManga(page) else source.fetchSearchManga(page, query, filters) - return transformer(observable) - .doOnNext { results.onNext(it) } - .doOnNext { this@CataloguePager.lastPage = it } + return observable + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + if (it.mangas.isNotEmpty()) { + onPageReceived(it) + } else { + throw NoResultsException() + } + } } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt index a35507376..25ac8abb8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt @@ -1,18 +1,22 @@ package eu.kanade.tachiyomi.ui.catalogue import android.os.Bundle +import eu.davidea.flexibleadapter.items.IFlexible +import eu.davidea.flexibleadapter.items.ISectionable import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.SourceManager -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.online.LoginSource -import eu.kanade.tachiyomi.data.source.online.OnlineSource -import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.LoginSource import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.catalogue.filter.* import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers @@ -20,7 +24,6 @@ import rx.schedulers.Schedulers import rx.subjects.PublishSubject import timber.log.Timber import uy.kohesive.injekt.injectLazy -import java.util.* /** * Presenter of [CatalogueFragment]. @@ -55,7 +58,7 @@ open class CataloguePresenter : BasePresenter() { /** * Active source. */ - lateinit var source: OnlineSource + lateinit var source: CatalogueSource private set /** @@ -65,9 +68,20 @@ open class CataloguePresenter : BasePresenter() { private set /** - * Active filters. + * Modifiable list of filters. */ - var filters: List = emptyList() + var sourceFilters = FilterList() + set(value) { + field = value + filterItems = value.toItems() + } + + var filterItems: List> = emptyList() + + /** + * List of filters used by the [Pager]. If empty alongside [query], the popular query is used. + */ + var appliedFilters = FilterList() /** * Pager containing a list of manga results. @@ -103,11 +117,8 @@ open class CataloguePresenter : BasePresenter() { override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - try { - source = getLastUsedSource() - } catch (error: NoSuchElementException) { - return - } + source = getLastUsedSource() + sourceFilters = source.getFilterList() if (savedState != null) { query = savedState.getString(CataloguePresenter::query.name, "") @@ -128,24 +139,29 @@ open class CataloguePresenter : BasePresenter() { * Restarts the pager for the active source with the provided query and filters. * * @param query the query. - * @param filters the list of active filters (for search mode). + * @param filters the current state of the filters (for search mode). */ - fun restartPager(query: String = this.query, filters: List = this.filters) { + fun restartPager(query: String = this.query, filters: FilterList = this.appliedFilters) { this.query = query - this.filters = filters + this.appliedFilters = filters - if (!isListMode) { - subscribeToMangaInitializer() - } + subscribeToMangaInitializer() // Create a new pager. pager = createPager(query, filters) + val sourceId = source.id + // Prepare the pager. pagerSubscription?.let { remove(it) } pagerSubscription = pager.results() - .subscribeReplay({ view, page -> - view.onAddPage(page.page, page.mangas) + .observeOn(Schedulers.io()) + .map { it.first to it.second.map { networkToLocalManga(it, sourceId) } } + .doOnNext { initializeMangas(it.second) } + .map { it.first to it.second.map(::CatalogueItem) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeReplay({ view, pair -> + view.onAddPage(pair.first, pair.second) }, { view, error -> Timber.e(error) }) @@ -161,7 +177,7 @@ open class CataloguePresenter : BasePresenter() { if (!hasNextPage()) return pageSubscription?.let { remove(it) } - pageSubscription = pager.requestNext { getPageTransformer(it) } + pageSubscription = Observable.defer { pager.requestNext() } .subscribeFirst({ view, page -> // Nothing to do when onNext is emitted. }, CatalogueFragment::onAddPageError) @@ -171,7 +187,7 @@ open class CataloguePresenter : BasePresenter() { * Returns true if the last fetched page has a next page. */ fun hasNextPage(): Boolean { - return pager.hasNextPage() + return pager.hasNextPage } /** @@ -179,11 +195,12 @@ open class CataloguePresenter : BasePresenter() { * * @param source the new active source. */ - fun setActiveSource(source: OnlineSource) { + fun setActiveSource(source: CatalogueSource) { prefs.lastUsedCatalogueSource().set(source.id) this.source = source + sourceFilters = source.getFilterList() - restartPager(query = "", filters = emptyList()) + restartPager(query = "", filters = FilterList()) } /** @@ -193,11 +210,7 @@ open class CataloguePresenter : BasePresenter() { */ private fun setDisplayMode(asList: Boolean) { isListMode = asList - if (asList) { - initializerSubscription?.let { remove(it) } - } else { - subscribeToMangaInitializer() - } + subscribeToMangaInitializer() } /** @@ -207,7 +220,7 @@ open class CataloguePresenter : BasePresenter() { initializerSubscription?.let { remove(it) } initializerSubscription = mangaDetailSubject.observeOn(Schedulers.io()) .flatMap { Observable.from(it) } - .filter { !it.initialized } + .filter { it.thumbnail_url == null && !it.initialized } .concatMap { getMangaDetailsObservable(it) } .onBackpressureBuffer() .observeOn(AndroidSchedulers.mainThread()) @@ -220,41 +233,21 @@ open class CataloguePresenter : BasePresenter() { .apply { add(this) } } - /** - * Returns the function to apply to the observable of the list of manga from the source. - * - * @param observable the observable from the source. - * @return the function to apply. - */ - fun getPageTransformer(observable: Observable): Observable { - return observable.subscribeOn(Schedulers.io()) - .doOnNext { it.mangas.replace { networkToLocalManga(it) } } - .doOnNext { initializeMangas(it.mangas) } - .observeOn(AndroidSchedulers.mainThread()) - } - - /** - * Replaces an object in the list with another. - */ - fun MutableList.replace(block: (T) -> T) { - forEachIndexed { i, obj -> - set(i, block(obj)) - } - } - /** * Returns a manga from the database for the given manga from network. It creates a new entry * if the manga is not yet in the database. * - * @param networkManga the manga from network. + * @param sManga the manga from the source. * @return a manga from the database. */ - private fun networkToLocalManga(networkManga: Manga): Manga { - var localManga = db.getManga(networkManga.url, source.id).executeAsBlocking() + private fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { + var localManga = db.getManga(sManga.url, sourceId).executeAsBlocking() if (localManga == null) { - val result = db.insertManga(networkManga).executeAsBlocking() - networkManga.id = result.insertedId() - localManga = networkManga + val newManga = Manga.create(sManga.url, sManga.title, sourceId) + newManga.copyFrom(sManga) + val result = db.insertManga(newManga).executeAsBlocking() + newManga.id = result.insertedId() + localManga = newManga } return localManga } @@ -278,6 +271,7 @@ open class CataloguePresenter : BasePresenter() { return source.fetchMangaDetails(manga) .flatMap { networkManga -> manga.copyFrom(networkManga) + manga.initialized = true db.insertManga(manga).executeAsBlocking() Observable.just(manga) } @@ -289,13 +283,13 @@ open class CataloguePresenter : BasePresenter() { * * @return a source. */ - fun getLastUsedSource(): OnlineSource { + fun getLastUsedSource(): CatalogueSource { val id = prefs.lastUsedCatalogueSource().get() ?: -1 val source = sourceManager.get(id) - if (!isValidSource(source)) { - return findFirstValidSource() + if (!isValidSource(source) || source !in sources) { + return sources.first { isValidSource(it) } } - return source as OnlineSource + return source as CatalogueSource } /** @@ -314,19 +308,10 @@ open class CataloguePresenter : BasePresenter() { return true } - /** - * Finds the first valid source. - * - * @return the index of the first valid source. - */ - fun findFirstValidSource(): OnlineSource { - return sources.first { isValidSource(it) } - } - /** * Returns a list of enabled sources ordered by language and name. */ - open protected fun getEnabledSources(): List { + open protected fun getEnabledSources(): List { val languages = prefs.enabledLanguages().getOrDefault() val hiddenCatalogues = prefs.hiddenCatalogues().getOrDefault() @@ -335,7 +320,7 @@ open class CataloguePresenter : BasePresenter() { languages.add("en") } - return sourceManager.getOnlineSources() + return sourceManager.getCatalogueSources() .filter { it.lang in languages } .filterNot { it.id.toString() in hiddenCatalogues } .sortedBy { "(${it.lang}) ${it.name}" } @@ -362,16 +347,53 @@ open class CataloguePresenter : BasePresenter() { } /** - * Set the active filters for the current source. + * Set the filter states for the current source. * - * @param selectedFilters a list of active filters. + * @param filters a list of active filters. */ - fun setSourceFilter(selectedFilters: List) { - restartPager(filters = selectedFilters) + fun setSourceFilter(filters: FilterList) { + restartPager(filters = filters) } - open fun createPager(query: String, filters: List): Pager { + open fun createPager(query: String, filters: FilterList): Pager { return CataloguePager(source, query, filters) } + private fun FilterList.toItems(): List> { + return mapNotNull { + when (it) { + is Filter.Header -> HeaderItem(it) + is Filter.Separator -> SeparatorItem(it) + is Filter.CheckBox -> CheckboxItem(it) + is Filter.TriState -> TriStateItem(it) + is Filter.Text -> TextItem(it) + is Filter.Select<*> -> SelectItem(it) + is Filter.Group<*> -> { + val group = GroupItem(it) + val subItems = it.state.mapNotNull { + when (it) { + is Filter.CheckBox -> CheckboxSectionItem(it) + is Filter.TriState -> TriStateSectionItem(it) + is Filter.Text -> TextSectionItem(it) + is Filter.Select<*> -> SelectSectionItem(it) + else -> null + } as? ISectionable<*, *> + } + subItems.forEach { it.header = group } + group.subItems = subItems + group + } + is Filter.Sort -> { + val group = SortGroup(it) + val subItems = it.values.mapNotNull { + SortItem(it, group) + } + group.subItems = subItems + group + } + else -> null + } + } + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/NoResultsException.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/NoResultsException.kt new file mode 100644 index 000000000..3ac0dbac8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/NoResultsException.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.ui.catalogue + +class NoResultsException : Exception() \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/Pager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/Pager.kt index 26cb466f6..a0f3d55e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/Pager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/Pager.kt @@ -1,25 +1,31 @@ package eu.kanade.tachiyomi.ui.catalogue -import eu.kanade.tachiyomi.data.source.model.MangasPage -import rx.subjects.PublishSubject +import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga import rx.Observable /** * A general pager for source requests (latest updates, popular, search) */ -abstract class Pager { +abstract class Pager(var currentPage: Int = 1) { - protected var lastPage: MangasPage? = null + var hasNextPage = true + private set - protected val results = PublishSubject.create() + protected val results: PublishRelay>> = PublishRelay.create() - fun results(): Observable { + fun results(): Observable>> { return results.asObservable() } - fun hasNextPage(): Boolean { - return lastPage == null || lastPage?.nextPageUrl != null + abstract fun requestNext(): Observable + + fun onPageReceived(mangasPage: MangasPage) { + val page = currentPage + currentPage++ + hasNextPage = mangasPage.hasNextPage && !mangasPage.mangas.isEmpty() + results.call(Pair(page, mangasPage.mangas)) } - abstract fun requestNext(transformer: (Observable) -> Observable): Observable } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/ProgressItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/ProgressItem.kt new file mode 100644 index 000000000..7d1d8cfbe --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/ProgressItem.kt @@ -0,0 +1,52 @@ +package eu.kanade.tachiyomi.ui.catalogue + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.TextView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R + + +class ProgressItem : AbstractFlexibleItem() { + + var loadMore = true + + override fun getLayoutRes(): Int { + return R.layout.progress_item + } + + override fun createViewHolder(adapter: FlexibleAdapter>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List) { + holder.progressBar.visibility = View.GONE + holder.progressMessage.visibility = View.GONE + + if (!adapter.isEndlessScrollEnabled) { + loadMore = false + } + + if (loadMore) { + holder.progressBar.visibility = View.VISIBLE + } else { + holder.progressMessage.visibility = View.VISIBLE + } + } + + override fun equals(other: Any?): Boolean { + return this === other + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { + + val progressBar = view.findViewById(R.id.progress_bar) as ProgressBar + val progressMessage = view.findViewById(R.id.progress_message) as TextView + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/CheckboxItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/CheckboxItem.kt new file mode 100644 index 000000000..d9bab855e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/CheckboxItem.kt @@ -0,0 +1,49 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.Filter + +open class CheckboxItem(val filter: Filter.CheckBox) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.navigation_view_checkbox + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + val view = holder.check + view.text = filter.name + view.isChecked = filter.state + holder.itemView.setOnClickListener { + view.toggle() + filter.state = view.isChecked + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is CheckboxItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { + + val check = itemView.findViewById(R.id.nav_view_item) as CheckBox + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/GroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/GroupItem.kt new file mode 100644 index 000000000..c023ce596 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/GroupItem.kt @@ -0,0 +1,56 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem +import eu.davidea.flexibleadapter.items.ISectionable +import eu.davidea.viewholders.ExpandableViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.util.setVectorCompat + +class GroupItem(val filter: Filter.Group<*>) : AbstractExpandableHeaderItem>() { + + override fun getLayoutRes(): Int { + return R.layout.navigation_view_group + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + holder.title.text = filter.name + + holder.icon.setVectorCompat(if (isExpanded) + R.drawable.ic_expand_more_white_24dp + else + R.drawable.ic_chevron_right_white_24dp) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is GroupItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + open class Holder(view: View, adapter: FlexibleAdapter<*>) : ExpandableViewHolder(view, adapter, true) { + + val title = itemView.findViewById(R.id.title) as TextView + val icon = itemView.findViewById(R.id.expand_icon) as ImageView + + override fun shouldNotifyParentOnClick(): Boolean { + return true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HeaderItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HeaderItem.kt new file mode 100644 index 000000000..a76612167 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HeaderItem.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.annotation.SuppressLint +import android.support.design.R +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractHeaderItem +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.source.model.Filter + +class HeaderItem(val filter: Filter.Header) : AbstractHeaderItem() { + + @SuppressLint("PrivateResource") + override fun getLayoutRes(): Int { + return R.layout.design_navigation_item_subheader + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + val view = holder.itemView as TextView + view.text = filter.name + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is HeaderItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SectionItems.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SectionItems.kt new file mode 100644 index 000000000..71302c8d4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SectionItems.kt @@ -0,0 +1,96 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import eu.davidea.flexibleadapter.items.ISectionable +import eu.kanade.tachiyomi.source.model.Filter + +class TriStateSectionItem(filter: Filter.TriState) : TriStateItem(filter), ISectionable { + + private var head: GroupItem? = null + + override fun getHeader(): GroupItem? = head + + override fun setHeader(header: GroupItem?) { + head = header + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is TriStateSectionItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} + +class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable { + + private var head: GroupItem? = null + + override fun getHeader(): GroupItem? = head + + override fun setHeader(header: GroupItem?) { + head = header + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is TextSectionItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} + +class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable { + + private var head: GroupItem? = null + + override fun getHeader(): GroupItem? = head + + override fun setHeader(header: GroupItem?) { + head = header + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is CheckboxSectionItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} + +class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable { + + private var head: GroupItem? = null + + override fun getHeader(): GroupItem? = head + + override fun setHeader(header: GroupItem?) { + head = header + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is SelectSectionItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SelectItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SelectItem.kt new file mode 100644 index 000000000..9b153cdc1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SelectItem.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Spinner +import android.widget.TextView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener + +open class SelectItem(val filter: Filter.Select<*>) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.navigation_view_spinner + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + holder.text.text = filter.name + ": " + + val spinner = holder.spinner + spinner.prompt = filter.name + spinner.adapter = ArrayAdapter(holder.itemView.context, + android.R.layout.simple_spinner_item, filter.values).apply { + setDropDownViewResource(R.layout.spinner_item) + } + spinner.onItemSelectedListener = IgnoreFirstSpinnerListener { position -> + filter.state = position + } + spinner.setSelection(filter.state) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is SelectItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { + + val text = itemView.findViewById(R.id.nav_view_item_text) as TextView + val spinner = itemView.findViewById(R.id.nav_view_item) as Spinner + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SeparatorItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SeparatorItem.kt new file mode 100644 index 000000000..8420f2f7d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SeparatorItem.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.annotation.SuppressLint +import android.support.design.R +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractHeaderItem +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.source.model.Filter + +class SeparatorItem(val filter: Filter.Separator) : AbstractHeaderItem() { + + @SuppressLint("PrivateResource") + override fun getLayoutRes(): Int { + return R.layout.design_navigation_item_separator + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is SeparatorItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortGroup.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortGroup.kt new file mode 100644 index 000000000..26c92aea4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortGroup.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem +import eu.davidea.flexibleadapter.items.ISectionable +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.util.setVectorCompat + +class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem>() { + + // Use an id instead of the layout res to allow to reuse the layout. + override fun getLayoutRes(): Int { + return R.id.catalogue_filter_sort_group + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(R.layout.navigation_view_group, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + holder.title.text = filter.name + + holder.icon.setVectorCompat(if (isExpanded) + R.drawable.ic_expand_more_white_24dp + else + R.drawable.ic_chevron_right_white_24dp) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is SortGroup) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt new file mode 100644 index 000000000..5646fbc26 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt @@ -0,0 +1,74 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.support.graphics.drawable.VectorDrawableCompat +import android.support.v4.content.ContextCompat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckedTextView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractSectionableItem +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.util.getResourceColor + +class SortItem(val name: String, val group: SortGroup) : AbstractSectionableItem(group) { + + // Use an id instead of the layout res to allow to reuse the layout. + override fun getLayoutRes(): Int { + return R.id.catalogue_filter_sort_item + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(R.layout.navigation_view_checkedtext, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + val view = holder.text + view.text = name + val filter = group.filter + + val i = filter.values.indexOf(name) + + fun getIcon() = when (filter.state) { + Filter.Sort.Selection(i, false) -> VectorDrawableCompat.create(view.resources, R.drawable.ic_keyboard_arrow_down_black_32dp, null) + ?.apply { setTint(view.context.getResourceColor(R.attr.colorAccent)) } + Filter.Sort.Selection(i, true) -> VectorDrawableCompat.create(view.resources, R.drawable.ic_keyboard_arrow_up_black_32dp, null) + ?.apply { setTint(view.context.getResourceColor(R.attr.colorAccent)) } + else -> ContextCompat.getDrawable(view.context, R.drawable.empty_drawable_32dp) + } + + view.setCompoundDrawablesWithIntrinsicBounds(getIcon(), null, null, null) + holder.itemView.setOnClickListener { + val pre = filter.state?.index ?: i + if (pre != i) { + filter.state = Filter.Sort.Selection(i, false) + } else { + filter.state = Filter.Sort.Selection(i, filter.state?.ascending == false) + } + + group.subItems.forEach { adapter.notifyItemChanged(adapter.getGlobalPositionOf(it)) } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is SortItem) { + return name == other.name && group == other.group + } + return false + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + group.hashCode() + return result + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { + + val text = itemView.findViewById(R.id.nav_view_item) as CheckedTextView + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TextItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TextItem.kt new file mode 100644 index 000000000..9d4321dcb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TextItem.kt @@ -0,0 +1,52 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.support.design.widget.TextInputLayout +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.widget.SimpleTextWatcher + +open class TextItem(val filter: Filter.Text) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.navigation_view_text + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + holder.wrapper.hint = filter.name + holder.edit.setText(filter.state) + holder.edit.addTextChangedListener(object : SimpleTextWatcher() { + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + filter.state = s.toString() + } + }) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is TextItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { + + val wrapper = itemView.findViewById(R.id.nav_view_item_wrapper) as TextInputLayout + val edit = itemView.findViewById(R.id.nav_view_item) as EditText + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TriStateItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TriStateItem.kt new file mode 100644 index 000000000..0c834b337 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TriStateItem.kt @@ -0,0 +1,75 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.support.design.R +import android.support.graphics.drawable.VectorDrawableCompat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckedTextView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.util.dpToPx +import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.R as TR + +open class TriStateItem(val filter: Filter.TriState) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return TR.layout.navigation_view_checkedtext + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup?): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + val view = holder.text + view.text = filter.name + + fun getIcon() = VectorDrawableCompat.create(view.resources, when (filter.state) { + Filter.TriState.STATE_IGNORE -> TR.drawable.ic_check_box_outline_blank_24dp + Filter.TriState.STATE_INCLUDE -> TR.drawable.ic_check_box_24dp + Filter.TriState.STATE_EXCLUDE -> TR.drawable.ic_check_box_x_24dp + else -> throw Exception("Unknown state") + }, null)?.apply { + val color = if (filter.state == Filter.TriState.STATE_INCLUDE) + R.attr.colorAccent + else + android.R.attr.textColorSecondary + + setTint(view.context.getResourceColor(color)) + } + + view.setCompoundDrawablesWithIntrinsicBounds(getIcon(), null, null, null) + holder.itemView.setOnClickListener { + filter.state = (filter.state + 1) % 3 + view.setCompoundDrawablesWithIntrinsicBounds(getIcon(), null, null, null) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is TriStateItem) { + return filter == other.filter + } + return false + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { + + val text = itemView.findViewById(TR.id.nav_view_item) as CheckedTextView + + init { + // Align with native checkbox + text.setPadding(4.dpToPx, 0, 0, 0) + text.compoundDrawablePadding = 20.dpToPx + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryActivity.kt index 72e6af3a7..a8cceabe5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryActivity.kt @@ -6,16 +6,15 @@ import android.os.Bundle import android.support.v7.view.ActionMode import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView -import android.support.v7.widget.helper.ItemTouchHelper import android.view.Menu import android.view.MenuItem +import android.view.View import com.afollestad.materialdialogs.MaterialDialog import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.helpers.UndoHelper import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity -import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder -import eu.kanade.tachiyomi.ui.base.adapter.OnStartDragListener import kotlinx.android.synthetic.main.activity_edit_categories.* import kotlinx.android.synthetic.main.toolbar.* import nucleus.factory.RequiresPresenter @@ -29,7 +28,10 @@ import nucleus.factory.RequiresPresenter @RequiresPresenter(CategoryPresenter::class) class CategoryActivity : BaseRxActivity(), - ActionMode.Callback, FlexibleViewHolder.OnListItemClickListener, OnStartDragListener { + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + UndoHelper.OnUndoListener { /** * Object used to show actionMode toolbar. @@ -41,11 +43,6 @@ class CategoryActivity : */ private lateinit var adapter: CategoryAdapter - /** - * TouchHelper used for reorder animation and movement. - */ - private lateinit var touchHelper: ItemTouchHelper - companion object { /** * Create new CategoryActivity intent. @@ -75,27 +72,17 @@ class CategoryActivity : recycler.setHasFixedSize(true) recycler.adapter = adapter - // Touch helper to drag and reorder categories - touchHelper = ItemTouchHelper(CategoryItemTouchHelper(adapter)) - touchHelper.attachToRecyclerView(recycler) + adapter.isHandleDragEnabled = true // Create OnClickListener for creating new category - fab.setOnClickListener({ v -> + fab.setOnClickListener { MaterialDialog.Builder(this) .title(R.string.action_add_category) .negativeText(android.R.string.cancel) .input(R.string.name, 0, false) { dialog, input -> presenter.createCategory(input.toString()) } .show() - }) - } - - /** - * Finishes action mode. - * Call this when action mode action is finished. - */ - fun destroyActionModeIfNeeded() { - actionMode?.finish() + } } /** @@ -103,19 +90,13 @@ class CategoryActivity : * * @param categories list containing categories */ - fun setCategories(categories: List) { - destroyActionModeIfNeeded() - adapter.setItems(categories) - } - - /** - * Returns the selected categories - * - * @return list of selected categories - */ - private fun getSelectedCategories(): List { - // Create a list of the selected categories - return adapter.selectedItems.map { adapter.getItem(it) } + fun setCategories(categories: List) { + actionMode?.finish() + adapter.updateDataSet(categories.toMutableList()) + val selected = categories.filter { it.isSelected } + if (selected.isNotEmpty()) { + selected.forEach { onItemLongClick(categories.indexOf(it)) } + } } /** @@ -127,51 +108,11 @@ class CategoryActivity : MaterialDialog.Builder(this) .title(R.string.action_rename_category) .negativeText(android.R.string.cancel) - .onNegative { materialDialog, dialogAction -> destroyActionModeIfNeeded() } .input(getString(R.string.name), category.name, false) { dialog, input -> presenter.renameCategory(category, input.toString()) } .show() } - /** - * Toggle actionMode selection - * - * @param position position of selected item - */ - private fun toggleSelection(position: Int) { - adapter.toggleSelection(position, false) - - // Get selected item count - val count = adapter.selectedItemCount - - // If no item is selected finish action mode - if (count == 0) { - actionMode?.finish() - } else { - // This block will only run if actionMode is not null - actionMode?.let { - - // Set title equal to selected item - it.title = getString(R.string.label_selected, count) - it.invalidate() - - // Show edit button only when one item is selected - val editItem = it.menu.findItem(R.id.action_edit) - editItem.isVisible = count == 1 - } - } - } - - /** - * Called each time the action mode is shown. - * Always called after onCreateActionMode - * - * @return false - */ - override fun onPrepareActionMode(actionMode: ActionMode, menu: Menu): Boolean { - return false - } - /** * Called when action mode item clicked. * @@ -183,12 +124,26 @@ class CategoryActivity : override fun onActionItemClicked(actionMode: ActionMode, menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.action_delete -> { - // Delete select categories. - presenter.deleteCategories(getSelectedCategories()) + UndoHelper(adapter, this) + .withAction(UndoHelper.ACTION_REMOVE, object : UndoHelper.OnActionListener { + override fun onPreAction(): Boolean { + adapter.selectedPositions.forEach { adapter.getItem(it).isSelected = false } + return false + } + + override fun onPostAction() { + actionMode.finish() + } + }) + .remove(adapter.selectedPositions, recycler.parent as View, + R.string.snack_categories_deleted, R.string.action_undo, 3000) } R.id.action_edit -> { // Edit selected category - editCategory(getSelectedCategories()[0]) + if (adapter.selectedItemCount == 1) { + val position = adapter.selectedPositions.first() + editCategory(adapter.getItem(position).category) + } } else -> return false } @@ -211,6 +166,22 @@ class CategoryActivity : return true } + /** + * Called each time the action mode is shown. + * Always called after onCreateActionMode + * + * @return false + */ + override fun onPrepareActionMode(actionMode: ActionMode, menu: Menu): Boolean { + val count = adapter.selectedItemCount + actionMode.title = getString(R.string.label_selected, count) + + // Show edit button only when one item is selected + val editItem = actionMode.menu.findItem(R.id.action_edit) + editItem.isVisible = count == 1 + return true + } + /** * Called when action mode destroyed. * @@ -218,8 +189,7 @@ class CategoryActivity : */ override fun onDestroyActionMode(mode: ActionMode?) { // Reset adapter to single selection - adapter.mode = FlexibleAdapter.MODE_SINGLE - // Clear selected items + adapter.mode = FlexibleAdapter.MODE_IDLE adapter.clearSelection() actionMode = null } @@ -229,11 +199,9 @@ class CategoryActivity : * * @param position position of clicked item. */ - override fun onListItemClick(position: Int): Boolean { + override fun onItemClick(position: Int): Boolean { // Check if action mode is initialized and selected item exist. - if (position == -1) { - return false - } else if (actionMode != null) { + if (actionMode != null && position != RecyclerView.NO_POSITION) { toggleSelection(position) return true } else { @@ -246,24 +214,52 @@ class CategoryActivity : * * @param position position of clicked item. */ - override fun onListItemLongClick(position: Int) { + override fun onItemLongClick(position: Int) { // Check if action mode is initialized. - if (actionMode == null) - // Initialize action mode + if (actionMode == null) { + // Initialize action mode actionMode = startSupportActionMode(this) + } // Set item as selected toggleSelection(position) } /** - * Called when item is dragged - * - * @param viewHolder view that contains dragged item + * Toggle the selection state of an item. + * If the item was the last one in the selection and is unselected, the ActionMode is finished. */ - override fun onStartDrag(viewHolder: RecyclerView.ViewHolder) { - // Notify touchHelper - touchHelper.startDrag(viewHolder) + private fun toggleSelection(position: Int) { + //Mark the position selected + adapter.toggleSelection(position) + + if (adapter.selectedItemCount == 0) { + actionMode?.finish() + } else { + actionMode?.invalidate() + } + } + + /** + * Called when an item is released from a drag. + */ + fun onItemReleased() { + val categories = (0..adapter.itemCount-1).map { adapter.getItem(it).category } + presenter.reorderCategories(categories) + } + + /** + * Called when the undo action is clicked in the snackbar. + */ + override fun onUndoConfirmed(action: Int) { + adapter.restoreDeletedItems() + } + + /** + * Called when the time to restore the items expires. + */ + override fun onDeleteConfirmed(action: Int) { + presenter.deleteCategories(adapter.deletedItems.map { it.category }) } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt index ddac48e63..5f3b89fee 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt @@ -1,12 +1,6 @@ package eu.kanade.tachiyomi.ui.category -import android.view.ViewGroup import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.ui.base.adapter.ItemTouchHelperAdapter -import eu.kanade.tachiyomi.util.inflate -import java.util.* /** * Adapter of CategoryHolder. @@ -17,85 +11,23 @@ import java.util.* * @constructor Creates a CategoryAdapter object */ class CategoryAdapter(private val activity: CategoryActivity) : - FlexibleAdapter(), ItemTouchHelperAdapter { - - init { - // Set unique id's - setHasStableIds(true) - } + FlexibleAdapter(null, activity, true) { /** - * Called when ViewHolder is created - * - * @param parent parent View - * @param viewType int containing viewType + * Called when item is released. */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryHolder { - // Inflate layout with item_edit_categories.xml - val view = parent.inflate(R.layout.item_edit_categories) - return CategoryHolder(view, this, activity, activity) + fun onItemReleased() { + activity.onItemReleased() } - /** - * Called when ViewHolder is bind - * - * @param holder bind holder - * @param position position of holder - */ - override fun onBindViewHolder(holder: CategoryHolder, position: Int) { - // Update holder values. - val category = getItem(position) - holder.onSetValues(category) - - //When user scrolls this bind the correct selection status - holder.itemView.isActivated = isSelected(position) + override fun clearSelection() { + super.clearSelection() + (0..itemCount-1).forEach { getItem(it).isSelected = false } } - /** - * Update items with list of categories - * - * @param items list of categories - */ - fun setItems(items: List) { - mItems = ArrayList(items) - notifyDataSetChanged() + override fun toggleSelection(position: Int) { + super.toggleSelection(position) + getItem(position).isSelected = isSelected(position) } - /** - * Get category by position - * - * @param position position of item - */ - override fun getItemId(position: Int): Long { - return mItems[position].id!!.toLong() - } - - /** - * Called when item is moved - * - * @param fromPosition previous position of item. - * @param toPosition new position of item. - */ - override fun onItemMove(fromPosition: Int, toPosition: Int) { - // Move items and notify touch helper - Collections.swap(mItems, fromPosition, toPosition) - notifyItemMoved(fromPosition, toPosition) - - // Update database - activity.presenter.reorderCategories(mItems) - } - - /** - * Must be implemented, not used - */ - override fun onItemDismiss(position: Int) { - // Empty method. - } - - /** - * Must be implemented, not used - */ - override fun updateDataSet(p0: String?) { - // Empty method. - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt index b9cec3445..35f58a7b5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt @@ -2,14 +2,11 @@ package eu.kanade.tachiyomi.ui.category import android.graphics.Color import android.graphics.Typeface -import android.support.v4.view.MotionEventCompat -import android.view.MotionEvent import android.view.View import com.amulyakhare.textdrawable.TextDrawable import com.amulyakhare.textdrawable.util.ColorGenerator +import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder -import eu.kanade.tachiyomi.ui.base.adapter.OnStartDragListener import kotlinx.android.synthetic.main.item_edit_categories.view.* /** @@ -19,17 +16,10 @@ import kotlinx.android.synthetic.main.item_edit_categories.view.* * * @param view view of category item. * @param adapter adapter belonging to holder. - * @param listener called when item clicked. - * @param dragListener called when item dragged. * * @constructor Create CategoryHolder object */ -class CategoryHolder( - view: View, - adapter: CategoryAdapter, - listener: FlexibleViewHolder.OnListItemClickListener, - dragListener: OnStartDragListener -) : FlexibleViewHolder(view, adapter, listener) { +class CategoryHolder(view: View, val adapter: CategoryAdapter) : FlexibleViewHolder(view, adapter) { init { // Create round letter image onclick to simulate long click @@ -38,13 +28,7 @@ class CategoryHolder( onLongClick(view) } - // Set on touch listener for reorder image - itemView.reorder.setOnTouchListener { v, event -> - if (MotionEventCompat.getActionMasked(event) == MotionEvent.ACTION_DOWN) { - dragListener.onStartDrag(this) - } - false - } + setDragHandleView(itemView.reorder) } /** @@ -52,7 +36,7 @@ class CategoryHolder( * * @param category category of item. */ - fun onSetValues(category: Category) { + fun bind(category: Category) { // Set capitalized title. itemView.title.text = category.name.capitalize() @@ -78,4 +62,10 @@ class CategoryHolder( .endConfig() .buildRound(text, ColorGenerator.MATERIAL.getColor(text)) } + + override fun onItemReleased(position: Int) { + super.onItemReleased(position) + adapter.onItemReleased() + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt new file mode 100644 index 000000000..3dd3ad5a7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.ui.category + +import android.view.LayoutInflater +import android.view.ViewGroup +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.util.inflate + +class CategoryItem(val category: Category) : AbstractFlexibleItem() { + + var isSelected = false + + override fun getLayoutRes(): Int { + return R.layout.item_edit_categories + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, + parent: ViewGroup): CategoryHolder { + return CategoryHolder(parent.inflate(layoutRes), adapter as CategoryAdapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: CategoryHolder, + position: Int, payloads: List?) { + holder.bind(category) + } + + override fun isDraggable(): Boolean { + return true + } + + override fun equals(other: Any?): Boolean { + if (other is CategoryItem) { + return category.id == other.category.id + } + return false + } + + override fun hashCode(): Int { + return category.id!! + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItemTouchHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItemTouchHelper.kt deleted file mode 100644 index 1e1ad8df3..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItemTouchHelper.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eu.kanade.tachiyomi.ui.category - -import eu.kanade.tachiyomi.ui.base.adapter.ItemTouchHelperAdapter -import eu.kanade.tachiyomi.ui.base.adapter.SimpleItemTouchHelperCallback - -class CategoryItemTouchHelper(adapter: ItemTouchHelperAdapter) : SimpleItemTouchHelperCallback(adapter) { - - /** - * Disable items swipe remove - * - * @return false - */ - override fun isItemViewSwipeEnabled(): Boolean { - return false - } - - /** - * Disable long press item drag - * - * @return false - */ - override fun isLongPressDragEnabled(): Boolean { - return false - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt index 9f129a150..b8691aa5d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt @@ -31,6 +31,7 @@ class CategoryPresenter : BasePresenter() { db.getCategories().asRxObservable() .doOnNext { categories = it } + .map { it.map(::CategoryItem) } .observeOn(AndroidSchedulers.mainThread()) .subscribeLatestCache(CategoryActivity::setCategories) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadActivity.kt similarity index 80% rename from app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadFragment.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadActivity.kt index 39e4226f9..9a8d010fe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadActivity.kt @@ -2,15 +2,17 @@ package eu.kanade.tachiyomi.ui.download import android.os.Bundle import android.support.v7.widget.LinearLayoutManager -import android.view.* +import android.view.Menu +import android.view.MenuItem import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment -import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity import eu.kanade.tachiyomi.util.plusAssign +import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.fragment_download_queue.* +import kotlinx.android.synthetic.main.toolbar.* import nucleus.factory.RequiresPresenter import rx.Observable import rx.Subscription @@ -20,19 +22,18 @@ import java.util.* import java.util.concurrent.TimeUnit /** - * Fragment that shows the currently active downloads. + * Activity that shows the currently active downloads. * Uses R.layout.fragment_download_queue. */ @RequiresPresenter(DownloadPresenter::class) -class DownloadFragment : BaseRxFragment() { - +class DownloadActivity : BaseRxActivity() { /** * Adapter containing the active downloads. */ private lateinit var adapter: DownloadAdapter /** - * Subscription list to be cleared during [onDestroyView]. + * Subscription list to be cleared during [onDestroy]. */ private val subscriptions by lazy { CompositeSubscription() } @@ -46,38 +47,22 @@ class DownloadFragment : BaseRxFragment() { */ private var isRunning: Boolean = false - companion object { - /** - * Creates a new instance of this fragment. - * - * @return a new instance of [DownloadFragment]. - */ - fun newInstance(): DownloadFragment { - return DownloadFragment() - } - } - override fun onCreate(savedState: Bundle?) { + setAppTheme() super.onCreate(savedState) - setHasOptionsMenu(true) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View { - return inflater.inflate(R.layout.fragment_download_queue, container, false) - } - - override fun onViewCreated(view: View, savedState: Bundle?) { + setContentView(R.layout.activity_download_manager) + setupToolbar(toolbar) setToolbarTitle(R.string.label_download_queue) // Check if download queue is empty and update information accordingly. setInformationView() // Initialize adapter. - adapter = DownloadAdapter(activity) + adapter = DownloadAdapter(this) recycler.adapter = adapter // Set the layout manager for the recycler and fixed size. - recycler.layoutManager = LinearLayoutManager(activity) + recycler.layoutManager = LinearLayoutManager(this) recycler.setHasFixedSize(true) // Suscribe to changes @@ -94,20 +79,21 @@ class DownloadFragment : BaseRxFragment() { .subscribe { onUpdateDownloadedPages(it) } } - override fun onDestroyView() { + override fun onDestroy() { for (subscription in progressSubscriptions.values) { subscription.unsubscribe() } progressSubscriptions.clear() subscriptions.clear() - super.onDestroyView() + super.onDestroy() } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.download_queue, menu) + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.download_queue, menu) + return true } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun onPrepareOptionsMenu(menu: Menu): Boolean { // Set start button visibility. menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty() @@ -116,14 +102,18 @@ class DownloadFragment : BaseRxFragment() { // Set clear button visibility. menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty() + return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.start_queue -> DownloadService.start(activity) - R.id.pause_queue -> DownloadService.stop(activity) + R.id.start_queue -> DownloadService.start(this) + R.id.pause_queue -> { + DownloadService.stop(this) + presenter.pauseDownloads() + } R.id.clear_queue -> { - DownloadService.stop(activity) + DownloadService.stop(this) presenter.clearQueue() } else -> return super.onOptionsItemSelected(item) @@ -198,7 +188,7 @@ class DownloadFragment : BaseRxFragment() { */ private fun onQueueStatusChange(running: Boolean) { isRunning = running - activity.supportInvalidateOptionsMenu() + supportInvalidateOptionsMenu() // Check if download queue is empty and update information accordingly. setInformationView() @@ -210,7 +200,7 @@ class DownloadFragment : BaseRxFragment() { * @param downloads the downloads from the queue. */ fun onNextDownloads(downloads: List) { - activity.supportInvalidateOptionsMenu() + supportInvalidateOptionsMenu() setInformationView() adapter.setItems(downloads) } @@ -247,8 +237,11 @@ class DownloadFragment : BaseRxFragment() { * Set information view when queue is empty */ private fun setInformationView() { - (activity as MainActivity).updateEmptyView(presenter.downloadQueue.isEmpty(), + updateEmptyView(presenter.downloadQueue.isEmpty(), R.string.information_no_downloads, R.drawable.ic_file_download_black_128dp) } + fun updateEmptyView(show: Boolean, textResource: Int, drawable: Int) { + if (show) empty_view.show(drawable, textResource) else empty_view.hide() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt index 6b2412b51..5f5e7d361 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.ui.download import android.content.Context import android.view.ViewGroup -import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter4.FlexibleAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.util.inflate diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt index 6e71eb585..2664a70e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt @@ -12,9 +12,9 @@ import uy.kohesive.injekt.injectLazy import java.util.* /** - * Presenter of [DownloadFragment]. + * Presenter of [DownloadActivity]. */ -class DownloadPresenter : BasePresenter() { +class DownloadPresenter : BasePresenter() { /** * Download manager. @@ -33,7 +33,7 @@ class DownloadPresenter : BasePresenter() { downloadQueue.getUpdatedObservable() .observeOn(AndroidSchedulers.mainThread()) .map { ArrayList(it) } - .subscribeLatestCache(DownloadFragment::onNextDownloads, { view, error -> + .subscribeLatestCache(DownloadActivity::onNextDownloads, { view, error -> Timber.e(error) }) } @@ -48,6 +48,13 @@ class DownloadPresenter : BasePresenter() { .onBackpressureBuffer() } + /** + * Pauses the download queue. + */ + fun pauseDownloads() { + downloadManager.pauseDownloads() + } + /** * Clears the download queue. */ @@ -55,4 +62,4 @@ class DownloadPresenter : BasePresenter() { downloadManager.clearQueue() } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesFragment.kt index cff2ea3db..b567e79d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesFragment.kt @@ -1,9 +1,9 @@ package eu.kanade.tachiyomi.ui.latest_updates +import android.view.Menu +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment import nucleus.factory.RequiresPresenter -import android.view.* -import eu.kanade.tachiyomi.R /** * Fragment that shows the manga from the catalogue. Inherit CatalogueFragment. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesPager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesPager.kt index 6391f9d7c..7cdcdff66 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesPager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesPager.kt @@ -1,28 +1,22 @@ package eu.kanade.tachiyomi.ui.latest_updates -import eu.kanade.tachiyomi.data.source.model.MangasPage -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.ui.catalogue.Pager import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers /** * LatestUpdatesPager inherited from the general Pager. */ -class LatestUpdatesPager(val source: OnlineSource): Pager() { +class LatestUpdatesPager(val source: CatalogueSource): Pager() { - override fun requestNext(transformer: (Observable) -> Observable): Observable { - val lastPage = lastPage - - val page = if (lastPage == null) - MangasPage(1) - else - MangasPage(lastPage.page + 1).apply { url = lastPage.nextPageUrl!! } - - val observable = source.fetchLatestUpdates(page) - - return transformer(observable) - .doOnNext { results.onNext(it) } - .doOnNext { this@LatestUpdatesPager.lastPage = it } + override fun requestNext(): Observable { + return source.fetchLatestUpdates(currentPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { onPageReceived(it) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesPresenter.kt index 8df1196e6..bcb27c418 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/latest_updates/LatestUpdatesPresenter.kt @@ -1,26 +1,26 @@ package eu.kanade.tachiyomi.ui.latest_updates -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.ui.catalogue.CataloguePresenter import eu.kanade.tachiyomi.ui.catalogue.Pager -import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter /** * Presenter of [LatestUpdatesFragment]. Inherit CataloguePresenter. */ class LatestUpdatesPresenter : CataloguePresenter() { - override fun createPager(query: String, filters: List): Pager { + override fun createPager(query: String, filters: FilterList): Pager { return LatestUpdatesPager(source) } - override fun getEnabledSources(): List { + override fun getEnabledSources(): List { return super.getEnabledSources().filter { it.supportsLatest } } override fun isValidSource(source: Source?): Boolean { - return super.isValidSource(source) && (source as OnlineSource).supportsLatest + return super.isValidSource(source) && (source as CatalogueSource).supportsLatest } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt index 94617d3d5..ef56d7029 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt @@ -6,7 +6,7 @@ import android.view.Gravity import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout -import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter4.FlexibleAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.source.SourceManager @@ -136,7 +136,7 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryView) : } return LibraryGridHolder(view, this, fragment) } else { - val view = parent.inflate(R.layout.item_library_list) + val view = parent.inflate(R.layout.item_catalogue_list) return LibraryListHolder(view, this, fragment) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt index ae7e7d62c..092c675ef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt @@ -5,7 +5,7 @@ import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView import android.util.AttributeSet import android.widget.FrameLayout -import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter4.FlexibleAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Manga diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt index 9a0787cd0..c0663516b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt @@ -25,6 +25,7 @@ import eu.kanade.tachiyomi.ui.category.CategoryActivity import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.util.toast +import eu.kanade.tachiyomi.widget.DialogCheckboxView import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.fragment_library.* import nucleus.factory.RequiresPresenter @@ -480,12 +481,19 @@ class LibraryFragment : BaseRxFragment(), ActionMode.Callback } private fun showDeleteMangaDialog() { + val view = DialogCheckboxView(context).apply { + setDescription(R.string.confirm_delete_manga) + setOptionDescription(R.string.also_delete_chapters) + } + MaterialDialog.Builder(activity) - .content(R.string.confirm_delete_manga) + .title(R.string.action_remove) + .customView(view, true) .positiveText(android.R.string.yes) .negativeText(android.R.string.no) .onPositive { dialog, action -> - presenter.removeMangaFromLibrary() + val deleteChapters = view.isChecked() + presenter.removeMangaFromLibrary(deleteChapters) destroyActionModeIfNeeded() } .show() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt index d0928262b..22dd444c9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt @@ -5,7 +5,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder -import kotlinx.android.synthetic.main.item_library_list.view.* +import kotlinx.android.synthetic.main.item_catalogue_list.view.* /** * Class used to hold the displayed data of a manga in the library, like the cover or the title. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt index 449550a5b..8c3cb176c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt @@ -39,6 +39,7 @@ class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: A init { recycler.adapter = adapter + addView(recycler) groups.forEach { it.initModels() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index acdffc33f..b1990ef80 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -13,7 +13,9 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.source.SourceManager +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.util.combineLatest import eu.kanade.tachiyomi.util.isNullOrUnsubscribed @@ -125,7 +127,7 @@ class LibraryPresenter : BasePresenter() { */ private fun applyFilters(map: Map>): Map> { // Cached list of downloaded manga directories given a source id. - val mangaDirectories = mutableMapOf>() + val mangaDirsForSource = mutableMapOf>() // Cached list of downloaded chapter directories for a manga. val chapterDirectories = mutableMapOf() @@ -145,15 +147,17 @@ class LibraryPresenter : BasePresenter() { // Filter when the download directory doesn't exist or is null. if (filterDownloaded) { - val mangaDirs = mangaDirectories.getOrPut(source.id) { - downloadManager.findSourceDir(source)?.listFiles() ?: emptyArray() + // Get the directories for the source of the manga. + val dirsForSource = mangaDirsForSource.getOrPut(source.id) { + val sourceDir = downloadManager.findSourceDir(source) + sourceDir?.listFiles()?.associateBy { it.name }.orEmpty() } val mangaDirName = downloadManager.getMangaDirName(manga) - val mangaDir = mangaDirs.find { it.name == mangaDirName } ?: return@f false + val mangaDir = dirsForSource[mangaDirName] ?: return@f false val hasDirs = chapterDirectories.getOrPut(manga.id!!) { - (mangaDir.listFiles() ?: emptyArray()).isNotEmpty() + mangaDir.listFiles()?.isNotEmpty() ?: false } if (!hasDirs) { return@f false @@ -293,25 +297,40 @@ class LibraryPresenter : BasePresenter() { * * @param mangas the list of manga. */ - fun getCommonCategories(mangas: List): Collection = mangas.toSet() - .map { db.getCategoriesForManga(it).executeAsBlocking() } - .reduce { set1: Iterable, set2 -> set1.intersect(set2) } + fun getCommonCategories(mangas: List): Collection { + if (mangas.isEmpty()) return emptyList() + return mangas.toSet() + .map { db.getCategoriesForManga(it).executeAsBlocking() } + .reduce { set1: Iterable, set2 -> set1.intersect(set2) } + } /** * Remove the selected manga from the library. + * + * @param deleteChapters whether to also delete downloaded chapters. */ - fun removeMangaFromLibrary() { + fun removeMangaFromLibrary(deleteChapters: Boolean) { // Create a set of the list - val mangaToDelete = selectedMangas.toSet() + val mangaToDelete = selectedMangas.distinctBy { it.id } + mangaToDelete.forEach { it.favorite = false } - Observable.from(mangaToDelete) + Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() } + .onErrorResumeNext { Observable.empty() } .subscribeOn(Schedulers.io()) - .doOnNext { - it.favorite = false - coverCache.deleteFromCache(it.thumbnail_url) + .subscribe() + + Observable.fromCallable { + mangaToDelete.forEach { manga -> + coverCache.deleteFromCache(manga.thumbnail_url) + if (deleteChapters) { + val source = sourceManager.get(manga.source) as? HttpSource + if (source != null) { + downloadManager.findMangaDir(source, manga)?.delete() + } } - .toList() - .flatMap { db.insertMangas(it).asRxObservable() } + } + } + .subscribeOn(Schedulers.io()) .subscribe() } @@ -342,6 +361,11 @@ class LibraryPresenter : BasePresenter() { */ @Throws(IOException::class) fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean { + if (manga.source == LocalSource.ID) { + LocalSource.updateCover(context, manga, inputStream) + return true + } + if (manga.thumbnail_url != null && manga.favorite) { coverCache.copyToCache(manga.thumbnail_url!!, inputStream) return true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt index e6de71544..1c6c35861 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt @@ -38,6 +38,18 @@ class ChangelogDialogFragment : DialogFragment() { // Delete internal chapter cache dir. File(context.cacheDir, "chapter_disk_cache").deleteRecursively() } + if (oldVersion < 19) { + // Move covers to external files dir. + val oldDir = File(context.externalCacheDir, "cover_disk_cache") + if (oldDir.exists()) { + val destDir = context.getExternalFilesDir("covers") + if (destDir != null) { + oldDir.listFiles().forEach { + it.renameTo(File(destDir, it.name)) + } + } + } + } //TODO Review any other changes below } } 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 ee40ba579..ed504b977 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 @@ -12,7 +12,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.backup.BackupFragment import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment -import eu.kanade.tachiyomi.ui.download.DownloadFragment +import eu.kanade.tachiyomi.ui.download.DownloadActivity import eu.kanade.tachiyomi.ui.latest_updates.LatestUpdatesFragment import eu.kanade.tachiyomi.ui.library.LibraryFragment import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersFragment @@ -66,19 +66,23 @@ class MainActivity : BaseActivity() { empty_view.hide() val id = item.itemId - when (id) { - R.id.nav_drawer_library -> setFragment(LibraryFragment.newInstance(), id) - R.id.nav_drawer_recent_updates -> setFragment(RecentChaptersFragment.newInstance(), id) - R.id.nav_drawer_recently_read -> setFragment(RecentlyReadFragment.newInstance(), id) - R.id.nav_drawer_catalogues -> setFragment(CatalogueFragment.newInstance(), id) - R.id.nav_drawer_latest_updates -> setFragment(LatestUpdatesFragment.newInstance(), id) - R.id.nav_drawer_batch_add -> setFragment(BatchAddFragment.newInstance(), id) - R.id.nav_drawer_downloads -> setFragment(DownloadFragment.newInstance(), id) - R.id.nav_drawer_settings -> { - val intent = Intent(this, SettingsActivity::class.java) - startActivityForResult(intent, REQUEST_OPEN_SETTINGS) + + val oldFragment = supportFragmentManager.findFragmentById(R.id.frame_container) + if (oldFragment == null || oldFragment.tag.toInt() != id) { + when (id) { + R.id.nav_drawer_library -> setFragment(LibraryFragment.newInstance(), id) + R.id.nav_drawer_recent_updates -> setFragment(RecentChaptersFragment.newInstance(), id) + R.id.nav_drawer_recently_read -> setFragment(RecentlyReadFragment.newInstance(), id) + R.id.nav_drawer_catalogues -> setFragment(CatalogueFragment.newInstance(), id) + R.id.nav_drawer_latest_updates -> setFragment(LatestUpdatesFragment.newInstance(), id) + R.id.nav_drawer_batch_add -> setFragment(BatchAddFragment.newInstance(), id) + R.id.nav_drawer_downloads -> startActivity(Intent(this, DownloadActivity::class.java)) + R.id.nav_drawer_settings -> { + val intent = Intent(this, SettingsActivity::class.java) + startActivityForResult(intent, REQUEST_OPEN_SETTINGS) + } + R.id.nav_drawer_backup -> setFragment(BackupFragment.newInstance(), id) } - R.id.nav_drawer_backup -> setFragment(BackupFragment.newInstance(), id) } drawer.closeDrawer(GravityCompat.START) true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt index 022ee880b..bf9edbc0a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt @@ -6,7 +6,9 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.manga.info.ChapterCountEvent +import eu.kanade.tachiyomi.ui.manga.info.MangaFavoriteEvent import eu.kanade.tachiyomi.util.SharedData +import eu.kanade.tachiyomi.util.isNullOrUnsubscribed import rx.Observable import rx.Subscription import uy.kohesive.injekt.injectLazy @@ -38,13 +40,15 @@ class MangaPresenter : BasePresenter() { // Prepare a subject to communicate the chapters and info presenters for the chapter count. SharedData.put(ChapterCountEvent()) + // Prepare a subject to communicate the chapters and info presenters for the chapter favorite. + SharedData.put(MangaFavoriteEvent()) } fun setMangaEvent(event: MangaEvent) { - if (isUnsubscribed(mangaSubscription)) { + if (mangaSubscription.isNullOrUnsubscribed()) { manga = event.manga mangaSubscription = Observable.just(manga) - .subscribeLatestCache({ view, manga -> view.onSetManga(manga) }) + .subscribeLatestCache(MangaActivity::onSetManga) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt index b53bc2d24..55b1f3f9c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.ui.manga.chapter import android.view.ViewGroup -import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter4.FlexibleAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.util.inflate diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersFragment.kt index 04cb5bac0..7997bfb9c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersFragment.kt @@ -4,13 +4,14 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.content.Intent import android.os.Bundle +import android.support.design.widget.Snackbar import android.support.v4.app.DialogFragment import android.support.v7.view.ActionMode import android.support.v7.widget.DividerItemDecoration import android.support.v7.widget.LinearLayoutManager import android.view.* import com.afollestad.materialdialogs.MaterialDialog -import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter4.FlexibleAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga @@ -20,6 +21,7 @@ import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment import eu.kanade.tachiyomi.ui.manga.MangaActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.getCoordinates +import eu.kanade.tachiyomi.util.snack import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.widget.DeletingChaptersDialog import kotlinx.android.synthetic.main.fragment_manga_chapters.* @@ -118,7 +120,7 @@ class ChaptersFragment : BaseRxFragment(), ActionMode.Callbac override fun onPrepareOptionsMenu(menu: Menu) { // Initialize menu items. - val menuFilterRead = menu.findItem(R.id.action_filter_read) + val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return val menuFilterUnread = menu.findItem(R.id.action_filter_unread) val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded) val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked) @@ -370,6 +372,13 @@ class ChaptersFragment : BaseRxFragment(), ActionMode.Callbac fun downloadChapters(chapters: List) { destroyActionModeIfNeeded() presenter.downloadChapters(chapters) + if (!presenter.manga.favorite){ + recycler.snack(getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) { + setAction(R.string.action_add) { + presenter.addToLibrary() + } + } + } } fun bookmarkChapters(chapters: List, bookmarked: Boolean) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersHolder.kt index e34a6c407..a5f7e09c4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersHolder.kt @@ -19,9 +19,9 @@ class ChaptersHolder( listener: FlexibleViewHolder.OnListItemClickListener) : FlexibleViewHolder(view, adapter, listener) { - private val readColor = view.context.theme.getResourceColor(android.R.attr.textColorHint) - private val unreadColor = view.context.theme.getResourceColor(android.R.attr.textColorPrimary) - private val bookmarkedColor = view.context.theme.getResourceColor(R.attr.colorAccent) + private val readColor = view.context.getResourceColor(android.R.attr.textColorHint) + private val unreadColor = view.context.getResourceColor(android.R.attr.textColorPrimary) + private val bookmarkedColor = view.context.getResourceColor(R.attr.colorAccent) private val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' }) private val df = DateFormat.getDateInstance(DateFormat.SHORT) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt index 3d8be2801..79c328fe9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.manga.chapter import android.os.Bundle +import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga @@ -8,17 +9,19 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.SourceManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.manga.MangaEvent import eu.kanade.tachiyomi.ui.manga.info.ChapterCountEvent +import eu.kanade.tachiyomi.ui.manga.info.MangaFavoriteEvent import eu.kanade.tachiyomi.util.SharedData +import eu.kanade.tachiyomi.util.isNullOrUnsubscribed import eu.kanade.tachiyomi.util.syncChaptersWithSource import rx.Observable +import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers -import rx.subjects.PublishSubject import timber.log.Timber import uy.kohesive.injekt.injectLazy @@ -68,7 +71,8 @@ class ChaptersPresenter : BasePresenter() { /** * Subject of list of chapters to allow updating the view without going to DB. */ - val chaptersSubject by lazy { PublishSubject.create>() } + val chaptersRelay: PublishRelay> + by lazy { PublishRelay.create>() } /** * Whether the chapter list has been requested to the source. @@ -76,56 +80,33 @@ class ChaptersPresenter : BasePresenter() { var hasRequested = false private set - companion object { - /** - * Id of the restartable which sends a filtered and ordered list of chapters to the view. - */ - private const val GET_CHAPTERS = 1 + /** + * Subscription to retrieve the new list of chapters from the source. + */ + private var fetchChaptersSubscription: Subscription? = null - /** - * Id of the restartable which requests an updated list of chapters to the source. - */ - private const val FETCH_CHAPTERS = 2 - - /** - * Id of the restartable which listens for download status changes. - */ - private const val CHAPTER_STATUS_CHANGES = 3 - } + /** + * Subscription to observe download status changes. + */ + private var observeDownloadsSubscription: Subscription? = null override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - startableLatestCache(GET_CHAPTERS, - // On each subject emission, apply filters and sort then update the view. - { chaptersSubject - .flatMap { applyChapterFilters(it) } - .observeOn(AndroidSchedulers.mainThread()) }, - { view, chapters -> view.onNextChapters(chapters) }) - - startableFirst(FETCH_CHAPTERS, - { getRemoteChaptersObservable() }, - { view, result -> view.onFetchChaptersDone() }, - { view, error -> view.onFetchChaptersError(error) }) - - startableLatestCache(CHAPTER_STATUS_CHANGES, - { getChapterStatusObservable() }, - { view, download -> view.onChapterStatusChange(download) }, - { view, error -> Timber.e(error) }) - // Find the active manga from the shared data or return. manga = SharedData.get(MangaEvent::class.java)?.manga ?: return - Observable.just(manga) - .subscribeLatestCache({ view, manga -> view.onNextManga(manga) }) - - // Find the source for this manga. source = sourceManager.get(manga.source)!! + Observable.just(manga) + .subscribeLatestCache(ChaptersFragment::onNextManga) - // Prepare the publish subject. - start(GET_CHAPTERS) + // Prepare the relay. + chaptersRelay.flatMap { applyChapterFilters(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(ChaptersFragment::onNextChapters, + { view, error -> Timber.e(error) }) // Add the subscription that retrieves the chapters from the database, keeps subscribed to - // changes, and sends the list of chapters to the publish subject. + // changes, and sends the list of chapters to the relay. add(db.getChapters(manga).asRxObservable() .map { chapters -> // Convert every chapter to a model. @@ -139,12 +120,22 @@ class ChaptersPresenter : BasePresenter() { this.chapters = chapters // Listen for download status changes - start(CHAPTER_STATUS_CHANGES) + observeDownloads() // Emit the number of chapters to the info tab. SharedData.get(ChapterCountEvent::class.java)?.emit(chapters.size) } - .subscribe { chaptersSubject.onNext(it) }) + .subscribe { chaptersRelay.call(it) }) + } + + private fun observeDownloads() { + observeDownloadsSubscription?.let { remove(it) } + observeDownloadsSubscription = downloadManager.queue.getStatusObservable() + .observeOn(AndroidSchedulers.mainThread()) + .filter { download -> download.manga.id == manga.id } + .doOnNext { onDownloadStatusChange(it) } + .subscribeLatestCache(ChaptersFragment::onChapterStatusChange, + { view, error -> Timber.e(error) }) } /** @@ -184,32 +175,24 @@ class ChaptersPresenter : BasePresenter() { */ fun fetchChaptersFromSource() { hasRequested = true - start(FETCH_CHAPTERS) + + if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return + fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } + .subscribeOn(Schedulers.io()) + .map { syncChaptersWithSource(db, it, manga, source) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst({ view, chapters -> + view.onFetchChaptersDone() + }, ChaptersFragment::onFetchChaptersError) } /** * Updates the UI after applying the filters. */ private fun refreshChapters() { - chaptersSubject.onNext(chapters) + chaptersRelay.call(chapters) } - /** - * Returns an observable that updates the chapter list with the latest from the source. - */ - fun getRemoteChaptersObservable() = source.fetchChapterList(manga) - .subscribeOn(Schedulers.io()) - .map { syncChaptersWithSource(db, it, manga, source) } - .observeOn(AndroidSchedulers.mainThread()) - - /** - * Returns an observable that listens to download queue status changes. - */ - fun getChapterStatusObservable() = downloadManager.queue.getStatusObservable() - .observeOn(AndroidSchedulers.mainThread()) - .filter { download -> download.manga.id == manga.id } - .doOnNext { onDownloadStatusChange(it) } - /** * Applies the view filters to the list of chapters obtained from the database. * @param chapters the list of chapters from the database @@ -220,7 +203,7 @@ class ChaptersPresenter : BasePresenter() { if (onlyUnread()) { observable = observable.filter { !it.read } } - if (onlyRead()) { + else if (onlyRead()) { observable = observable.filter { it.read } } if (onlyDownloaded()) { @@ -231,11 +214,11 @@ class ChaptersPresenter : BasePresenter() { } val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { Manga.SORTING_SOURCE -> when (sortDescending()) { - true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } + true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } } Manga.SORTING_NUMBER -> when (sortDescending()) { - true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) } + true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) } false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } } else -> throw NotImplementedError("Unimplemented sorting method") @@ -325,9 +308,7 @@ class ChaptersPresenter : BasePresenter() { .observeOn(AndroidSchedulers.mainThread()) .subscribeFirst({ view, result -> view.onChaptersDeleted() - }, { view, error -> - view.onChaptersDeletedError(error) - }) + }, ChaptersFragment::onChaptersDeletedError) } /** @@ -401,6 +382,13 @@ class ChaptersPresenter : BasePresenter() { refreshChapters() } + /** + * Adds manga to library + */ + fun addToLibrary() { + SharedData.get(MangaFavoriteEvent::class.java)?.call(true) + } + /** * Sets the active display mode. * @param mode the mode to set. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/ChapterCountEvent.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/ChapterCountEvent.kt index 14a025df6..d307941bc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/ChapterCountEvent.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/ChapterCountEvent.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.ui.manga.info import rx.Observable import rx.subjects.BehaviorSubject -class ChapterCountEvent() { +class ChapterCountEvent { private val subject = BehaviorSubject.create() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFavoriteEvent.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFavoriteEvent.kt new file mode 100644 index 000000000..75beb742c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFavoriteEvent.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.ui.manga.info + +import com.jakewharton.rxrelay.PublishRelay +import rx.Observable + +class MangaFavoriteEvent { + + private val subject = PublishRelay.create() + + val observable: Observable + get() = subject + + fun call(favorite: Boolean) { + subject.call(favorite) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoFragment.kt index 9cf122198..e874f034c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoFragment.kt @@ -14,11 +14,13 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CenterCrop import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment import eu.kanade.tachiyomi.ui.manga.MangaActivity import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.snack import eu.kanade.tachiyomi.util.toast import jp.wasabeef.glide.transformations.CropCircleTransformation import jp.wasabeef.glide.transformations.CropSquareTransformation @@ -61,7 +63,7 @@ class MangaInfoFragment : BaseRxFragment() { override fun onViewCreated(view: View?, savedState: Bundle?) { // Set onclickListener to toggle favorite when FAB clicked. - fab_favorite.setOnClickListener { presenter.toggleFavorite() } + fab_favorite.setOnClickListener { toggleFavorite() } // Set SwipeRefresh to refresh manga data. swipe_refresh.setOnRefreshListener { fetchMangaFromSource() } @@ -122,9 +124,9 @@ class MangaInfoFragment : BaseRxFragment() { // Update status TextView. manga_status.setText(when (manga.status) { - Manga.ONGOING -> R.string.ongoing - Manga.COMPLETED -> R.string.completed - Manga.LICENSED -> R.string.licensed + SManga.ONGOING -> R.string.ongoing + SManga.COMPLETED -> R.string.completed + SManga.LICENSED -> R.string.licensed else -> R.string.unknown }) @@ -159,15 +161,33 @@ class MangaInfoFragment : BaseRxFragment() { manga_chapters.text = count.toString() } + /** + * Toggles the favorite status and asks for confirmation to delete downloaded chapters. + */ + fun toggleFavorite() { + if (!isAdded) return + + val isNowFavorite = presenter.toggleFavorite() + if (!isNowFavorite && presenter.hasDownloads()) { + view!!.snack(getString(R.string.delete_downloads_for_manga)) { + setAction(R.string.action_delete) { + presenter.deleteDownloads() + } + } + } + } + /** * Open the manga in browser. */ fun openInBrowser() { - val source = presenter.source as? OnlineSource ?: return + if (!isAdded) return + + val source = presenter.source as? HttpSource ?: return try { - val url = Uri.parse(source.baseUrl + presenter.manga.url) + val url = Uri.parse(source.mangaDetailsRequest(presenter.manga).url().toString()) val intent = CustomTabsIntent.Builder() - .setToolbarColor(context.theme.getResourceColor(R.attr.colorPrimary)) + .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) .build() intent.launchUrl(activity, url) } catch (e: Exception) { @@ -179,14 +199,16 @@ class MangaInfoFragment : BaseRxFragment() { * Called to run Intent with [Intent.ACTION_SEND], which show share dialog. */ private fun shareManga() { - val source = presenter.source as? OnlineSource ?: return + if (!isAdded) return + + val source = presenter.source as? HttpSource ?: return try { val url = source.mangaDetailsRequest(presenter.manga).url().toString() val sharingIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" - putExtra(android.content.Intent.EXTRA_TEXT, resources.getString(R.string.share_text, presenter.manga.title, url)) + putExtra(Intent.EXTRA_TEXT, getString(R.string.share_text, presenter.manga.title, url)) } - startActivity(Intent.createChooser(sharingIntent, resources.getText(R.string.action_share))) + startActivity(Intent.createChooser(sharingIntent, getString(R.string.action_share))) } catch (e: Exception) { context.toast(e.message) } @@ -196,6 +218,8 @@ class MangaInfoFragment : BaseRxFragment() { * Add the manga to the home screen */ fun addToHomeScreen() { + if (!isAdded) return + val shortcutIntent = activity.intent shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) .putExtra(MangaActivity.FROM_LAUNCHER_EXTRA, true) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt index 35941b3d0..3db68e5fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt @@ -4,12 +4,15 @@ import android.os.Bundle import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.SourceManager +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.manga.MangaEvent import eu.kanade.tachiyomi.util.SharedData +import eu.kanade.tachiyomi.util.isNullOrUnsubscribed import rx.Observable +import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import uy.kohesive.injekt.injectLazy @@ -48,84 +51,101 @@ class MangaInfoPresenter : BasePresenter() { */ val coverCache: CoverCache by injectLazy() - /** - * The id of the restartable. - */ - private val GET_MANGA = 1 + private val downloadManager: DownloadManager by injectLazy() /** - * The id of the restartable. + * Subscription to send the manga to the view. */ - private val FETCH_MANGA_INFO = 2 + private var viewMangaSubcription: Subscription? = null + + /** + * Subscription to update the manga from the source. + */ + private var fetchMangaSubscription: Subscription? = null override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - // Notify the view a manga is available or has changed. - startableLatestCache(GET_MANGA, - { Observable.just(manga) }, - { view, manga -> view.onNextManga(manga, source) }) - - // Fetch manga info from source. - startableFirst(FETCH_MANGA_INFO, - { fetchMangaObs() }, - { view, manga -> view.onFetchMangaDone() }, - { view, error -> view.onFetchMangaError() }) - manga = SharedData.get(MangaEvent::class.java)?.manga ?: return source = sourceManager.get(manga.source)!! - refreshManga() + sendMangaToView() // Update chapter count - SharedData.get(ChapterCountEvent::class.java)?.let { - it.observable - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache({ view, count -> view.setChapterCount(count) }) - } + SharedData.get(ChapterCountEvent::class.java)?.observable + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribeLatestCache(MangaInfoFragment::setChapterCount) + + // Update favorite status + SharedData.get(MangaFavoriteEvent::class.java)?.observable + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe { setFavorite(it) } + } + + /** + * Sends the active manga to the view. + */ + fun sendMangaToView() { + viewMangaSubcription?.let { remove(it) } + viewMangaSubcription = Observable.just(manga) + .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) }) } /** * Fetch manga information from source. */ fun fetchMangaFromSource() { - if (isUnsubscribed(FETCH_MANGA_INFO)) { - start(FETCH_MANGA_INFO) - } - } - - /** - * Fetch manga information from source. - * - * @return manga information. - */ - private fun fetchMangaObs(): Observable { - return source.fetchMangaDetails(manga) - .flatMap { networkManga -> + if (!fetchMangaSubscription.isNullOrUnsubscribed()) return + fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) } + .map { networkManga -> manga.copyFrom(networkManga) + manga.initialized = true db.insertManga(manga).executeAsBlocking() - Observable.just(manga) + manga } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { refreshManga() } + .doOnNext { sendMangaToView() } + .subscribeFirst({ view, manga -> + view.onFetchMangaDone() + }, { view, error -> + view.onFetchMangaError() + }) } /** * Update favorite status of manga, (removes / adds) manga (to / from) library. + * + * @return the new status of the manga. */ - fun toggleFavorite() { + fun toggleFavorite(): Boolean { manga.favorite = !manga.favorite if (!manga.favorite) { coverCache.deleteFromCache(manga.thumbnail_url) } db.insertManga(manga).executeAsBlocking() - refreshManga() + sendMangaToView() + return manga.favorite + } + + private fun setFavorite(favorite: Boolean) { + if (manga.favorite == favorite) { + return + } + toggleFavorite() } /** - * Refresh MangaInfo view. + * Returns true if the manga has any downloads. */ - private fun refreshManga() { - start(GET_MANGA) + fun hasDownloads(): Boolean { + return downloadManager.findMangaDir(source, manga) != null } + + /** + * Deletes all the downloads for the manga. + */ + fun deleteDownloads() { + downloadManager.findMangaDir(source, manga)?.delete() + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackFragment.kt index fa4feb475..017ef8703 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackFragment.kt @@ -45,7 +45,7 @@ class TrackFragment : BaseRxFragment() { private fun findSearchFragmentIfNeeded() { if (dialog == null) { - dialog = childFragmentManager.findFragmentByTag(searchFragmentTag) as TrackSearchDialog + dialog = childFragmentManager.findFragmentByTag(searchFragmentTag) as? TrackSearchDialog } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt index 0da76894a..88b2b184f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt @@ -2,8 +2,11 @@ package eu.kanade.tachiyomi.ui.reader import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.source.online.fetchImageFromCacheThenNet +import eu.kanade.tachiyomi.source.online.fetchPageListFromCacheThenNet import eu.kanade.tachiyomi.util.plusAssign import rx.Observable import rx.schedulers.Schedulers @@ -36,9 +39,11 @@ class ChapterLoader( } private fun prepareOnlineReading() { + if (source !is HttpSource) return + subscriptions += Observable.defer { Observable.just(queue.take().page) } .filter { it.status == Page.QUEUE } - .concatMap { source.fetchImage(it) } + .concatMap { source.fetchImageFromCacheThenNet(it) } .repeat() .subscribeOn(Schedulers.io()) .subscribe({ @@ -57,6 +62,10 @@ class ChapterLoader( Observable.just(chapter.pages!!) } .doOnNext { pages -> + if (pages.isEmpty()) { + throw Exception("Page list is empty") + } + // Now that the number of pages is known, fix the requested page if the last one // was requested. if (chapter.requestedPage == -1) { @@ -76,8 +85,8 @@ class ChapterLoader( // Fetch the page list from disk. downloadManager.buildPageList(source, manga, chapter) } else { - // Fetch the page list from cache or fallback to network - source.fetchPageList(chapter) + (source as? HttpSource)?.fetchPageListFromCacheThenNet(chapter) + ?: source.fetchPageList(chapter) } } .doOnNext { pages -> @@ -111,6 +120,8 @@ class ChapterLoader( queue.offer(PriorityPage(page, 2)) } + + private data class PriorityPage(val page: Page, val priority: Int): Comparable { companion object { 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 6afb528d1..11b27c623 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 @@ -20,7 +20,7 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader @@ -171,11 +171,11 @@ class ReaderActivity : BaseRxActivity() { .content(getString(R.string.confirm_update_manga_sync, chapterToUpdate)) .positiveText(android.R.string.yes) .negativeText(android.R.string.no) - .onPositive { dialog, which -> presenter.updateTrackLastChapterRead() } + .onPositive { dialog, which -> presenter.updateTrackLastChapterRead(chapterToUpdate) } .onAny { dialog1, which1 -> super.onBackPressed() } .show() } else { - presenter.updateTrackLastChapterRead() + presenter.updateTrackLastChapterRead(chapterToUpdate) super.onBackPressed() } } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt index 56de9c972..e9bd9a0c4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.ui.reader import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page class ReaderChapter(c: Chapter) : Chapter by c { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderCustomFilterDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderCustomFilterDialog.kt index dc820106e..4dc26c487 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderCustomFilterDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderCustomFilterDialog.kt @@ -96,6 +96,7 @@ class ReaderCustomFilterDialog : DialogFragment() { // Set brightness value txt_brightness_seekbar_value.text = brightness.toString() + brightness_seekbar.progress = brightness // Initialize seekBar progress seekbar_color_filter_alpha.progress = argb[0] @@ -145,7 +146,7 @@ class ReaderCustomFilterDialog : DialogFragment() { } } }) - brightness_seekbar.progress = preferences.customBrightnessValue().getOrDefault() + brightness_seekbar.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { if (fromUser) { 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 f85ab3665..56bcdc934 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 @@ -13,13 +13,12 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.source.SourceManager -import eu.kanade.tachiyomi.data.source.model.Page -import eu.kanade.tachiyomi.data.source.online.OnlineSource import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.data.track.TrackUpdateService +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.ui.reader.notification.ImageNotifier import eu.kanade.tachiyomi.util.DiskUtil import eu.kanade.tachiyomi.util.RetryWithDelay import eu.kanade.tachiyomi.util.SharedData @@ -141,6 +140,11 @@ class ReaderPresenter : BasePresenter() { */ private var adjacentChaptersSubscription: Subscription? = null + /** + * Whether the active chapter has been loaded. + */ + private var chapterLoaded = false + companion object { /** * Id of the restartable that loads the active chapter. @@ -211,6 +215,7 @@ class ReaderPresenter : BasePresenter() { return loader.loadChapter(chapter) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { chapterLoaded = true } } /** @@ -298,6 +303,7 @@ class ReaderPresenter : BasePresenter() { nextChapter = null prevChapter = null + chapterLoaded = false start(LOAD_ACTIVE_CHAPTER) getAdjacentChapters(chapter) } @@ -342,7 +348,7 @@ class ReaderPresenter : BasePresenter() { * @param page the page that failed. */ fun retryPage(page: Page?) { - if (page != null && source is OnlineSource) { + if (page != null && source is HttpSource) { page.status = Page.QUEUE val uri = page.uri if (uri != null && !page.chapter.isDownloaded) { @@ -365,7 +371,9 @@ class ReaderPresenter : BasePresenter() { Observable.fromCallable { // Cache current page list progress for online chapters to allow a faster reopen if (!chapter.isDownloaded) { - source.let { if (it is OnlineSource) it.savePageList(chapter, pages) } + source.let { + if (it is HttpSource) chapterCache.putPageListToCache(chapter, pages) + } } try { @@ -443,27 +451,31 @@ class ReaderPresenter : BasePresenter() { Math.floor(chapter.chapter_number.toDouble()).toInt() else if (prevChapter != null && prevChapter.read) Math.floor(prevChapter.chapter_number.toDouble()).toInt() + else + return 0 + + return if (trackList.any { lastChapterRead > it.last_chapter_read }) + lastChapterRead else 0 - - trackList.forEach { sync -> - if (lastChapterRead > sync.last_chapter_read) { - sync.last_chapter_read = lastChapterRead - sync.update = true - } - } - - return if (trackList.any { it.update }) lastChapterRead else 0 } /** * Starts the service that updates the last chapter read in sync services */ - fun updateTrackLastChapterRead() { - trackList?.forEach { sync -> - val service = trackManager.getService(sync.sync_id) - if (service != null && service.isLogged && sync.update) { - TrackUpdateService.start(context, sync) + fun updateTrackLastChapterRead(lastChapterRead: Int) { + trackList?.forEach { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) { + track.last_chapter_read = lastChapterRead + + // We wan't these to execute even if the presenter is destroyed and leaks for a + // while. The view can still be garbage collected. + Observable.defer { service.update(track) } + .map { db.insertTrack(track).executeAsBlocking() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({}, { Timber.e(it) }) } } } @@ -474,6 +486,9 @@ class ReaderPresenter : BasePresenter() { * @return true if the next chapter is being loaded, false if there is no next chapter. */ fun loadNextChapter(): Boolean { + // Avoid skipping chapters. + if (!chapterLoaded) return true + nextChapter?.let { onChapterLeft() loadChapter(it, 0) @@ -488,6 +503,9 @@ class ReaderPresenter : BasePresenter() { * @return true if the previous chapter is being loaded, false if there is no previous chapter. */ fun loadPreviousChapter(): Boolean { + // Avoid skipping chapters. + if (!chapterLoaded) return true + prevChapter?.let { onChapterLeft() loadChapter(it, if (it.read) -1 else 0) @@ -525,6 +543,13 @@ class ReaderPresenter : BasePresenter() { */ internal fun setImageAsCover(page: Page) { try { + if (manga.source == LocalSource.ID) { + val input = context.contentResolver.openInputStream(page.uri) + LocalSource.updateCover(context, manga, input) + context.toast(R.string.cover_updated) + return + } + val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found") if (manga.favorite) { val input = context.contentResolver.openInputStream(page.uri) @@ -547,7 +572,7 @@ class ReaderPresenter : BasePresenter() { return // Used to show image notification. - val imageNotifier = ImageNotifier(context) + val imageNotifier = SaveImageNotifier(context) // Remove the notification if it already exists (user feedback). imageNotifier.onClear() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt index 56659e637..bdcc558dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt @@ -83,6 +83,11 @@ class ReaderSettingsDialog : DialogFragment() { fullscreen.setOnCheckedChangeListener { v, isChecked -> preferences.fullscreen().set(isChecked) } + + crop_borders.isChecked = preferences.cropBorders().getOrDefault() + crop_borders.setOnCheckedChangeListener { v, isChecked -> + preferences.cropBorders().set(isChecked) + } } override fun onDestroyView() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotifier.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt index eeb20695e..9f4f43bd9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.reader.notification +package eu.kanade.tachiyomi.ui.reader import android.content.Context import android.graphics.Bitmap @@ -7,13 +7,15 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import eu.kanade.tachiyomi.Constants import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.NotificationHandler +import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.util.notificationManager import java.io.File /** * Class used to show BigPictureStyle notifications */ -class ImageNotifier(private val context: Context) { +class SaveImageNotifier(private val context: Context) { /** * Notification builder. */ @@ -58,15 +60,15 @@ class ImageNotifier(private val context: Context) { if (!mActions.isEmpty()) mActions.clear() - setContentIntent(ImageNotificationReceiver.showImageIntent(context, file)) + setContentIntent(NotificationHandler.openImagePendingActivity(context, file)) // Share action addAction(R.drawable.ic_share_grey_24dp, - context.getString(R.string.action_share), - ImageNotificationReceiver.shareImageIntent(context, file)) + context.getString(R.string.action_share), + NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId)) // Delete action addAction(R.drawable.ic_delete_grey_24dp, context.getString(R.string.action_delete), - ImageNotificationReceiver.deleteImageIntent(context, file.absolutePath, notificationId)) + NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId)) updateNotification() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotificationReceiver.kt deleted file mode 100644 index d1b123928..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotificationReceiver.kt +++ /dev/null @@ -1,84 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader.notification - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.support.v4.content.FileProvider -import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.util.notificationManager -import java.io.File -import eu.kanade.tachiyomi.Constants.NOTIFICATION_DOWNLOAD_IMAGE_ID as defaultNotification - -/** - * The BroadcastReceiver of [ImageNotifier] - * Intent calls should be made from this class. - */ -class ImageNotificationReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - ACTION_DELETE_IMAGE -> { - deleteImage(intent.getStringExtra(EXTRA_FILE_LOCATION)) - context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, defaultNotification)) - } - } - } - - /** - * Called to delete image - * - * @param path path of file - */ - private fun deleteImage(path: String) { - val file = File(path) - if (file.exists()) file.delete() - } - - companion object { - private const val ACTION_DELETE_IMAGE = "eu.kanade.DELETE_IMAGE" - - private const val EXTRA_FILE_LOCATION = "file_location" - - private const val NOTIFICATION_ID = "notification_id" - - /** - * Called to start share intent to share image - * - * @param context context of application - * @param file file that contains image - */ - internal fun shareImageIntent(context: Context, file: File): PendingIntent { - val intent = Intent(Intent.ACTION_SEND).apply { - val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file) - putExtra(Intent.EXTRA_STREAM, uri) - type = "image/*" - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - } - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) - } - - /** - * Called to show image in gallery application - * - * @param context context of application - * @param file file that contains image - */ - internal fun showImageIntent(context: Context, file: File): PendingIntent { - val intent = Intent(Intent.ACTION_VIEW).apply { - val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file) - setDataAndType(uri, "image/*") - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - } - return PendingIntent.getActivity(context, 0, intent, 0) - } - - internal fun deleteImageIntent(context: Context, path: String, notificationId: Int): PendingIntent { - val intent = Intent(context, ImageNotificationReceiver::class.java).apply { - action = ACTION_DELETE_IMAGE - putExtra(EXTRA_FILE_LOCATION, path) - putExtra(NOTIFICATION_ID, notificationId) - } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt index 46903d038..878ab38df 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.ui.reader.viewer.base import com.davemorrissey.labs.subscaleview.decoder.* import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.base.fragment.BaseFragment import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderChapter @@ -20,15 +20,15 @@ abstract class BaseReader : BaseFragment() { */ const val IMAGE_DECODER = 0 - /** - * Skia decoder. - */ - const val SKIA_DECODER = 1 - /** * Rapid decoder. */ - const val RAPID_DECODER = 2 + const val RAPID_DECODER = 1 + + /** + * Skia decoder. + */ + const val SKIA_DECODER = 2 } /** @@ -233,14 +233,14 @@ abstract class BaseReader : BaseFragment() { bitmapDecoderClass = IImageDecoder::class.java regionDecoderClass = IImageRegionDecoder::class.java } - SKIA_DECODER -> { - bitmapDecoderClass = SkiaImageDecoder::class.java - regionDecoderClass = SkiaImageRegionDecoder::class.java - } RAPID_DECODER -> { bitmapDecoderClass = RapidImageDecoder::class.java regionDecoderClass = RapidImageRegionDecoder::class.java } + SKIA_DECODER -> { + bitmapDecoderClass = SkiaImageDecoder::class.java + regionDecoderClass = SkiaImageRegionDecoder::class.java + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt index 562356551..b9314d0c4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt @@ -4,7 +4,7 @@ import android.net.Uri import android.support.v4.content.ContextCompat import android.view.View import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.ReaderActivity import kotlinx.android.synthetic.main.page_decode_error.view.* diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt index bab6603c1..a75eaaee7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt @@ -10,7 +10,7 @@ import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader @@ -61,7 +61,7 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? } with(image_view) { - setMaxBitmapDimensions((reader.activity as ReaderActivity).maxBitmapSize) + setMaxTileSize((reader.activity as ReaderActivity).maxBitmapSize) setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED) setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) setMinimumScaleType(reader.scaleType) @@ -70,6 +70,7 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? setRegionDecoderClass(reader.regionDecoderClass) setBitmapDecoderClass(reader.bitmapDecoderClass) setVerticalScrollingParent(reader is VerticalReader) + setCropBorders(reader.cropBorders) setOnTouchListener { v, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) } setOnLongClickListener { reader.onLongClick(page) } setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt index 5e53aebed..d3c1f4589 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt @@ -6,12 +6,11 @@ import android.view.MotionEvent import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.ReaderChapter import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader -import eu.kanade.tachiyomi.util.toast import rx.subscriptions.CompositeSubscription /** @@ -80,6 +79,12 @@ abstract class PagerReader : BaseReader() { var transitions: Boolean = false private set + /** + * Whether to crop image borders. + */ + var cropBorders: Boolean = false + private set + /** * Scale type (fit width, fit screen, etc). */ @@ -151,9 +156,16 @@ abstract class PagerReader : BaseReader() { .distinctUntilChanged() .subscribe { refreshAdapter() }) - add(preferences.enableTransitions() + add(preferences.pageTransitions() .asObservable() .subscribe { transitions = it }) + + add(preferences.cropBorders() + .asObservable() + .doOnNext { cropBorders = it } + .skip(1) + .distinctUntilChanged() + .subscribe { refreshAdapter() }) } setPagesOnAdapter() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt index 48f13e152..0fa7a808f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt @@ -1,9 +1,10 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager +import android.support.v4.view.PagerAdapter import android.view.View import android.view.ViewGroup import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.widget.ViewPagerAdapter @@ -34,4 +35,13 @@ class PagerReaderAdapter(private val reader: PagerReader) : ViewPagerAdapter() { return pages.size } + override fun getItemPosition(obj: Any): Int { + val view = obj as PageView + return if (view.page in pages) { + PagerAdapter.POSITION_UNCHANGED + } else { + PagerAdapter.POSITION_NONE + } + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt index d706540de..ea6c6dca9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt @@ -4,7 +4,7 @@ import android.support.v7.widget.RecyclerView import android.view.View import android.view.ViewGroup import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.util.inflate /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt index 3b2557647..b9acac158 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt @@ -8,7 +8,7 @@ import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout import eu.kanade.tachiyomi.util.inflate @@ -54,7 +54,7 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter) init { with(view.image_view) { - setMaxBitmapDimensions(readerActivity.maxBitmapSize) + setMaxTileSize(readerActivity.maxBitmapSize) setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED) setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt index 391ef68ce..f160f0ce6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt @@ -5,7 +5,7 @@ import android.support.v7.widget.RecyclerView import android.view.* import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.ReaderChapter import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader import eu.kanade.tachiyomi.widget.PreCachingLayoutManager diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt new file mode 100644 index 000000000..cc13088dd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.ui.recent_updates + +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractHeaderItem +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import java.util.* + +class DateItem(val date: Date) : AbstractHeaderItem() { + + override fun getLayoutRes(): Int { + return R.layout.item_recent_chapter_section + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(layoutRes, parent, false), adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List?) { + holder.bind(this) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is DateItem) { + return date == other.date + } + return false + } + + override fun hashCode(): Int { + return date.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter, true) { + + private val now = Date().time + + val section_text = view.findViewById(R.id.section_text) as TextView + + fun bind(item: DateItem) { + section_text.text = DateUtils.getRelativeTimeSpanString(item.date.time, now, DateUtils.DAY_IN_MILLIS) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapter.kt deleted file mode 100644 index c9060f3e8..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapter.kt +++ /dev/null @@ -1,22 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.MangaChapter -import eu.kanade.tachiyomi.data.download.model.Download - -class RecentChapter(mc: MangaChapter) : Chapter by mc.chapter { - - val manga = mc.manga - - private var _status: Int = 0 - - var status: Int - get() = download?.status ?: _status - set(value) { _status = value } - - @Transient var download: Download? = null - - val isDownloaded: Boolean - get() = status == Download.DOWNLOADED - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt similarity index 71% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersHolder.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt index 8465adf0a..7ee7e1fe9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt @@ -2,9 +2,9 @@ package eu.kanade.tachiyomi.ui.recent_updates import android.view.View import android.widget.PopupMenu +import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder import eu.kanade.tachiyomi.util.getResourceColor import kotlinx.android.synthetic.main.item_recent_chapters.view.* @@ -18,49 +18,47 @@ import kotlinx.android.synthetic.main.item_recent_chapters.view.* * @param listener a listener to react to single tap and long tap events. * @constructor creates a new recent chapter holder. */ -class RecentChaptersHolder( - private val view: View, - private val adapter: RecentChaptersAdapter, - listener: OnListItemClickListener) -: FlexibleViewHolder(view, adapter, listener) { +class RecentChapterHolder(private val view: View, private val adapter: RecentChaptersAdapter) : + FlexibleViewHolder(view, adapter) { + /** * Color of read chapter */ - private var readColor = view.context.theme.getResourceColor(android.R.attr.textColorHint) + private var readColor = view.context.getResourceColor(android.R.attr.textColorHint) /** * Color of unread chapter */ - private var unreadColor = view.context.theme.getResourceColor(android.R.attr.textColorPrimary) + private var unreadColor = view.context.getResourceColor(android.R.attr.textColorPrimary) /** - * Object containing chapter information + * Currently bound item. */ - private var chapter: RecentChapter? = null + private var item: RecentChapterItem? = null init { // We need to post a Runnable to show the popup to make sure that the PopupMenu is // correctly positioned. The reason being that the view may change position before the // PopupMenu is shown. - view.chapter_menu.setOnClickListener { it.post({ showPopupMenu(it) }) } + view.chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } } } /** * Set values of view * - * @param chapter item containing chapter information + * @param item item containing chapter information */ - fun onSetValues(chapter: RecentChapter) { - this.chapter = chapter + fun bind(item: RecentChapterItem) { + this.item = item // Set chapter title - view.chapter_title.text = chapter.name + view.chapter_title.text = item.chapter.name // Set manga title - view.manga_title.text = chapter.manga.title + view.manga_title.text = item.manga.title // Check if chapter is read and set correct color - if (chapter.read) { + if (item.chapter.read) { view.chapter_title.setTextColor(readColor) view.manga_title.setTextColor(readColor) } else { @@ -69,7 +67,7 @@ class RecentChaptersHolder( } // Set chapter status - notifyStatus(chapter.status) + notifyStatus(item.status) } /** @@ -92,7 +90,7 @@ class RecentChaptersHolder( * * @param view view containing popup menu. */ - private fun showPopupMenu(view: View) = chapter?.let { chapter -> + private fun showPopupMenu(view: View) = item?.let { item -> // Create a PopupMenu, giving it the clicked view for an anchor val popup = PopupMenu(view.context, view) @@ -100,18 +98,18 @@ class RecentChaptersHolder( popup.menuInflater.inflate(R.menu.chapter_recent, popup.menu) // Hide download and show delete if the chapter is downloaded and - if (chapter.isDownloaded) { + if (item.isDownloaded) { popup.menu.findItem(R.id.action_download).isVisible = false popup.menu.findItem(R.id.action_delete).isVisible = true } // Hide mark as unread when the chapter is unread - if (!chapter.read /*&& mangaChapter.chapter.last_page_read == 0*/) { + if (!item.chapter.read /*&& mangaChapter.chapter.last_page_read == 0*/) { popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false } // Hide mark as read when the chapter is read - if (chapter.read) { + if (item.chapter.read) { popup.menu.findItem(R.id.action_mark_as_read).isVisible = false } @@ -119,10 +117,10 @@ class RecentChaptersHolder( popup.setOnMenuItemClickListener { menuItem -> with(adapter.fragment) { when (menuItem.itemId) { - R.id.action_download -> downloadChapter(chapter) - R.id.action_delete -> deleteChapter(chapter) - R.id.action_mark_as_read -> markAsRead(listOf(chapter)) - R.id.action_mark_as_unread -> markAsUnread(listOf(chapter)) + R.id.action_download -> downloadChapter(item) + R.id.action_delete -> deleteChapter(item) + R.id.action_mark_as_read -> markAsRead(listOf(item)) + R.id.action_mark_as_unread -> markAsUnread(listOf(item)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt new file mode 100644 index 000000000..7f1a1e4fd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.ui.recent_updates + +import android.view.LayoutInflater +import android.view.ViewGroup +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractSectionableItem +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.download.model.Download + +class RecentChapterItem(val chapter: Chapter, val manga: Manga, header: DateItem) : + AbstractSectionableItem(header) { + + private var _status: Int = 0 + + var status: Int + get() = download?.status ?: _status + set(value) { _status = value } + + @Transient var download: Download? = null + + val isDownloaded: Boolean + get() = status == Download.DOWNLOADED + + override fun getLayoutRes(): Int { + return R.layout.item_recent_chapters + } + + override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): RecentChapterHolder { + return RecentChapterHolder(inflater.inflate(layoutRes, parent, false), adapter as RecentChaptersAdapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: RecentChapterHolder, position: Int, payloads: List?) { + holder.bind(this) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is RecentChapterItem) { + return chapter.id!! == other.chapter.id!! + } + return false + } + + override fun hashCode(): Int { + return chapter.id!!.hashCode() + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt index 802cd4141..ebd1b7e69 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt @@ -1,128 +1,13 @@ package eu.kanade.tachiyomi.ui.recent_updates -import android.support.v7.widget.RecyclerView -import android.view.View -import android.view.ViewGroup import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.inflate -import java.util.* +import eu.davidea.flexibleadapter.items.IFlexible -/** - * Adapter of RecentChaptersHolder. - * Connection between Fragment and Holder - * Holder updates should be called from here. - * - * @param fragment a RecentChaptersFragment object - * @constructor creates an instance of the adapter. - */ - -class RecentChaptersAdapter(val fragment: RecentChaptersFragment) -: FlexibleAdapter() { - /** - * The id of the view type - */ - private val VIEW_TYPE_CHAPTER = 0 - - /** - * The id of the view type - */ - private val VIEW_TYPE_SECTION = 1 +class RecentChaptersAdapter(val fragment: RecentChaptersFragment) : + FlexibleAdapter>(null, fragment, true) { init { - // Let each each item in the data set be represented with a unique identifier. - setHasStableIds(true) + setDisplayHeadersAtStartUp(true) + setStickyHeaders(true) } - - /** - * Called when ViewHolder is bind - * - * @param holder bind holder - * @param position position of holder - */ - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - // Check which view type and set correct values. - val item = getItem(position) - when (holder.itemViewType) { - VIEW_TYPE_CHAPTER -> { - if (item is RecentChapter) { - (holder as RecentChaptersHolder).onSetValues(item) - } - } - VIEW_TYPE_SECTION -> { - if (item is Date) { - (holder as SectionViewHolder).onSetValues(item) - } - } - } - - //When user scrolls this bind the correct selection status - holder.itemView.isActivated = isSelected(position) - } - - /** - * Called when ViewHolder is created - * - * @param parent parent View - * @param viewType int containing viewType - */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder? { - val view: View - - // Check which view type and set correct values. - when (viewType) { - VIEW_TYPE_CHAPTER -> { - view = parent.inflate(R.layout.item_recent_chapters) - return RecentChaptersHolder(view, this, fragment) - } - VIEW_TYPE_SECTION -> { - view = parent.inflate(R.layout.item_recent_chapter_section) - return SectionViewHolder(view) - } - } - return null - } - - /** - * Returns the correct ViewType - * - * @param position position of item - */ - override fun getItemViewType(position: Int): Int { - return if (getItem(position) is RecentChapter) VIEW_TYPE_CHAPTER else VIEW_TYPE_SECTION - } - - - /** - * Update items - - * @param items items - */ - fun setItems(items: List) { - mItems = items - notifyDataSetChanged() - } - - /** - * Needed to determine holder id - * - * @param position position of holder item - */ - override fun getItemId(position: Int): Long { - val item = getItem(position) - if (item is RecentChapter) - return item.id!! - else - return item.hashCode().toLong() - } - - /** - * Abstract function (not needed). - * - * @param p0 a string. - */ - override fun updateDataSet(p0: String) { - // Empty function. - } - } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersFragment.kt index acd502c81..607f732b9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersFragment.kt @@ -9,15 +9,16 @@ import android.support.v7.widget.RecyclerView import android.view.* import com.afollestad.materialdialogs.MaterialDialog import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.widget.DeletingChaptersDialog +import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.fragment_recent_chapters.* import nucleus.factory.RequiresPresenter import timber.log.Timber @@ -28,8 +29,11 @@ import timber.log.Timber * UI related actions should be called from here. */ @RequiresPresenter(RecentChaptersPresenter::class) -class RecentChaptersFragment -: BaseRxFragment(), ActionMode.Callback, FlexibleViewHolder.OnListItemClickListener { +class RecentChaptersFragment: + BaseRxFragment(), + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener{ companion object { /** @@ -97,40 +101,46 @@ class RecentChaptersFragment // Update toolbar text setToolbarTitle(R.string.label_recent_updates) + + // Disable toolbar elevation, it looks better with sticky headers. + activity.appbar.disableElevation() + } + + override fun onDestroyView() { + // Restore toolbar elevation. + activity.appbar.enableElevation() + super.onDestroyView() } /** * Returns selected chapters * @return list of selected chapters */ - fun getSelectedChapters(): List { - return adapter.selectedItems.map { adapter.getItem(it) as? RecentChapter }.filterNotNull() + fun getSelectedChapters(): List { + return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem } } /** * Called when item in list is clicked * @param position position of clicked item */ - override fun onListItemClick(position: Int): Boolean { + override fun onItemClick(position: Int): Boolean { // Get item from position - val item = adapter.getItem(position) - if (item is RecentChapter) { - if (actionMode != null && adapter.mode == FlexibleAdapter.MODE_MULTI) { - toggleSelection(position) - return true - } else { - openChapter(item) - return false - } + val item = adapter.getItem(position) as? RecentChapterItem ?: return false + if (actionMode != null && adapter.mode == FlexibleAdapter.MODE_MULTI) { + toggleSelection(position) + return true + } else { + openChapter(item) + return false } - return false } /** * Called when item in list is long clicked * @param position position of clicked item */ - override fun onListItemLongClick(position: Int) { + override fun onItemLongClick(position: Int) { if (actionMode == null) actionMode = activity.startSupportActionMode(this) @@ -142,31 +152,22 @@ class RecentChaptersFragment * @param position position of selected item */ private fun toggleSelection(position: Int) { - adapter.toggleSelection(position, false) + adapter.toggleSelection(position) val count = adapter.selectedItemCount if (count == 0) { actionMode?.finish() } else { - setContextTitle(count) - actionMode?.invalidate() + actionMode?.title = getString(R.string.label_selected, count) } } - /** - * Set the context title - * @param count count of selected items - */ - private fun setContextTitle(count: Int) { - actionMode?.title = getString(R.string.label_selected, count) - } - /** * Open chapter in reader * @param chapter selected chapter */ - private fun openChapter(chapter: RecentChapter) { - val intent = ReaderActivity.newIntent(activity, chapter.manga, chapter) + private fun openChapter(item: RecentChapterItem) { + val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter) startActivity(intent) } @@ -174,7 +175,7 @@ class RecentChaptersFragment * Download selected items * @param chapters list of selected [RecentChapter]s */ - fun downloadChapters(chapters: List) { + fun downloadChapters(chapters: List) { destroyActionModeIfNeeded() presenter.downloadChapters(chapters) } @@ -183,12 +184,12 @@ class RecentChaptersFragment * Populate adapter with chapters * @param chapters list of [Any] */ - fun onNextRecentChapters(chapters: List) { + fun onNextRecentChapters(chapters: List>) { (activity as MainActivity).updateEmptyView(chapters.isEmpty(), R.string.information_no_recent, R.drawable.ic_update_black_128dp) destroyActionModeIfNeeded() - adapter.setItems(chapters) + adapter.updateDataSet(chapters.toMutableList()) } /** @@ -203,15 +204,15 @@ class RecentChaptersFragment * Returns holder belonging to chapter * @param download [Download] object containing download progress. */ - private fun getHolder(download: Download): RecentChaptersHolder? { - return recycler.findViewHolderForItemId(download.chapter.id!!) as? RecentChaptersHolder + private fun getHolder(download: Download): RecentChapterHolder? { + return recycler.findViewHolderForItemId(download.chapter.id!!) as? RecentChapterHolder } /** * Mark chapter as read * @param chapters list of chapters */ - fun markAsRead(chapters: List) { + fun markAsRead(chapters: List) { presenter.markChapterRead(chapters, true) if (presenter.preferences.removeAfterMarkedAsRead()) { deleteChapters(chapters) @@ -222,7 +223,7 @@ class RecentChaptersFragment * Delete selected chapters * @param chapters list of [RecentChapter] objects */ - fun deleteChapters(chapters: List) { + fun deleteChapters(chapters: List) { destroyActionModeIfNeeded() DeletingChaptersDialog().show(childFragmentManager, DeletingChaptersDialog.TAG) presenter.deleteChapters(chapters) @@ -239,7 +240,7 @@ class RecentChaptersFragment * Mark chapter as unread * @param chapters list of selected [RecentChapter] */ - fun markAsUnread(chapters: List) { + fun markAsUnread(chapters: List) { presenter.markChapterRead(chapters, false) } @@ -247,15 +248,15 @@ class RecentChaptersFragment * Start downloading chapter * @param chapter selected chapter with manga */ - fun downloadChapter(chapter: RecentChapter) { - presenter.downloadChapter(chapter) + fun downloadChapter(chapter: RecentChapterItem) { + presenter.downloadChapters(listOf(chapter)) } /** * Start deleting chapter * @param chapter selected chapter with manga */ - fun deleteChapter(chapter: RecentChapter) { + fun deleteChapter(chapter: RecentChapterItem) { DeletingChaptersDialog().show(childFragmentManager, DeletingChaptersDialog.TAG) presenter.deleteChapters(listOf(chapter)) } @@ -281,11 +282,8 @@ class RecentChaptersFragment * Called to dismiss deleting dialog */ fun dismissDeletingDialog() { - (childFragmentManager.findFragmentByTag(DeletingChaptersDialog.TAG) as? DialogFragment)?.dismiss() - } - - override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { - return false + (childFragmentManager.findFragmentByTag(DeletingChaptersDialog.TAG) as? DialogFragment) + ?.dismissAllowingStateLoss() } /** @@ -294,6 +292,8 @@ class RecentChaptersFragment * @param item item from ActionMode. */ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + if (!isAdded) return true + when (item.itemId) { R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) @@ -322,12 +322,16 @@ class RecentChaptersFragment return true } + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + return false + } + /** * Called when ActionMode destroyed * @param mode the ActionMode object */ override fun onDestroyActionMode(mode: ActionMode?) { - adapter.mode = FlexibleAdapter.MODE_SINGLE + adapter.mode = FlexibleAdapter.MODE_IDLE adapter.clearSelection() actionMode = null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt index 4c9c99057..8fb4a5b31 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt @@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.source.SourceManager +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import rx.Observable import rx.android.schedulers.AndroidSchedulers @@ -41,7 +41,7 @@ class RecentChaptersPresenter : BasePresenter() { /** * List containing chapter and manga information */ - private var chapters: List? = null + private var chapters: List = emptyList() override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) @@ -53,15 +53,15 @@ class RecentChaptersPresenter : BasePresenter() { getChapterStatusObservable() .subscribeLatestCache(RecentChaptersFragment::onChapterStatusChange, { view, error -> Timber.e(error) }) - } /** * Get observable containing recent chapters and date + * * @return observable containing recent chapters and date */ - fun getRecentChaptersObservable(): Observable> { - // Set date for recent chapters + fun getRecentChaptersObservable(): Observable> { + // Set date limit for recent chapters val cal = Calendar.getInstance().apply { time = Date() add(Calendar.MONTH, -1) @@ -70,109 +70,32 @@ class RecentChaptersPresenter : BasePresenter() { return db.getRecentChapters(cal.time).asRxObservable() // Convert to a list of recent chapters. .map { mangaChapters -> - mangaChapters.map { it.toModel() } + val map = TreeMap> { d1, d2 -> d2.compareTo(d1) } + val byDay = mangaChapters.groupByTo(map, { getMapKey(it.chapter.date_fetch) }) + byDay.flatMap { + val dateItem = DateItem(it.key) + it.value.map { RecentChapterItem(it.chapter, it.manga, dateItem) } + } } .doOnNext { + it.forEach { item -> + // Find an active download for this chapter. + val download = downloadManager.queue.find { it.chapter.id == item.chapter.id } + + // If there's an active download, assign it, otherwise ask the manager if + // the chapter is downloaded and assign it to the status. + if (download != null) { + item.download = download + } + } setDownloadedChapters(it) chapters = it } - // Group chapters by the date they were fetched on a ordered map. - .flatMap { recentItems -> - Observable.from(recentItems) - .toMultimap( - { getMapKey(it.date_fetch) }, - { it }, - { TreeMap { d1, d2 -> d2.compareTo(d1) } }) - } - // Add every day and all its chapters to a single list. - .map { recentItems -> - ArrayList().apply { - for ((key, value) in recentItems) { - add(key) - addAll(value) - } - } - } - } - - /** - * Returns observable containing chapter status. - * @return download object containing download progress. - */ - private fun getChapterStatusObservable(): Observable { - return downloadManager.queue.getStatusObservable() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { download -> onDownloadStatusChange(download) } - } - - /** - * Converts a chapter from the database to an extended model, allowing to store new fields. - */ - private fun MangaChapter.toModel(): RecentChapter { - // Create the model object. - val model = RecentChapter(this) - - // Find an active download for this chapter. - val download = downloadManager.queue.find { it.chapter.id == chapter.id } - - // If there's an active download, assign it, otherwise ask the manager if the chapter is - // downloaded and assign it to the status. - if (download != null) { - model.download = download - } - return model - } - - /** - * Finds and assigns the list of downloaded chapters. - * - * @param chapters the list of chapter from the database. - */ - private fun setDownloadedChapters(chapters: List) { - // Cached list of downloaded manga directories. - val mangaDirectories = mutableMapOf>() - - // Cached list of downloaded chapter directories for a manga. - val chapterDirectories = mutableMapOf>() - - for (chapter in chapters) { - val manga = chapter.manga - val source = sourceManager.get(manga.source) ?: continue - - val mangaDirs = mangaDirectories.getOrPut(source.id) { - downloadManager.findSourceDir(source)?.listFiles() ?: emptyArray() - } - - val mangaDirName = downloadManager.getMangaDirName(manga) - val mangaDir = mangaDirs.find { it.name == mangaDirName } ?: continue - - val chapterDirs = chapterDirectories.getOrPut(manga.id!!) { - mangaDir.listFiles() ?: emptyArray() - } - - val chapterDirName = downloadManager.getChapterDirName(chapter) - if (chapterDirs.any { it.name == chapterDirName }) { - chapter.status = Download.DOWNLOADED - } - } - } - - /** - * Update status of chapters. - * @param download download object containing progress. - */ - private fun onDownloadStatusChange(download: Download) { - // Assign the download to the model object. - if (download.status == Download.QUEUE) { - val chapter = chapters?.find { it.id == download.chapter.id } - if (chapter != null && chapter.download == null) { - chapter.download = download - } - } } /** * Get date as time key + * * @param date desired date * @return date as time key */ @@ -186,30 +109,99 @@ class RecentChaptersPresenter : BasePresenter() { return cal.time } + /** + * Returns observable containing chapter status. + * + * @return download object containing download progress. + */ + private fun getChapterStatusObservable(): Observable { + return downloadManager.queue.getStatusObservable() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { download -> onDownloadStatusChange(download) } + } + + /** + * Finds and assigns the list of downloaded chapters. + * + * @param items the list of chapter from the database. + */ + private fun setDownloadedChapters(items: List) { + // Cached list of downloaded manga directories. Directory name is also cached because + // it's slow when using SAF. + val mangaDirsForSource = mutableMapOf>() + + // Cached list of downloaded chapter directories for a manga. + val chapterDirsForManga = mutableMapOf>() + + for (item in items) { + val manga = item.manga + val chapter = item.chapter + val source = sourceManager.get(manga.source) ?: continue + + // Get the directories for the source of the manga. + val dirsForSource = mangaDirsForSource.getOrPut(source.id) { + val sourceDir = downloadManager.findSourceDir(source) + sourceDir?.listFiles()?.associateBy { it.name }.orEmpty() + } + + // Get the manga directory in the source or continue. + val mangaDirName = downloadManager.getMangaDirName(manga) + val mangaDir = dirsForSource[mangaDirName] ?: continue + + // Get the directories for the manga. + val chapterDirs = chapterDirsForManga.getOrPut(manga.id!!) { + mangaDir.listFiles()?.associateBy { it.name }.orEmpty() + } + + // Assign the download if the directory exists. + val chapterDirName = downloadManager.getChapterDirName(chapter) + if (chapterDirName in chapterDirs) { + item.status = Download.DOWNLOADED + } + } + } + + /** + * Update status of chapters. + * + * @param download download object containing progress. + */ + private fun onDownloadStatusChange(download: Download) { + // Assign the download to the model object. + if (download.status == Download.QUEUE) { + val chapter = chapters.find { it.chapter.id == download.chapter.id } + if (chapter != null && chapter.download == null) { + chapter.download = download + } + } + } + /** * Mark selected chapter as read - * @param chapters list of selected chapters + * + * @param items list of selected chapters * @param read read status */ - fun markChapterRead(chapters: List, read: Boolean) { - Observable.from(chapters) - .doOnNext { chapter -> - chapter.read = read - if (!read) { - chapter.last_page_read = 0 - } - } - .toList() - .flatMap { db.updateChaptersProgress(it).asRxObservable() } + fun markChapterRead(items: List, read: Boolean) { + val chapters = items.map { it.chapter } + chapters.forEach { + it.read = read + if (!read) { + it.last_page_read = 0 + } + } + + Observable.fromCallable { db.updateChaptersProgress(chapters).executeAsBlocking() } .subscribeOn(Schedulers.io()) .subscribe() } /** * Delete selected chapters + * * @param chapters list of chapters */ - fun deleteChapters(chapters: List) { + fun deleteChapters(chapters: List) { Observable.from(chapters) .doOnNext { deleteChapter(it) } .toList() @@ -217,42 +209,29 @@ class RecentChaptersPresenter : BasePresenter() { .observeOn(AndroidSchedulers.mainThread()) .subscribeFirst({ view, result -> view.onChaptersDeleted() - }, { view, error -> - view.onChaptersDeletedError(error) - }) + }, RecentChaptersFragment::onChaptersDeletedError) } /** * Download selected chapters - * @param chapters list of recent chapters seleted. + * @param items list of recent chapters seleted. */ - fun downloadChapters(chapters: List) { + fun downloadChapters(items: List) { + items.forEach { downloadManager.downloadChapters(it.manga, listOf(it.chapter)) } DownloadService.start(context) - Observable.from(chapters) - .doOnNext { downloadChapter(it) } - .subscribeOn(AndroidSchedulers.mainThread()) - .subscribe() - } - - /** - * Download selected chapter - * @param chapter chapter that is selected - */ - fun downloadChapter(chapter: RecentChapter) { - DownloadService.start(context) - downloadManager.downloadChapters(chapter.manga, listOf(chapter)) } /** * Delete selected chapter - * @param chapter chapter that is selected + * + * @param item chapter that is selected */ - private fun deleteChapter(chapter: RecentChapter) { - val source = sourceManager.get(chapter.manga.source) ?: return - downloadManager.queue.remove(chapter) - downloadManager.deleteChapter(source, chapter.manga, chapter) - chapter.status = Download.NOT_DOWNLOADED - chapter.download = null + private fun deleteChapter(item: RecentChapterItem) { + val source = sourceManager.get(item.manga.source) ?: return + downloadManager.queue.remove(item.chapter) + downloadManager.deleteChapter(source, item.manga, item.chapter) + item.status = Download.NOT_DOWNLOADED + item.download = null } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/SectionViewHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/SectionViewHolder.kt deleted file mode 100644 index d1f5e9cea..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/SectionViewHolder.kt +++ /dev/null @@ -1,25 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import android.support.v7.widget.RecyclerView -import android.text.format.DateUtils -import android.text.format.DateUtils.DAY_IN_MILLIS -import android.view.View -import kotlinx.android.synthetic.main.item_recent_chapter_section.view.* -import java.util.* - -class SectionViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { - - /** - * Current date - */ - private val now = Date().time - - /** - * Set value of section header - * - * @param date of section header - */ - fun onSetValues(date: Date) { - view.section_text.text = DateUtils.getRelativeTimeSpanString(date.time, now, DAY_IN_MILLIS) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt index 5faec1f03..e95b457ef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt @@ -1,10 +1,10 @@ package eu.kanade.tachiyomi.ui.recently_read import android.view.ViewGroup -import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter4.FlexibleAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory -import eu.kanade.tachiyomi.data.source.SourceManager +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.inflate import uy.kohesive.injekt.injectLazy diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt index 9aa7c79de..e63723fd0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt @@ -7,7 +7,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory -import kotlinx.android.synthetic.main.dialog_remove_recently.view.* +import eu.kanade.tachiyomi.widget.DialogCheckboxView import kotlinx.android.synthetic.main.item_recently_read.view.* import java.text.DateFormat import java.text.DecimalFormat @@ -24,7 +24,7 @@ import java.util.* * @constructor creates a new recent chapter holder. */ class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter) -: RecyclerView.ViewHolder(view) { + : RecyclerView.ViewHolder(view) { /** * DecimalFormat used to display correct chapter number @@ -50,7 +50,7 @@ class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter) // Set source + chapter title val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble()) itemView.manga_source.text = itemView.context.getString(R.string.recent_manga_source) - .format(adapter.sourceManager.get(manga.source)?.name, formattedNumber) + .format(adapter.sourceManager.get(manga.source)?.toString(), formattedNumber) // Set last read timestamp title itemView.last_read.text = df.format(Date(history.last_read)) @@ -66,14 +66,19 @@ class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter) // Set remove clickListener itemView.remove.setOnClickListener { + // Create custom view + val dialogCheckboxView = DialogCheckboxView(itemView.context).apply { + setDescription(R.string.dialog_with_checkbox_remove_description) + setOptionDescription(R.string.dialog_with_checkbox_reset) + } MaterialDialog.Builder(itemView.context) .title(R.string.action_remove) - .customView(R.layout.dialog_remove_recently, true) + .customView(dialogCheckboxView, true) .positiveText(R.string.action_remove) .negativeText(android.R.string.cancel) .onPositive { materialDialog, dialogAction -> // Check if user wants all chapters reset - if (materialDialog.customView?.removeAll?.isChecked as Boolean) { + if (dialogCheckboxView.isChecked()) { adapter.fragment.removeAllFromHistory(manga.id!!) } else { adapter.fragment.removeFromHistory(history) @@ -81,8 +86,7 @@ class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter) } .onNegative { materialDialog, dialogAction -> materialDialog.dismiss() - } - .show() + }.show() } // Set continue reading clickListener diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt index e23b53a5f..e96484813 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt @@ -28,6 +28,7 @@ class SettingsActivity : BaseActivity(), override fun onCreate(savedState: Bundle?) { setAppTheme() super.onCreate(savedState) + setTitle(R.string.label_settings) setContentView(R.layout.activity_preferences) replaceFragmentStrategy = ReplaceFragment(this, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt index f35ff2ceb..35bb43079 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt @@ -9,7 +9,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.util.plusAssign import eu.kanade.tachiyomi.util.toast import rx.Observable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSourcesFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSourcesFragment.kt index 4e25168eb..6f9349a5d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSourcesFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSourcesFragment.kt @@ -7,8 +7,8 @@ import android.support.v7.preference.XpPreferenceFragment import android.view.View import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.source.SourceManager -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.widget.preference.LoginCheckBoxPreference import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog import eu.kanade.tachiyomi.widget.preference.SwitchPreferenceCategory @@ -87,7 +87,7 @@ class SettingsSourcesFragment : SettingsFragment() { * * @param group the language category. */ - private fun addLanguageSources(group: SwitchPreferenceCategory, sources: List) { + private fun addLanguageSources(group: SwitchPreferenceCategory, sources: List) { val hiddenCatalogues = preferences.hiddenCatalogues().getOrDefault() sources.forEach { source -> @@ -123,13 +123,14 @@ class SettingsSourcesFragment : SettingsFragment() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == SOURCE_CHANGE_REQUEST) { - val pref = findPreference(getSourceKey(resultCode)) as? LoginCheckBoxPreference + if (requestCode == SOURCE_CHANGE_REQUEST && data != null) { + val sourceId = data.getLongExtra("key", -1L) + val pref = findPreference(getSourceKey(sourceId)) as? LoginCheckBoxPreference pref?.notifyChanged() } } - private fun getSourceKey(sourceId: Int): String { + private fun getSourceKey(sourceId: Long): String { return "source_$sourceId" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingFragment.kt index d6948d7c6..922d83958 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingFragment.kt @@ -41,7 +41,7 @@ class SettingsTrackingFragment : SettingsFragment() { registerService(trackManager.aniList) { val intent = CustomTabsIntent.Builder() - .setToolbarColor(activity.theme.getResourceColor(R.attr.colorPrimary)) + .setToolbarColor(activity.getResourceColor(R.attr.colorPrimary)) .build() intent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) intent.launchUrl(activity, AnilistApi.authUrl()) @@ -81,8 +81,9 @@ class SettingsTrackingFragment : SettingsFragment() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == SYNC_CHANGE_REQUEST) { - updatePreference(resultCode) + if (requestCode == SYNC_CHANGE_REQUEST && data != null) { + val serviceId = data.getIntExtra("key", -1) + updatePreference(serviceId) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt index ff0a2c359..16df65b59 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.util -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga /** * -R> = regex conversion. @@ -23,7 +23,7 @@ object ChapterRecognition { * Regex used when manga title removed * Example: Solanin 028 Vol. 2 -> 028 Vol.2 -> 028Vol.2 -R> 028 */ - private val withoutMange = Regex("""^([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""") + private val withoutManga = Regex("""^([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""") /** * Regex used to remove unwanted tags @@ -37,7 +37,7 @@ object ChapterRecognition { */ private val unwantedWhiteSpace = Regex("""(\s)(extra|special|omake)""") - fun parseChapterNumber(chapter: Chapter, manga: Manga) { + fun parseChapterNumber(chapter: SChapter, manga: SManga) { // If chapter number is known return. if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) return @@ -77,7 +77,7 @@ object ChapterRecognition { val nameWithoutManga = name.replace(manga.title.toLowerCase(), "").trim() // Check if first value is number after title remove. - if (updateChapter(withoutMange.find(nameWithoutManga), chapter)) + if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) return // Take the first number encountered. @@ -91,7 +91,7 @@ object ChapterRecognition { * @param chapter chapter object * @return true if volume is found */ - fun updateChapter(match: MatchResult?, chapter: Chapter): Boolean { + fun updateChapter(match: MatchResult?, chapter: SChapter): Boolean { match?.let { val initial = it.groups[1]?.value?.toFloat()!! val subChapterDecimal = it.groups[2]?.value @@ -123,7 +123,7 @@ object ChapterRecognition { if (alpha.contains("special")) return .97f - if (alpha[0].equals('.') ) { + if (alpha[0] == '.') { // Take value after (.) return parseAlphaPostFix(alpha[1]) } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt index 672a94691..7a705fe66 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt @@ -3,31 +3,38 @@ package eu.kanade.tachiyomi.util 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.source.Source -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.online.HttpSource import java.util.* /** * Helper method for syncing the list of chapters from the source with the ones from the database. * * @param db the database. - * @param sourceChapters a list of chapters from the source. + * @param rawSourceChapters a list of chapters from the source. * @param manga the manga of the chapters. * @param source the source of the chapters. * @return a pair of new insertions and deletions. */ fun syncChaptersWithSource(db: DatabaseHelper, - sourceChapters: List, + rawSourceChapters: List, manga: Manga, source: Source) : Pair, List> { + if (rawSourceChapters.isEmpty()) { + throw Exception("No chapters found") + } + // Chapters from db. val dbChapters = db.getChapters(manga).executeAsBlocking() - // Fix manga id and order in source. - sourceChapters.forEachIndexed { i, chapter -> - chapter.manga_id = manga.id - chapter.source_order = i + val sourceChapters = rawSourceChapters.mapIndexed { i, sChapter -> + Chapter.create().apply { + copyFrom(sChapter) + manga_id = manga.id + source_order = i + } } // Chapters from the source not in db. @@ -35,7 +42,7 @@ fun syncChaptersWithSource(db: DatabaseHelper, // Recognize number for new chapters. toAdd.forEach { - if (source is OnlineSource) { + if (source is HttpSource) { source.prepareNewChapter(it, manga) } ChapterRecognition.parseChapterNumber(it, manga) @@ -44,6 +51,11 @@ fun syncChaptersWithSource(db: DatabaseHelper, // Chapters from the db not in the source. val toDelete = dbChapters.filterNot { it in sourceChapters } + // Return if there's nothing to add or delete, avoiding unnecessary db transactions. + if (toAdd.isEmpty() && toDelete.isEmpty()) { + return Pair(emptyList(), emptyList()) + } + val readded = mutableListOf() db.inTransaction { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt index f07a0533c..7fd84a3d8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt @@ -2,17 +2,23 @@ package eu.kanade.tachiyomi.util import android.app.Notification import android.app.NotificationManager +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager +import android.content.res.Resources import android.net.ConnectivityManager import android.os.PowerManager import android.support.annotation.StringRes import android.support.v4.app.NotificationCompat import android.support.v4.content.ContextCompat +import android.support.v4.content.LocalBroadcastManager import android.widget.Toast /** * Display a toast in this context. + * * @param resource the text resource. * @param duration the duration of the toast. Defaults to short. */ @@ -22,6 +28,7 @@ fun Context.toast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT) /** * Display a toast in this context. + * * @param text the text to display. * @param duration the duration of the toast. Defaults to short. */ @@ -31,6 +38,7 @@ fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT) { /** * Helper method to create a notification. + * * @param func the function that will execute inside the builder. * @return a notification to be displayed or updated. */ @@ -42,12 +50,37 @@ inline fun Context.notification(func: NotificationCompat.Builder.() -> Unit): No /** * Checks if the give permission is granted. + * * @param permission the permission to check. * @return true if it has permissions. */ fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +/** + * Returns the color for the given attribute. + * + * @param resource the attribute. + */ +fun Context.getResourceColor(@StringRes resource: Int): Int { + val typedArray = obtainStyledAttributes(intArrayOf(resource)) + val attrValue = typedArray.getColor(0, 0) + typedArray.recycle() + return attrValue +} + +/** + * Converts to dp. + */ +val Int.pxToDp: Int + get() = (this / Resources.getSystem().displayMetrics.density).toInt() + +/** + * Converts to px. + */ +val Int.dpToPx: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() + /** * Property to get the notification manager from the context. */ @@ -64,4 +97,42 @@ val Context.connectivityManager: ConnectivityManager * Property to get the power manager from the context. */ val Context.powerManager: PowerManager - get() = getSystemService(Context.POWER_SERVICE) as PowerManager \ No newline at end of file + get() = getSystemService(Context.POWER_SERVICE) as PowerManager + +/** + * Function used to send a local broadcast asynchronous + * + * @param intent intent that contains broadcast information + */ +fun Context.sendLocalBroadcast(intent:Intent){ + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) +} + +/** + * Function used to send a local broadcast synchronous + * + * @param intent intent that contains broadcast information + */ +fun Context.sendLocalBroadcastSync(intent: Intent) { + LocalBroadcastManager.getInstance(this).sendBroadcastSync(intent) +} + +/** + * Function used to register local broadcast + * + * @param receiver receiver that gets registered. + */ +fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFilter ){ + LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter) +} + +/** + * Function used to unregister local broadcast + * + * @param receiver receiver that gets unregistered. + */ +fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver){ + LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) +} + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DeviceUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DeviceUtil.kt deleted file mode 100644 index e7dc9e559..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/DeviceUtil.kt +++ /dev/null @@ -1,24 +0,0 @@ -package eu.kanade.tachiyomi.util - -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.os.BatteryManager - -object DeviceUtil { - fun isPowerConnected(context: Context): Boolean { - val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) - intent?.let { - val plugged = it.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) - return plugged == BatteryManager.BATTERY_PLUGGED_AC || plugged == BatteryManager.BATTERY_PLUGGED_USB || plugged == BatteryManager.BATTERY_PLUGGED_WIRELESS - } - return false - } - - fun isNetworkConnected(context: Context): Boolean { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetwork = cm.activeNetworkInfo - return activeNetwork != null && activeNetwork.isConnectedOrConnecting - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt index fa775183d..cf289506b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt @@ -1,11 +1,50 @@ package eu.kanade.tachiyomi.util +import android.content.Context +import android.os.Environment +import android.support.v4.content.ContextCompat +import android.support.v4.os.EnvironmentCompat import java.io.File +import java.io.InputStream +import java.net.URLConnection import java.security.MessageDigest import java.security.NoSuchAlgorithmException object DiskUtil { + fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean { + val contentType = URLConnection.guessContentTypeFromName(name) + ?: openStream?.let { findImageMime(it) } + + return contentType?.startsWith("image/") ?: false + } + + fun findImageMime(openStream: () -> InputStream): String? { + try { + openStream().buffered().use { + val bytes = ByteArray(8) + it.mark(bytes.size) + val length = it.read(bytes, 0, bytes.size) + it.reset() + if (length == -1) + return null + if (bytes[0] == 'G'.toByte() && bytes[1] == 'I'.toByte() && bytes[2] == 'F'.toByte() && bytes[3] == '8'.toByte()) { + return "image/gif" + } else if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte() + && bytes[3] == 0x47.toByte() && bytes[4] == 0x0D.toByte() && bytes[5] == 0x0A.toByte() + && bytes[6] == 0x1A.toByte() && bytes[7] == 0x0A.toByte()) { + return "image/png" + } else if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) { + return "image/jpeg" + } else if (bytes[0] == 'W'.toByte() && bytes[1] == 'E'.toByte() && bytes[2] == 'B'.toByte() && bytes[3] == 'P'.toByte()) { + return "image/webp" + } + } + } catch(e: Exception) { + } + return null + } + fun hashKeyForDisk(key: String): String { return try { val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) @@ -31,9 +70,26 @@ object DiskUtil { return size } + /** + * Returns the root folders of all the available external storages. + */ + fun getExternalStorages(context: Context): List { + return ContextCompat.getExternalFilesDirs(context, null) + .filterNotNull() + .mapNotNull { + val file = File(it.absolutePath.substringBefore("/Android/")) + val state = EnvironmentCompat.getStorageState(file) + if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) { + file + } else { + null + } + } + } + /** * Mutate the given filename to make it valid for a FAT filesystem, - * replacing any invalid characters with "_". This method doesn't allow private files (starting + * 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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt new file mode 100644 index 000000000..7b208c608 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.util + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.support.v4.content.FileProvider +import eu.kanade.tachiyomi.BuildConfig +import java.io.File + +/** + * Returns the uri of a file + * + * @param context context of application + */ +fun File.getUriCompat(context: Context): Uri { + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this) + else Uri.fromFile(this) + return uri +} + +/** + * Deletes file if exists + * + * @return success of file deletion + */ +fun File.deleteIfExists(): Boolean { + if (this.exists()) { + this.delete() + return true + } + return false +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ImageViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ImageViewExtensions.kt index 64bba887a..0419476da 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ImageViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ImageViewExtensions.kt @@ -12,6 +12,7 @@ import android.widget.ImageView fun ImageView.setVectorCompat(@DrawableRes drawable: Int, tint: Int? = null) { val vector = VectorDrawableCompat.create(resources, drawable, context.theme) if (tint != null) { + vector?.mutate() vector?.setTint(tint) } setImageDrawable(vector) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/LocaleHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/util/LocaleHelper.kt index b3dd5fb41..df9c038ea 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/LocaleHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/LocaleHelper.kt @@ -78,8 +78,7 @@ object LocaleHelper { if (systemLocale == null) { systemLocale = getConfigLocale(config) } - // In API 16 and lower [systemLocale] can't be changed. - if (configChange && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + if (configChange) { val configLocale = getConfigLocale(config) if (currentLocale == configLocale) { return diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt new file mode 100644 index 000000000..12ad9706f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt @@ -0,0 +1,73 @@ +package eu.kanade.tachiyomi.util + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.res.AssetFileDescriptor +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import eu.kanade.tachiyomi.BuildConfig +import junrar.Archive +import java.io.File +import java.io.IOException +import java.net.URLConnection +import java.util.concurrent.Executors + +class RarContentProvider : ContentProvider() { + + private val pool by lazy { Executors.newCachedThreadPool() } + + companion object { + const val PROVIDER = "${BuildConfig.APPLICATION_ID}.rar-provider" + } + + override fun onCreate(): Boolean { + return true + } + + override fun getType(uri: Uri): String? { + return URLConnection.guessContentTypeFromName(uri.toString()) + } + + override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? { + try { + val pipe = ParcelFileDescriptor.createPipe() + pool.execute { + try { + val (rar, file) = uri.toString() + .substringAfter("content://$PROVIDER") + .split("!-/", limit = 2) + + Archive(File(rar)).use { archive -> + val fileHeader = archive.fileHeaders.first { it.fileNameString == file } + + ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]).use { output -> + archive.extractFile(fileHeader, output) + } + } + } catch (e: Exception) { + // Ignore + } + } + return AssetFileDescriptor(pipe[0], 0, -1) + } catch (e: IOException) { + return null + } + } + + override fun query(p0: Uri?, p1: Array?, p2: String?, p3: Array?, p4: String?): Cursor? { + return null + } + + override fun insert(p0: Uri?, p1: ContentValues?): Uri { + throw UnsupportedOperationException("not implemented") + } + + override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array?): Int { + throw UnsupportedOperationException("not implemented") + } + + override fun delete(p0: Uri?, p1: String?, p2: Array?): Int { + throw UnsupportedOperationException("not implemented") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ThemeExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ThemeExtensions.kt deleted file mode 100644 index da0f90282..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ThemeExtensions.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.util - -import android.content.res.Resources -import android.graphics.drawable.Drawable -import android.support.annotation.AttrRes -import android.support.annotation.StringRes - -fun Resources.Theme.getResourceColor(@StringRes resource: Int): Int { - val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val attrValue = typedArray.getColor(0, 0) - typedArray.recycle() - return attrValue -} - -fun Resources.Theme.getResourceDrawable(@StringRes resource: Int): Drawable { - val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val attrValue = typedArray.getDrawable(0) - typedArray.recycle() - return attrValue -} - -fun Resources.Theme.getResourceId(@AttrRes resource: Int, fallback: Int): Int { - val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val attrValue = typedArray.getResourceId(0, fallback) - typedArray.recycle() - return attrValue -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/UrlUtil.java b/app/src/main/java/eu/kanade/tachiyomi/util/UrlUtil.java deleted file mode 100644 index 5918013c6..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/UrlUtil.java +++ /dev/null @@ -1,26 +0,0 @@ -package eu.kanade.tachiyomi.util; - -import java.net.URI; -import java.net.URISyntaxException; - -public final class UrlUtil { - - private UrlUtil() throws InstantiationException { - throw new InstantiationException("This class is not for instantiation"); - } - - public static String getPath(String s) { - try { - URI uri = new URI(s); - String out = uri.getPath(); - if (uri.getQuery() != null) - out += "?" + uri.getQuery(); - if (uri.getFragment() != null) - out += "#" + uri.getFragment(); - return out; - } catch (URISyntaxException e) { - return s; - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ViewExtensions.kt index 3fab969ea..912f05e80 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ViewExtensions.kt @@ -1,3 +1,5 @@ +@file:Suppress("NOTHING_TO_INLINE") + package eu.kanade.tachiyomi.util import android.graphics.Color @@ -21,10 +23,23 @@ fun View.getCoordinates() = Point((left + right) / 2, (top + bottom) / 2) * @param length the duration of the snack. * @param f a function to execute in the snack, allowing for example to define a custom action. */ -inline fun View.snack(message: String, length: Int = Snackbar.LENGTH_LONG, f: Snackbar.() -> Unit) { +inline fun View.snack(message: String, length: Int = Snackbar.LENGTH_LONG, f: Snackbar.() -> Unit): Snackbar { val snack = Snackbar.make(this, message, length) val textView = snack.view.findViewById(android.support.design.R.id.snackbar_text) as TextView textView.setTextColor(Color.WHITE) snack.f() snack.show() -} \ No newline at end of file + return snack +} + +inline fun View.visible() { + visibility = View.VISIBLE +} + +inline fun View.invisible() { + visibility = View.INVISIBLE +} + +inline fun View.gone() { + visibility = View.GONE +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt new file mode 100644 index 000000000..737007720 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt @@ -0,0 +1,69 @@ +package eu.kanade.tachiyomi.util + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.res.AssetFileDescriptor +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import eu.kanade.tachiyomi.BuildConfig +import java.io.IOException +import java.net.URL +import java.net.URLConnection +import java.util.concurrent.Executors + +class ZipContentProvider : ContentProvider() { + + private val pool by lazy { Executors.newCachedThreadPool() } + + companion object { + const val PROVIDER = "${BuildConfig.APPLICATION_ID}.zip-provider" + } + + override fun onCreate(): Boolean { + return true + } + + override fun getType(uri: Uri): String? { + return URLConnection.guessContentTypeFromName(uri.toString()) + } + + override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? { + try { + val url = "jar:file://" + uri.toString().substringAfter("content://$PROVIDER") + val input = URL(url).openStream() + val pipe = ParcelFileDescriptor.createPipe() + pool.execute { + try { + val output = ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]) + input.use { + output.use { + input.copyTo(output) + } + } + } catch (e: IOException) { + // Ignore + } + } + return AssetFileDescriptor(pipe[0], 0, -1) + } catch (e: IOException) { + return null + } + } + + override fun query(p0: Uri?, p1: Array?, p2: String?, p3: Array?, p4: String?): Cursor? { + return null + } + + override fun insert(p0: Uri?, p1: ContentValues?): Uri { + throw UnsupportedOperationException("not implemented") + } + + override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array?): Int { + throw UnsupportedOperationException("not implemented") + } + + override fun delete(p0: Uri?, p1: String?, p2: Array?): Int { + throw UnsupportedOperationException("not implemented") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt new file mode 100644 index 000000000..e84826004 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.widget + +import android.content.Context +import android.support.annotation.StringRes +import android.util.AttributeSet +import android.widget.LinearLayout +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.inflate +import kotlinx.android.synthetic.main.dialog_with_checkbox.view.* + +class DialogCheckboxView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + LinearLayout(context, attrs) { + + init { + addView(inflate(R.layout.dialog_with_checkbox)) + } + + fun setDescription(@StringRes id: Int){ + description.text = context.getString(id) + } + + fun setOptionDescription(@StringRes id: Int){ + checkbox_option.text = context.getString(id) + } + + fun isChecked(): Boolean { + return checkbox_option.isChecked + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt new file mode 100644 index 000000000..20c33a4fc --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.widget + +import android.animation.ObjectAnimator +import android.animation.StateListAnimator +import android.content.Context +import android.os.Build +import android.support.design.R +import android.support.design.widget.AppBarLayout +import android.util.AttributeSet + +class ElevationAppBarLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppBarLayout(context, attrs) { + + private var origStateAnimator: StateListAnimator? = null + + init { + if (Build.VERSION.SDK_INT >= 21) { + origStateAnimator = stateListAnimator + } + } + + fun enableElevation() { + if (Build.VERSION.SDK_INT >= 21) { + stateListAnimator = origStateAnimator + } + } + + fun disableElevation() { + if (Build.VERSION.SDK_INT >= 21) { + stateListAnimator = StateListAnimator().apply { + val objAnimator = ObjectAnimator.ofFloat(this, "elevation", 0f) + + // Enabled and collapsible, but not collapsed means not elevated + addState(intArrayOf(android.R.attr.enabled, R.attr.state_collapsible, -R.attr.state_collapsed), + objAnimator) + + // Default enabled state + addState(intArrayOf(android.R.attr.enabled), objAnimator) + + // Disabled state + addState(IntArray(0), objAnimator) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt index 0a3e8eb23..7950ff0f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt @@ -29,7 +29,7 @@ class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? * @param textResource text of information view */ fun show(drawable: Int, textResource: Int) { - image_view.setVectorCompat(drawable, context.theme.getResourceColor(android.R.attr.textColorHint)) + image_view.setVectorCompat(drawable, context.getResourceColor(android.R.attr.textColorHint)) text_label.text = context.getString(textResource) this.visibility = View.VISIBLE } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/EndlessScrollListener.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/EndlessScrollListener.kt deleted file mode 100644 index 55e81c6d3..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/EndlessScrollListener.kt +++ /dev/null @@ -1,46 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.support.v7.widget.LinearLayoutManager -import android.support.v7.widget.RecyclerView - -class EndlessScrollListener( - private val layoutManager: LinearLayoutManager, - private val requestNext: () -> Unit) -: RecyclerView.OnScrollListener() { - - companion object { - // The minimum amount of items to have below your current scroll position before loading - // more. - private val VISIBLE_THRESHOLD = 5 - } - - private var previousTotal = 0 // The total number of items in the dataset after the last load - private var loading = true // True if we are still waiting for the last set of data to load. - private var firstVisibleItem = 0 - private var visibleItemCount = 0 - private var totalItemCount = 0 - - fun resetScroll() { - previousTotal = 0 - loading = true - } - - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - - visibleItemCount = recyclerView.childCount - totalItemCount = layoutManager.itemCount - firstVisibleItem = layoutManager.findFirstVisibleItemPosition() - - if (loading && totalItemCount > previousTotal) { - loading = false - previousTotal = totalItemCount - } - if (!loading && totalItemCount - visibleItemCount <= firstVisibleItem + VISIBLE_THRESHOLD) { - // End has been reached - requestNext() - loading = true - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt index 16ce78595..baa26946c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt @@ -1,88 +1,27 @@ package eu.kanade.tachiyomi.widget -import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable import android.support.annotation.CallSuper -import android.support.design.R -import android.support.design.internal.ScrimInsetsFrameLayout import android.support.graphics.drawable.VectorDrawableCompat import android.support.v4.content.ContextCompat -import android.support.v4.view.ViewCompat -import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView -import android.support.v7.widget.TintTypedArray import android.util.AttributeSet import android.view.View import android.view.ViewGroup -import android.widget.CheckBox -import android.widget.CheckedTextView -import android.widget.RadioButton import android.widget.TextView +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.util.getResourceColor -import eu.kanade.tachiyomi.util.inflate -import eu.kanade.tachiyomi.R as TR /** * An alternative implementation of [android.support.design.widget.NavigationView], without menu * inflation and allowing customizable items (multiple selections, custom views, etc). */ -@Suppress("LeakingThis") -@SuppressLint("PrivateResource") open class ExtendedNavigationView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) -: ScrimInsetsFrameLayout(context, attrs, defStyleAttr) { - - /** - * Max width of the navigation view. - */ - private var maxWidth: Int - - /** - * Recycler view containing all the items. - */ - protected val recycler = RecyclerView(context) - - init { - // Custom attributes - val a = TintTypedArray.obtainStyledAttributes(context, attrs, - R.styleable.NavigationView, defStyleAttr, - R.style.Widget_Design_NavigationView) - - ViewCompat.setBackground( - this, a.getDrawable(R.styleable.NavigationView_android_background)) - - if (a.hasValue(R.styleable.NavigationView_elevation)) { - ViewCompat.setElevation(this, a.getDimensionPixelSize( - R.styleable.NavigationView_elevation, 0).toFloat()) - } - - ViewCompat.setFitsSystemWindows(this, - a.getBoolean(R.styleable.NavigationView_android_fitsSystemWindows, false)) - - maxWidth = a.getDimensionPixelSize(R.styleable.NavigationView_android_maxWidth, 0) - - a.recycle() - - recycler.layoutManager = LinearLayoutManager(context) - addView(recycler) - } - - /** - * Overriden to measure the width of the navigation view. - */ - override fun onMeasure(widthSpec: Int, heightSpec: Int) { - val width = when (MeasureSpec.getMode(widthSpec)) { - MeasureSpec.AT_MOST -> MeasureSpec.makeMeasureSpec( - Math.min(MeasureSpec.getSize(widthSpec), maxWidth), MeasureSpec.EXACTLY) - MeasureSpec.UNSPECIFIED -> MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY) - else -> widthSpec - } - // Let super sort out the height - super.onMeasure(width, heightSpec) - } +: SimpleNavigationView(context, attrs, defStyleAttr) { /** * Every item of the nav view. Generic items must belong to this list, custom items could be @@ -136,7 +75,7 @@ open class ExtendedNavigationView @JvmOverloads constructor( */ fun tintVector(context: Context, resId: Int): Drawable { return VectorDrawableCompat.create(context.resources, resId, context.theme)!!.apply { - setTint(context.theme.getResourceColor(TR.attr.colorAccent)) + setTint(context.getResourceColor(R.attr.colorAccent)) } } } @@ -161,9 +100,9 @@ open class ExtendedNavigationView @JvmOverloads constructor( override fun getStateDrawable(context: Context): Drawable? { return when (state) { - SORT_ASC -> tintVector(context, TR.drawable.ic_keyboard_arrow_up_black_32dp) - SORT_DESC -> tintVector(context, TR.drawable.ic_keyboard_arrow_down_black_32dp) - SORT_NONE -> ContextCompat.getDrawable(context, TR.drawable.empty_drawable_32dp) + SORT_ASC -> tintVector(context, R.drawable.ic_keyboard_arrow_up_black_32dp) + SORT_DESC -> tintVector(context, R.drawable.ic_keyboard_arrow_down_black_32dp) + SORT_NONE -> ContextCompat.getDrawable(context, R.drawable.empty_drawable_32dp) else -> null } } @@ -218,59 +157,6 @@ open class ExtendedNavigationView @JvmOverloads constructor( } - /** - * Base view holder. - */ - abstract class Holder(view: View) : RecyclerView.ViewHolder(view) - - /** - * Separator view holder. - */ - class SeparatorHolder(parent: ViewGroup) - : Holder(parent.inflate(R.layout.design_navigation_item_separator)) - - /** - * Header view holder. - */ - class HeaderHolder(parent: ViewGroup) - : Holder(parent.inflate(R.layout.design_navigation_item_subheader)) - - /** - * Clickable view holder. - */ - abstract class ClickableHolder(view: View, listener: View.OnClickListener?) : Holder(view) { - init { - itemView.setOnClickListener(listener) - } - } - - /** - * Radio view holder. - */ - class RadioHolder(parent: ViewGroup, listener: View.OnClickListener?) - : ClickableHolder(parent.inflate(TR.layout.navigation_view_radio), listener) { - - val radio = itemView.findViewById(TR.id.nav_view_item) as RadioButton - } - - /** - * Checkbox view holder. - */ - class CheckboxHolder(parent: ViewGroup, listener: View.OnClickListener?) - : ClickableHolder(parent.inflate(TR.layout.navigation_view_checkbox), listener) { - - val check = itemView.findViewById(TR.id.nav_view_item) as CheckBox - } - - /** - * Multi state view holder. - */ - class MultiStateHolder(parent: ViewGroup, listener: View.OnClickListener?) - : ClickableHolder(parent.inflate(TR.layout.navigation_view_checkedtext), listener) { - - val text = itemView.findViewById(TR.id.nav_view_item) as CheckedTextView - } - /** * Base adapter for the navigation view. It knows how to create and render every subclass of * [Item]. @@ -352,12 +238,4 @@ open class ExtendedNavigationView @JvmOverloads constructor( } - companion object { - private const val VIEW_TYPE_HEADER = 100 - private const val VIEW_TYPE_SEPARATOR = 101 - private const val VIEW_TYPE_RADIO = 102 - private const val VIEW_TYPE_CHECKBOX = 103 - private const val VIEW_TYPE_MULTISTATE = 104 - } - } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationBase.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationBase.kt index cfffeb599..7a847f187 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationBase.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationBase.kt @@ -5,7 +5,7 @@ import android.support.design.widget.FloatingActionButton import android.support.v4.view.ViewCompat import android.view.View -abstract class FABAnimationBase() : FloatingActionButton.Behavior() { +abstract class FABAnimationBase : FloatingActionButton.Behavior() { var isAnimatingOut = false diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationUpDown.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationUpDown.kt index 3fa25c4d0..ea9e0f89e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationUpDown.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationUpDown.kt @@ -34,7 +34,7 @@ class FABAnimationUpDown @JvmOverloads constructor(ctx: Context, attrs: Attribut override fun onAnimationEnd(animation: Animation) { isAnimatingOut = false - button.visibility = View.GONE + button.visibility = View.INVISIBLE } override fun onAnimationRepeat(animation: Animation) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt index 44c9ee150..807435b4e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.widget import android.content.Context +import android.os.Parcelable import android.util.AttributeSet import android.widget.SeekBar import eu.kanade.tachiyomi.R @@ -58,4 +59,11 @@ class NegativeSeekBar @JvmOverloads constructor(context: Context, attrs: Attribu this.listener = listener } + override fun onRestoreInstanceState(state: Parcelable?) { + // We can't restore the progress from the saved state because it gets shifted. + val origProgress = progress + super.onRestoreInstanceState(state) + super.setProgress(origProgress) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt new file mode 100644 index 000000000..40b1cfa01 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt @@ -0,0 +1,152 @@ +package eu.kanade.tachiyomi.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.support.design.R +import android.support.design.internal.ScrimInsetsFrameLayout +import android.support.design.widget.TextInputLayout +import android.support.v4.view.ViewCompat +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.support.v7.widget.TintTypedArray +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.* +import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.R as TR + +@Suppress("LeakingThis") +@SuppressLint("PrivateResource") +open class SimpleNavigationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0) +: ScrimInsetsFrameLayout(context, attrs, defStyleAttr) { + + /** + * Max width of the navigation view. + */ + private var maxWidth: Int + + /** + * Recycler view containing all the items. + */ + protected val recycler = RecyclerView(context) + + init { + // Custom attributes + val a = TintTypedArray.obtainStyledAttributes(context, attrs, + R.styleable.NavigationView, defStyleAttr, + R.style.Widget_Design_NavigationView) + + ViewCompat.setBackground( + this, a.getDrawable(R.styleable.NavigationView_android_background)) + + if (a.hasValue(R.styleable.NavigationView_elevation)) { + ViewCompat.setElevation(this, a.getDimensionPixelSize( + R.styleable.NavigationView_elevation, 0).toFloat()) + } + + ViewCompat.setFitsSystemWindows(this, + a.getBoolean(R.styleable.NavigationView_android_fitsSystemWindows, false)) + + maxWidth = a.getDimensionPixelSize(R.styleable.NavigationView_android_maxWidth, 0) + + a.recycle() + + recycler.layoutManager = LinearLayoutManager(context) + } + + /** + * Overriden to measure the width of the navigation view. + */ + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + val width = when (MeasureSpec.getMode(widthSpec)) { + MeasureSpec.AT_MOST -> MeasureSpec.makeMeasureSpec( + Math.min(MeasureSpec.getSize(widthSpec), maxWidth), MeasureSpec.EXACTLY) + MeasureSpec.UNSPECIFIED -> MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY) + else -> widthSpec + } + // Let super sort out the height + super.onMeasure(width, heightSpec) + } + + /** + * Base view holder. + */ + abstract class Holder(view: View) : RecyclerView.ViewHolder(view) + + /** + * Separator view holder. + */ + class SeparatorHolder(parent: ViewGroup) + : Holder(parent.inflate(R.layout.design_navigation_item_separator)) + + /** + * Header view holder. + */ + class HeaderHolder(parent: ViewGroup) + : Holder(parent.inflate(R.layout.design_navigation_item_subheader)) + + /** + * Clickable view holder. + */ + abstract class ClickableHolder(view: View, listener: View.OnClickListener?) : Holder(view) { + init { + itemView.setOnClickListener(listener) + } + } + + /** + * Radio view holder. + */ + class RadioHolder(parent: ViewGroup, listener: View.OnClickListener?) + : ClickableHolder(parent.inflate(TR.layout.navigation_view_radio), listener) { + + val radio = itemView.findViewById(TR.id.nav_view_item) as RadioButton + } + + /** + * Checkbox view holder. + */ + class CheckboxHolder(parent: ViewGroup, listener: View.OnClickListener?) + : ClickableHolder(parent.inflate(TR.layout.navigation_view_checkbox), listener) { + + val check = itemView.findViewById(TR.id.nav_view_item) as CheckBox + } + + /** + * Multi state view holder. + */ + class MultiStateHolder(parent: ViewGroup, listener: View.OnClickListener?) + : ClickableHolder(parent.inflate(TR.layout.navigation_view_checkedtext), listener) { + + val text = itemView.findViewById(TR.id.nav_view_item) as CheckedTextView + } + + class SpinnerHolder(parent: ViewGroup, listener: OnClickListener? = null) + : ClickableHolder(parent.inflate(TR.layout.navigation_view_spinner), listener) { + + val text = itemView.findViewById(TR.id.nav_view_item_text) as TextView + val spinner = itemView.findViewById(TR.id.nav_view_item) as Spinner + } + + class EditTextHolder(parent: ViewGroup) + : Holder(parent.inflate(TR.layout.navigation_view_text)) { + + val wrapper = itemView.findViewById(TR.id.nav_view_item_wrapper) as TextInputLayout + val edit = itemView.findViewById(TR.id.nav_view_item) as EditText + } + + protected companion object { + const val VIEW_TYPE_HEADER = 100 + const val VIEW_TYPE_SEPARATOR = 101 + const val VIEW_TYPE_RADIO = 102 + const val VIEW_TYPE_CHECKBOX = 103 + const val VIEW_TYPE_MULTISTATE = 104 + const val VIEW_TYPE_TEXT = 105 + const val VIEW_TYPE_LIST = 106 + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt new file mode 100644 index 000000000..af12756dd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.widget + +import android.graphics.drawable.Drawable +import android.support.graphics.drawable.VectorDrawableCompat +import android.view.View +import android.widget.ImageView +import android.widget.ImageView.ScaleType +import com.bumptech.glide.load.resource.drawable.GlideDrawable +import com.bumptech.glide.request.animation.GlideAnimation +import com.bumptech.glide.request.target.GlideDrawableImageViewTarget +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.gone +import eu.kanade.tachiyomi.util.visible + +/** + * A glide target to display an image with an optional view to show while loading and a configurable + * error drawable. + * + * @param view the view where the image will be loaded + * @param progress an optional view to show when the image is loading. + * @param errorDrawableRes the error drawable resource to show. + * @param errorScaleType the scale type for the error drawable, [ScaleType.CENTER] by default. + */ +class StateImageViewTarget(view: ImageView, + val progress: View? = null, + val errorDrawableRes: Int = R.drawable.ic_broken_image_grey_24dp, + val errorScaleType: ScaleType = ScaleType.CENTER) : + GlideDrawableImageViewTarget(view) { + + private val imageScaleType = view.scaleType + + override fun onLoadStarted(placeholder: Drawable?) { + progress?.visible() + super.onLoadStarted(placeholder) + } + + override fun onLoadFailed(e: Exception?, errorDrawable: Drawable?) { + progress?.gone() + view.scaleType = errorScaleType + + val vector = VectorDrawableCompat.create(view.context.resources, errorDrawableRes, null) + vector?.setTint(view.context.getResourceColor(android.R.attr.textColorSecondary)) + view.setImageDrawable(vector) + } + + override fun onLoadCleared(placeholder: Drawable?) { + progress?.gone() + super.onLoadCleared(placeholder) + } + + override fun onResourceReady(resource: GlideDrawable?, animation: GlideAnimation?) { + progress?.gone() + view.scaleType = imageScaleType + super.onResourceReady(resource, animation) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginCheckBoxPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginCheckBoxPreference.kt index 1d4ef5862..a905b9f59 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginCheckBoxPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginCheckBoxPreference.kt @@ -6,15 +6,16 @@ import android.support.v7.preference.PreferenceViewHolder import android.util.AttributeSet import android.view.View import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.online.LoginSource -import eu.kanade.tachiyomi.data.source.online.OnlineSource +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.source.online.LoginSource +import eu.kanade.tachiyomi.util.getResourceColor import eu.kanade.tachiyomi.util.setVectorCompat import kotlinx.android.synthetic.main.pref_item_source.view.* import net.xpece.android.support.preference.CheckBoxPreference class LoginCheckBoxPreference @JvmOverloads constructor( context: Context, - val source: OnlineSource, + val source: HttpSource, attrs: AttributeSet? = null ) : CheckBoxPreference(context, attrs) { @@ -31,7 +32,7 @@ class LoginCheckBoxPreference @JvmOverloads constructor( val tint = if (source.isLogged()) Color.argb(255, 76, 175, 80) else - Color.argb(97, 0, 0, 0) + context.getResourceColor(android.R.attr.textColorSecondary) holder.itemView.login.setVectorCompat(R.drawable.ic_account_circle_black_24dp, tint) diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt index 95301c45d..a83d9712b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.widget.preference +import android.app.Activity import android.app.Dialog import android.content.DialogInterface import android.content.Intent @@ -70,7 +71,8 @@ abstract class LoginDialogPreference : DialogFragment() { override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) - targetFragment?.onActivityResult(targetRequestCode, arguments.getInt("key"), Intent()) + val intent = Intent().putExtras(arguments) + targetFragment?.onActivityResult(targetRequestCode, Activity.RESULT_OK, intent) } protected abstract fun checkLogin() diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt index 7e65bb879..b197ba6a8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt @@ -3,9 +3,9 @@ package eu.kanade.tachiyomi.widget.preference import android.os.Bundle import android.view.View import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.source.Source -import eu.kanade.tachiyomi.data.source.SourceManager -import eu.kanade.tachiyomi.data.source.online.LoginSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.LoginSource import eu.kanade.tachiyomi.util.toast import kotlinx.android.synthetic.main.pref_account_login.view.* import rx.android.schedulers.AndroidSchedulers @@ -19,7 +19,7 @@ class SourceLoginDialog : LoginDialogPreference() { fun newInstance(source: Source): LoginDialogPreference { val fragment = SourceLoginDialog() val bundle = Bundle(1) - bundle.putInt("key", source.id) + bundle.putLong("key", source.id) fragment.arguments = bundle return fragment } @@ -32,7 +32,7 @@ class SourceLoginDialog : LoginDialogPreference() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val sourceId = arguments.getInt("key") + val sourceId = arguments.getLong("key") source = sourceManager.get(sourceId) as LoginSource } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt index e4f3d38b6..f07ccbc72 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt @@ -25,7 +25,7 @@ class SwitchPreferenceCategory @JvmOverloads constructor( CompoundButton.OnCheckedChangeListener { init { - setTitleTextColor(context.theme.getResourceColor(R.attr.colorAccent)) + setTitleTextColor(context.getResourceColor(R.attr.colorAccent)) } private var mChecked = false diff --git a/app/src/main/java/exh/EHSourceHelpers.kt b/app/src/main/java/exh/EHSourceHelpers.kt new file mode 100644 index 000000000..7f6e120b3 --- /dev/null +++ b/app/src/main/java/exh/EHSourceHelpers.kt @@ -0,0 +1,5 @@ +package exh + +/** + * Created by nulldev on 2/28/17. + */ diff --git a/app/src/main/java/exh/ui/migration/SourceMigrator.kt b/app/src/main/java/exh/ui/migration/SourceMigrator.kt new file mode 100644 index 000000000..afb75b6fb --- /dev/null +++ b/app/src/main/java/exh/ui/migration/SourceMigrator.kt @@ -0,0 +1,5 @@ +package exh.ui.migration + +/** + * Created by nulldev on 2/28/17. + */ diff --git a/app/src/main/res/drawable-hdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-hdpi/ic_av_pause_grey_24dp_img.png new file mode 100644 index 000000000..074bf7562 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_av_pause_grey_24dp_img.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-hdpi/ic_av_play_arrow_grey_img.png new file mode 100644 index 000000000..37a4ca1cd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_av_play_arrow_grey_img.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_warning_white_24dp_img.png b/app/src/main/res/drawable-hdpi/ic_warning_white_24dp_img.png deleted file mode 100644 index 55c68431b..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_warning_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/reader_background_checkbox_selected.png b/app/src/main/res/drawable-hdpi/reader_background_checkbox_selected.png deleted file mode 100644 index 9908433c0..000000000 Binary files a/app/src/main/res/drawable-hdpi/reader_background_checkbox_selected.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/reader_background_checkbox_unselected.png b/app/src/main/res/drawable-hdpi/reader_background_checkbox_unselected.png deleted file mode 100644 index 195e875fb..000000000 Binary files a/app/src/main/res/drawable-hdpi/reader_background_checkbox_unselected.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi/reader_background_checkbox_selected.png b/app/src/main/res/drawable-ldpi/reader_background_checkbox_selected.png deleted file mode 100644 index d06d87c95..000000000 Binary files a/app/src/main/res/drawable-ldpi/reader_background_checkbox_selected.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi/reader_background_checkbox_unselected.png b/app/src/main/res/drawable-ldpi/reader_background_checkbox_unselected.png deleted file mode 100644 index 4899a16ff..000000000 Binary files a/app/src/main/res/drawable-ldpi/reader_background_checkbox_unselected.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-mdpi/ic_av_pause_grey_24dp_img.png new file mode 100644 index 000000000..e13456677 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_av_pause_grey_24dp_img.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-mdpi/ic_av_play_arrow_grey_img.png new file mode 100644 index 000000000..1c2bd317c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_av_play_arrow_grey_img.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_warning_white_24dp_img.png b/app/src/main/res/drawable-mdpi/ic_warning_white_24dp_img.png deleted file mode 100644 index 04365b98a..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_warning_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/reader_background_checkbox_selected.png b/app/src/main/res/drawable-mdpi/reader_background_checkbox_selected.png deleted file mode 100644 index f0faf295f..000000000 Binary files a/app/src/main/res/drawable-mdpi/reader_background_checkbox_selected.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/reader_background_checkbox_unselected.png b/app/src/main/res/drawable-mdpi/reader_background_checkbox_unselected.png deleted file mode 100644 index 69e1ee734..000000000 Binary files a/app/src/main/res/drawable-mdpi/reader_background_checkbox_unselected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-xhdpi/ic_av_pause_grey_24dp_img.png new file mode 100644 index 000000000..c218aee56 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_av_pause_grey_24dp_img.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-xhdpi/ic_av_play_arrow_grey_img.png new file mode 100644 index 000000000..d5d467c42 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_av_play_arrow_grey_img.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_warning_white_24dp_img.png b/app/src/main/res/drawable-xhdpi/ic_warning_white_24dp_img.png deleted file mode 100644 index a43fa3c27..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_warning_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-xxhdpi/ic_av_pause_grey_24dp_img.png new file mode 100644 index 000000000..803a258ff Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_av_pause_grey_24dp_img.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-xxhdpi/ic_av_play_arrow_grey_img.png new file mode 100644 index 000000000..cf825e63e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_av_play_arrow_grey_img.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_warning_white_24dp_img.png b/app/src/main/res/drawable-xxhdpi/ic_warning_white_24dp_img.png deleted file mode 100644 index 807b9fa18..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_warning_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/reader_background_checkbox_selected.png b/app/src/main/res/drawable-xxhdpi/reader_background_checkbox_selected.png deleted file mode 100644 index 287892b71..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/reader_background_checkbox_selected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/reader_background_checkbox_unselected.png b/app/src/main/res/drawable-xxhdpi/reader_background_checkbox_unselected.png deleted file mode 100644 index b279adbbd..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/reader_background_checkbox_unselected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-xxxhdpi/ic_av_pause_grey_24dp_img.png new file mode 100644 index 000000000..fdf4261dc Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_av_pause_grey_24dp_img.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-xxxhdpi/ic_av_play_arrow_grey_img.png new file mode 100644 index 000000000..7e1eef6c2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_av_play_arrow_grey_img.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_warning_white_24dp_img.png b/app/src/main/res/drawable-xxxhdpi/ic_warning_white_24dp_img.png deleted file mode 100644 index 8683a2ea9..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_warning_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/reader_background_checkbox_selected.png b/app/src/main/res/drawable-xxxhdpi/reader_background_checkbox_selected.png deleted file mode 100644 index 90c1bd6f5..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/reader_background_checkbox_selected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/reader_background_checkbox_unselected.png b/app/src/main/res/drawable-xxxhdpi/reader_background_checkbox_unselected.png deleted file mode 100644 index 5a3af58d6..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/reader_background_checkbox_unselected.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_broken_image_grey_24dp.xml b/app/src/main/res/drawable/ic_broken_image_grey_24dp.xml new file mode 100644 index 000000000..35506713b --- /dev/null +++ b/app/src/main/res/drawable/ic_broken_image_grey_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_box_24dp.xml b/app/src/main/res/drawable/ic_check_box_24dp.xml new file mode 100644 index 000000000..9948171c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_box_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_clear_black_24dp.xml b/app/src/main/res/drawable/ic_check_box_outline_blank_24dp.xml similarity index 65% rename from app/src/main/res/drawable/ic_clear_black_24dp.xml rename to app/src/main/res/drawable/ic_check_box_outline_blank_24dp.xml index ede4b7108..cf8bfa24b 100644 --- a/app/src/main/res/drawable/ic_clear_black_24dp.xml +++ b/app/src/main/res/drawable/ic_check_box_outline_blank_24dp.xml @@ -5,5 +5,5 @@ android:viewportHeight="24.0"> + android:pathData="M19,5v14H5V5h14m0,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z"/> diff --git a/app/src/main/res/drawable/ic_check_box_x_24dp.xml b/app/src/main/res/drawable/ic_check_box_x_24dp.xml new file mode 100644 index 000000000..1b2c9be12 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_box_x_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_carousel_white_24dp.xml b/app/src/main/res/drawable/ic_chevron_right_white_24dp.xml similarity index 74% rename from app/src/main/res/drawable/ic_view_carousel_white_24dp.xml rename to app/src/main/res/drawable/ic_chevron_right_white_24dp.xml index c5d900821..36b411ace 100644 --- a/app/src/main/res/drawable/ic_view_carousel_white_24dp.xml +++ b/app/src/main/res/drawable/ic_chevron_right_white_24dp.xml @@ -5,5 +5,5 @@ android:viewportHeight="24.0"> + android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/> diff --git a/app/src/main/res/drawable/ic_crop_original_white_24dp.xml b/app/src/main/res/drawable/ic_crop_original_white_24dp.xml deleted file mode 100644 index 6e130c7b3..000000000 --- a/app/src/main/res/drawable/ic_crop_original_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_expand_less_white_36dp.xml b/app/src/main/res/drawable/ic_expand_less_white_36dp.xml deleted file mode 100644 index c092cc2ca..000000000 --- a/app/src/main/res/drawable/ic_expand_less_white_36dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_home_white_24dp.xml b/app/src/main/res/drawable/ic_expand_more_white_24dp.xml similarity index 66% rename from app/src/main/res/drawable/ic_home_white_24dp.xml rename to app/src/main/res/drawable/ic_expand_more_white_24dp.xml index fafc05e0c..fd3ce4a46 100644 --- a/app/src/main/res/drawable/ic_home_white_24dp.xml +++ b/app/src/main/res/drawable/ic_expand_more_white_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> + android:fillColor="#FFFFFFFF" + android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z"/> diff --git a/app/src/main/res/drawable/ic_expand_more_white_36dp.xml b/app/src/main/res/drawable/ic_expand_more_white_36dp.xml deleted file mode 100644 index 5f60a424e..000000000 --- a/app/src/main/res/drawable/ic_expand_more_white_36dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_explore_blue_24dp.xml b/app/src/main/res/drawable/ic_explore_blue_24dp.xml deleted file mode 100644 index 51ea4b5ff..000000000 --- a/app/src/main/res/drawable/ic_explore_blue_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_more_vert_white_24dp.xml b/app/src/main/res/drawable/ic_more_vert_white_24dp.xml deleted file mode 100644 index 3dfd99efc..000000000 --- a/app/src/main/res/drawable/ic_more_vert_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_play_arrow_white_36dp.xml b/app/src/main/res/drawable/ic_play_arrow_white_36dp.xml deleted file mode 100644 index 5686df3ab..000000000 --- a/app/src/main/res/drawable/ic_play_arrow_white_36dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_screen_lock_landscape_white_24dp.xml b/app/src/main/res/drawable/ic_screen_lock_landscape_white_24dp.xml deleted file mode 100644 index 79fae1854..000000000 --- a/app/src/main/res/drawable/ic_screen_lock_landscape_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_screen_lock_portrait_white_24dp.xml b/app/src/main/res/drawable/ic_screen_lock_portrait_white_24dp.xml deleted file mode 100644 index 774b9764b..000000000 --- a/app/src/main/res/drawable/ic_screen_lock_portrait_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_screen_rotation_white_24dp.xml b/app/src/main/res/drawable/ic_screen_rotation_white_24dp.xml deleted file mode 100644 index 617691b39..000000000 --- a/app/src/main/res/drawable/ic_screen_rotation_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_zoom_out_map_white_24dp.xml b/app/src/main/res/drawable/ic_zoom_out_map_white_24dp.xml deleted file mode 100644 index 078577510..000000000 --- a/app/src/main/res/drawable/ic_zoom_out_map_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/reader_background_checkbox.xml b/app/src/main/res/drawable/reader_background_checkbox.xml deleted file mode 100644 index 2ace26fce..000000000 --- a/app/src/main/res/drawable/reader_background_checkbox.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_download_manager.xml b/app/src/main/res/layout/activity_download_manager.xml new file mode 100644 index 000000000..c99c4f698 --- /dev/null +++ b/app/src/main/res/layout/activity_download_manager.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 10b7b3f8e..9e24d4df2 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -12,7 +12,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - @@ -30,7 +30,7 @@ app:tabMode="scrollable" app:tabMinWidth="75dp"/> - + + + diff --git a/app/src/main/res/layout/catalogue_drawer_content.xml b/app/src/main/res/layout/catalogue_drawer_content.xml new file mode 100644 index 000000000..4e04cc655 --- /dev/null +++ b/app/src/main/res/layout/catalogue_drawer_content.xml @@ -0,0 +1,34 @@ + + + + + +