From 600fbb2ef81bfd3c2ea88aa8e88161b432f4364f Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 5 Jan 2020 14:43:07 -0500 Subject: [PATCH] Update files to use unix line endings cmd: `find . -type f -print0 | xargs -0 dos2unix` --- .../tachiyomi/data/backup/models/Backup.kt | 44 +- .../data/database/queries/TrackQueries.kt | 66 +- .../data/preference/PreferenceKeys.kt | 264 ++-- .../tachiyomi/data/track/TrackManager.kt | 72 +- .../tachiyomi/data/track/TrackService.kt | 140 +- .../tachiyomi/data/track/anilist/Anilist.kt | 428 +++--- .../data/track/anilist/AnilistApi.kt | 582 ++++----- .../data/track/anilist/AnilistInterceptor.kt | 114 +- .../tachiyomi/data/track/anilist/OAuth.kt | 18 +- .../tachiyomi/data/track/bangumi/Bangumi.kt | 288 ++-- .../tachiyomi/data/track/bangumi/OAuth.kt | 32 +- .../tachiyomi/data/track/kitsu/Kitsu.kt | 288 ++-- .../tachiyomi/data/track/kitsu/OAuth.kt | 20 +- .../data/track/myanimelist/MyAnimeList.kt | 326 ++--- .../tachiyomi/data/track/shikimori/OAuth.kt | 26 +- .../data/track/shikimori/Shikimori.kt | 278 ++-- .../network/CloudflareInterceptor.kt | 308 ++--- .../kanade/tachiyomi/network/NetworkHelper.kt | 234 ++-- .../tachiyomi/network/OkHttpExtensions.kt | 140 +- .../tachiyomi/network/ProgressListener.kt | 8 +- .../tachiyomi/network/ProgressResponseBody.kt | 80 +- .../eu/kanade/tachiyomi/network/Requests.kt | 64 +- .../tachiyomi/source/CatalogueSource.kt | 90 +- .../java/eu/kanade/tachiyomi/source/Source.kt | 86 +- .../kanade/tachiyomi/source/SourceManager.kt | 148 +-- .../kanade/tachiyomi/source/model/Filter.kt | 78 +- .../tachiyomi/source/model/FilterList.kt | 12 +- .../tachiyomi/source/model/MangasPage.kt | 4 +- .../eu/kanade/tachiyomi/source/model/Page.kt | 96 +- .../kanade/tachiyomi/source/model/SChapter.kt | 60 +- .../tachiyomi/source/model/SChapterImpl.kt | 28 +- .../kanade/tachiyomi/source/model/SManga.kt | 114 +- .../tachiyomi/source/model/SMangaImpl.kt | 44 +- .../tachiyomi/source/online/HttpSource.kt | 734 +++++------ .../source/online/HttpSourceFetcher.kt | 50 +- .../tachiyomi/source/online/LoginSource.kt | 28 +- .../source/online/ParsedHttpSource.kt | 400 +++--- .../ui/base/controller/NucleusController.kt | 42 +- .../presenter/NucleusConductorDelegate.java | 122 +- .../NucleusConductorLifecycleListener.java | 88 +- .../ui/catalogue/filter/SectionItems.kt | 176 +-- .../ui/catalogue/filter/SortGroup.kt | 108 +- .../CatalogueSearchCardAdapter.kt | 54 +- .../CatalogueSearchCardHolder.kt | 102 +- .../global_search/CatalogueSearchCardItem.kt | 74 +- .../ui/download/DownloadController.kt | 494 +++---- .../ui/library/ChangeMangaCategoriesDialog.kt | 94 +- .../ui/library/DeleteLibraryMangasDialog.kt | 84 +- .../tachiyomi/ui/library/LibraryAdapter.kt | 204 +-- .../ui/library/LibraryCategoryAdapter.kt | 96 +- .../ui/library/LibraryCategoryView.kt | 496 +++---- .../tachiyomi/ui/library/LibraryController.kt | 1048 +++++++-------- .../tachiyomi/ui/library/LibraryGridHolder.kt | 114 +- .../tachiyomi/ui/library/LibraryHolder.kt | 54 +- .../tachiyomi/ui/library/LibraryItem.kt | 150 +-- .../tachiyomi/ui/library/LibraryListHolder.kt | 130 +- .../ui/library/LibraryNavigationView.kt | 432 +++--- .../tachiyomi/ui/library/LibraryPresenter.kt | 742 +++++------ .../tachiyomi/ui/library/LibrarySort.kt | 20 +- .../ui/main/ChangelogDialogController.kt | 62 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 564 ++++---- .../tachiyomi/ui/manga/MangaController.kt | 386 +++--- .../ui/manga/chapter/ChapterHolder.kt | 244 ++-- .../tachiyomi/ui/manga/chapter/ChapterItem.kt | 110 +- .../ui/manga/chapter/ChaptersAdapter.kt | 90 +- .../ui/manga/chapter/ChaptersController.kt | 972 +++++++------- .../ui/manga/chapter/ChaptersPresenter.kt | 836 ++++++------ .../ui/manga/chapter/DeleteChaptersDialog.kt | 62 +- .../manga/chapter/DeletingChaptersDialog.kt | 52 +- .../manga/chapter/DownloadChaptersDialog.kt | 82 +- .../ui/manga/chapter/SetDisplayModeDialog.kt | 84 +- .../ui/manga/chapter/SetSortingDialog.kt | 84 +- .../ui/manga/info/MangaInfoController.kt | 1154 ++++++++--------- .../ui/manga/info/MangaInfoPresenter.kt | 346 ++--- .../ui/manga/track/SetTrackChaptersDialog.kt | 146 +-- .../ui/manga/track/SetTrackScoreDialog.kt | 158 +-- .../ui/manga/track/SetTrackStatusDialog.kt | 114 +- .../tachiyomi/ui/manga/track/TrackAdapter.kt | 90 +- .../ui/manga/track/TrackController.kt | 284 ++-- .../tachiyomi/ui/manga/track/TrackHolder.kt | 84 +- .../tachiyomi/ui/manga/track/TrackItem.kt | 12 +- .../ui/manga/track/TrackPresenter.kt | 258 ++-- .../ui/manga/track/TrackSearchAdapter.kt | 156 +-- .../ui/manga/track/TrackSearchDialog.kt | 288 ++-- .../RecentChaptersController.kt | 666 +++++----- .../ui/setting/SettingsController.kt | 174 +-- .../ui/setting/SettingsMainController.kt | 122 +- .../widget/ExtendedNavigationView.kt | 478 +++---- .../main/res/drawable/empty_drawable_32dp.xml | 14 +- .../main/res/drawable/ic_done_white_18dp.xml | 18 +- .../drawable/ic_watch_later_black_24dp.xml | 18 +- .../res/layout/navigation_view_checkbox.xml | 46 +- .../main/res/layout/navigation_view_group.xml | 58 +- app/src/main/res/layout/pref_item_source.xml | 122 +- app/src/main/res/layout/track_item.xml | 382 +++--- 95 files changed, 9766 insertions(+), 9766 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt index 3a5e2d343..dd50553c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt @@ -1,23 +1,23 @@ -package eu.kanade.tachiyomi.data.backup.models - -import java.text.SimpleDateFormat -import java.util.* - -/** - * Json values - */ -object Backup { - const val CURRENT_VERSION = 2 - const val MANGA = "manga" - const val MANGAS = "mangas" - const val TRACK = "track" - const val CHAPTERS = "chapters" - const val CATEGORIES = "categories" - const val HISTORY = "history" - const val VERSION = "version" - - fun getDefaultFilename(): String { - val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) - return "tachiyomi_$date.json" - } +package eu.kanade.tachiyomi.data.backup.models + +import java.text.SimpleDateFormat +import java.util.* + +/** + * Json values + */ +object Backup { + const val CURRENT_VERSION = 2 + const val MANGA = "manga" + const val MANGAS = "mangas" + const val TRACK = "track" + const val CHAPTERS = "chapters" + const val CATEGORIES = "categories" + const val HISTORY = "history" + const val VERSION = "version" + + fun getDefaultFilename(): String { + val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) + return "tachiyomi_$date.json" + } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt index e215e72ea..a93877faf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt @@ -1,34 +1,34 @@ -package eu.kanade.tachiyomi.data.database.queries - -import com.pushtorefresh.storio.sqlite.queries.DeleteQuery -import com.pushtorefresh.storio.sqlite.queries.Query -import eu.kanade.tachiyomi.data.database.DbProvider -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.database.tables.TrackTable -import eu.kanade.tachiyomi.data.track.TrackService - -interface TrackQueries : DbProvider { - - fun getTracks(manga: Manga) = db.get() - .listOfObjects(Track::class.java) - .withQuery(Query.builder() - .table(TrackTable.TABLE) - .where("${TrackTable.COL_MANGA_ID} = ?") - .whereArgs(manga.id) - .build()) - .prepare() - - fun insertTrack(track: Track) = db.put().`object`(track).prepare() - - fun insertTracks(tracks: List) = db.put().objects(tracks).prepare() - - fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete() - .byQuery(DeleteQuery.builder() - .table(TrackTable.TABLE) - .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") - .whereArgs(manga.id, sync.id) - .build()) - .prepare() - +package eu.kanade.tachiyomi.data.database.queries + +import com.pushtorefresh.storio.sqlite.queries.DeleteQuery +import com.pushtorefresh.storio.sqlite.queries.Query +import eu.kanade.tachiyomi.data.database.DbProvider +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.tables.TrackTable +import eu.kanade.tachiyomi.data.track.TrackService + +interface TrackQueries : DbProvider { + + fun getTracks(manga: Manga) = db.get() + .listOfObjects(Track::class.java) + .withQuery(Query.builder() + .table(TrackTable.TABLE) + .where("${TrackTable.COL_MANGA_ID} = ?") + .whereArgs(manga.id) + .build()) + .prepare() + + fun insertTrack(track: Track) = db.put().`object`(track).prepare() + + fun insertTracks(tracks: List) = db.put().objects(tracks).prepare() + + fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete() + .byQuery(DeleteQuery.builder() + .table(TrackTable.TABLE) + .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") + .whereArgs(manga.id, sync.id) + .build()) + .prepare() + } \ 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 58388547c..057def523 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 @@ -1,132 +1,132 @@ -package eu.kanade.tachiyomi.data.preference - -/** - * This class stores the keys for the preferences in the application. - */ -object PreferenceKeys { - - const val theme = "pref_theme_key" - - const val rotation = "pref_rotation_type_key" - - const val enableTransitions = "pref_enable_transitions_key" - - const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" - - const val showPageNumber = "pref_show_page_number_key" - - const val trueColor = "pref_true_color_key" - - const val fullscreen = "fullscreen" - - const val keepScreenOn = "pref_keep_screen_on_key" - - const val customBrightness = "pref_custom_brightness_key" - - const val customBrightnessValue = "custom_brightness_value" - - const val colorFilter = "pref_color_filter_key" - - const val colorFilterValue = "color_filter_value" - - const val colorFilterMode = "color_filter_mode" - - const val defaultViewer = "pref_default_viewer_key" - - const val imageScaleType = "pref_image_scale_type_key" - - const val zoomStart = "pref_zoom_start_key" - - const val readerTheme = "pref_reader_theme_key" - - const val cropBorders = "crop_borders" - - const val cropBordersWebtoon = "crop_borders_webtoon" - - const val readWithTapping = "reader_tap" - - const val readWithLongTap = "reader_long_tap" - - const val readWithVolumeKeys = "reader_volume_keys" - - const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" - - const val portraitColumns = "pref_library_columns_portrait_key" - - const val landscapeColumns = "pref_library_columns_landscape_key" - - const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" - - const val autoUpdateTrack = "pref_auto_update_manga_sync_key" - - const val lastUsedCatalogueSource = "last_catalogue_source" - - const val lastUsedCategory = "last_used_category" - - const val catalogueAsList = "pref_display_catalogue_as_list" - - const val enabledLanguages = "source_languages" - - const val backupDirectory = "backup_directory" - - const val downloadsDirectory = "download_directory" - - const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" - - const val numberOfBackups = "backup_slots" - - const val backupInterval = "backup_interval" - - const val removeAfterReadSlots = "remove_after_read_slots" - - const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" - - const val libraryUpdateInterval = "pref_library_update_interval_key" - - const val libraryUpdateRestriction = "library_update_restriction" - - const val libraryUpdateCategories = "library_update_categories" - - const val libraryUpdatePrioritization = "library_update_prioritization" - - const val filterDownloaded = "pref_filter_downloaded_key" - - const val filterUnread = "pref_filter_unread_key" - - const val filterCompleted = "pref_filter_completed_key" - - const val librarySortingMode = "library_sorting_mode" - - const val automaticUpdates = "automatic_updates" - - const val startScreen = "start_screen" - - const val downloadNew = "download_new" - - const val downloadNewCategories = "download_new_categories" - - const val libraryAsList = "pref_display_library_as_list" - - const val lang = "app_language" - - const val defaultCategory = "default_category" - - const val skipRead = "skip_read" - - const val downloadBadge = "display_download_badge" - - @Deprecated("Use the preferences of the source") - fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" - - @Deprecated("Use the preferences of the source") - fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" - - fun sourceSharedPref(sourceId: Long) = "source_$sourceId" - - fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" - - fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" - - fun trackToken(syncId: Int) = "track_token_$syncId" - -} +package eu.kanade.tachiyomi.data.preference + +/** + * This class stores the keys for the preferences in the application. + */ +object PreferenceKeys { + + const val theme = "pref_theme_key" + + const val rotation = "pref_rotation_type_key" + + const val enableTransitions = "pref_enable_transitions_key" + + const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" + + const val showPageNumber = "pref_show_page_number_key" + + const val trueColor = "pref_true_color_key" + + const val fullscreen = "fullscreen" + + const val keepScreenOn = "pref_keep_screen_on_key" + + const val customBrightness = "pref_custom_brightness_key" + + const val customBrightnessValue = "custom_brightness_value" + + const val colorFilter = "pref_color_filter_key" + + const val colorFilterValue = "color_filter_value" + + const val colorFilterMode = "color_filter_mode" + + const val defaultViewer = "pref_default_viewer_key" + + const val imageScaleType = "pref_image_scale_type_key" + + const val zoomStart = "pref_zoom_start_key" + + const val readerTheme = "pref_reader_theme_key" + + const val cropBorders = "crop_borders" + + const val cropBordersWebtoon = "crop_borders_webtoon" + + const val readWithTapping = "reader_tap" + + const val readWithLongTap = "reader_long_tap" + + const val readWithVolumeKeys = "reader_volume_keys" + + const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" + + const val portraitColumns = "pref_library_columns_portrait_key" + + const val landscapeColumns = "pref_library_columns_landscape_key" + + const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" + + const val autoUpdateTrack = "pref_auto_update_manga_sync_key" + + const val lastUsedCatalogueSource = "last_catalogue_source" + + const val lastUsedCategory = "last_used_category" + + const val catalogueAsList = "pref_display_catalogue_as_list" + + const val enabledLanguages = "source_languages" + + const val backupDirectory = "backup_directory" + + const val downloadsDirectory = "download_directory" + + const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" + + const val numberOfBackups = "backup_slots" + + const val backupInterval = "backup_interval" + + const val removeAfterReadSlots = "remove_after_read_slots" + + const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" + + const val libraryUpdateInterval = "pref_library_update_interval_key" + + const val libraryUpdateRestriction = "library_update_restriction" + + const val libraryUpdateCategories = "library_update_categories" + + const val libraryUpdatePrioritization = "library_update_prioritization" + + const val filterDownloaded = "pref_filter_downloaded_key" + + const val filterUnread = "pref_filter_unread_key" + + const val filterCompleted = "pref_filter_completed_key" + + const val librarySortingMode = "library_sorting_mode" + + const val automaticUpdates = "automatic_updates" + + const val startScreen = "start_screen" + + const val downloadNew = "download_new" + + const val downloadNewCategories = "download_new_categories" + + const val libraryAsList = "pref_display_library_as_list" + + const val lang = "app_language" + + const val defaultCategory = "default_category" + + const val skipRead = "skip_read" + + const val downloadBadge = "display_download_badge" + + @Deprecated("Use the preferences of the source") + fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" + + @Deprecated("Use the preferences of the source") + fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" + + fun sourceSharedPref(sourceId: Long) = "source_$sourceId" + + fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" + + fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" + + fun trackToken(syncId: Int) = "track_token_$syncId" + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt index 854afa03d..62c34d422 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt @@ -1,36 +1,36 @@ -package eu.kanade.tachiyomi.data.track - -import android.content.Context -import eu.kanade.tachiyomi.data.track.anilist.Anilist -import eu.kanade.tachiyomi.data.track.kitsu.Kitsu -import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist -import eu.kanade.tachiyomi.data.track.shikimori.Shikimori -import eu.kanade.tachiyomi.data.track.bangumi.Bangumi - -class TrackManager(private val context: Context) { - - companion object { - const val MYANIMELIST = 1 - const val ANILIST = 2 - const val KITSU = 3 - const val SHIKIMORI = 4 - const val BANGUMI = 5 - } - - val myAnimeList = Myanimelist(context, MYANIMELIST) - - val aniList = Anilist(context, ANILIST) - - val kitsu = Kitsu(context, KITSU) - - val shikimori = Shikimori(context, SHIKIMORI) - - val bangumi = Bangumi(context, BANGUMI) - - val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi) - - fun getService(id: Int) = services.find { it.id == id } - - fun hasLoggedServices() = services.any { it.isLogged } - -} +package eu.kanade.tachiyomi.data.track + +import android.content.Context +import eu.kanade.tachiyomi.data.track.anilist.Anilist +import eu.kanade.tachiyomi.data.track.kitsu.Kitsu +import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist +import eu.kanade.tachiyomi.data.track.shikimori.Shikimori +import eu.kanade.tachiyomi.data.track.bangumi.Bangumi + +class TrackManager(private val context: Context) { + + companion object { + const val MYANIMELIST = 1 + const val ANILIST = 2 + const val KITSU = 3 + const val SHIKIMORI = 4 + const val BANGUMI = 5 + } + + val myAnimeList = Myanimelist(context, MYANIMELIST) + + val aniList = Anilist(context, ANILIST) + + val kitsu = Kitsu(context, KITSU) + + val shikimori = Shikimori(context, SHIKIMORI) + + val bangumi = Bangumi(context, BANGUMI) + + val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi) + + fun getService(id: Int) = services.find { it.id == id } + + fun hasLoggedServices() = services.any { it.isLogged } + +} 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 417e8ba5c..736b33cb9 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 @@ -1,70 +1,70 @@ -package eu.kanade.tachiyomi.data.track - -import androidx.annotation.CallSuper -import androidx.annotation.DrawableRes -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.network.NetworkHelper -import okhttp3.OkHttpClient -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy - -abstract class TrackService(val id: Int) { - - val preferences: PreferencesHelper by injectLazy() - val networkService: NetworkHelper by injectLazy() - - open val client: OkHttpClient - get() = networkService.client - - // Name of the manga sync service to display - abstract val name: String - - @DrawableRes - abstract fun getLogo(): Int - - abstract fun getLogoColor(): Int - - abstract fun getStatusList(): List - - abstract fun getStatus(status: Int): String - - abstract fun getScoreList(): List - - open fun indexToScore(index: Int): Float { - return index.toFloat() - } - - abstract fun displayScore(track: Track): String - - abstract fun add(track: Track): Observable - - abstract fun update(track: Track): Observable - - abstract fun bind(track: Track): Observable - - abstract fun search(query: String): Observable> - - abstract fun refresh(track: Track): Observable - - abstract fun login(username: String, password: String): Completable - - @CallSuper - open fun logout() { - preferences.setTrackCredentials(this, "", "") - } - - open val isLogged: Boolean - get() = !getUsername().isEmpty() && - !getPassword().isEmpty() - - fun getUsername() = preferences.trackUsername(this)!! - - fun getPassword() = preferences.trackPassword(this)!! - - fun saveCredentials(username: String, password: String) { - preferences.setTrackCredentials(this, username, password) - } -} +package eu.kanade.tachiyomi.data.track + +import androidx.annotation.CallSuper +import androidx.annotation.DrawableRes +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.network.NetworkHelper +import okhttp3.OkHttpClient +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy + +abstract class TrackService(val id: Int) { + + val preferences: PreferencesHelper by injectLazy() + val networkService: NetworkHelper by injectLazy() + + open val client: OkHttpClient + get() = networkService.client + + // Name of the manga sync service to display + abstract val name: String + + @DrawableRes + abstract fun getLogo(): Int + + abstract fun getLogoColor(): Int + + abstract fun getStatusList(): List + + abstract fun getStatus(status: Int): String + + abstract fun getScoreList(): List + + open fun indexToScore(index: Int): Float { + return index.toFloat() + } + + abstract fun displayScore(track: Track): String + + abstract fun add(track: Track): Observable + + abstract fun update(track: Track): Observable + + abstract fun bind(track: Track): Observable + + abstract fun search(query: String): Observable> + + abstract fun refresh(track: Track): Observable + + abstract fun login(username: String, password: String): Completable + + @CallSuper + open fun logout() { + preferences.setTrackCredentials(this, "", "") + } + + open val isLogged: Boolean + get() = !getUsername().isEmpty() && + !getPassword().isEmpty() + + fun getUsername() = preferences.trackUsername(this)!! + + fun getPassword() = preferences.trackPassword(this)!! + + fun saveCredentials(username: String, password: String) { + preferences.setTrackCredentials(this, username, password) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 95c4f6461..1f862cfef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -1,214 +1,214 @@ -package eu.kanade.tachiyomi.data.track.anilist - -import android.content.Context -import android.graphics.Color -import com.google.gson.Gson -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy - -class Anilist(private val context: Context, id: Int) : TrackService(id) { - - companion object { - const val READING = 1 - const val COMPLETED = 2 - const val ON_HOLD = 3 - const val DROPPED = 4 - const val PLANNING = 5 - const val REPEATING = 6 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0 - - const val POINT_100 = "POINT_100" - const val POINT_10 = "POINT_10" - const val POINT_10_DECIMAL = "POINT_10_DECIMAL" - const val POINT_5 = "POINT_5" - const val POINT_3 = "POINT_3" - } - - override val name = "AniList" - - private val gson: Gson by injectLazy() - - private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } - - private val api by lazy { AnilistApi(client, interceptor) } - - private val scorePreference = preferences.anilistScoreType() - - init { - // If the preference is an int from APIv1, logout user to force using APIv2 - try { - scorePreference.get() - } catch (e: ClassCastException) { - logout() - scorePreference.delete() - } - } - - override fun getLogo() = R.drawable.al - - override fun getLogoColor() = Color.rgb(18, 25, 35) - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) - } - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLANNING -> getString(R.string.plan_to_read) - REPEATING -> getString(R.string.repeating) - else -> "" - } - } - - override fun getScoreList(): List { - return when (scorePreference.getOrDefault()) { - // 10 point - POINT_10 -> IntRange(0, 10).map(Int::toString) - // 100 point - POINT_100 -> IntRange(0, 100).map(Int::toString) - // 5 stars - POINT_5 -> IntRange(0, 5).map { "$it ★" } - // Smiley - POINT_3 -> listOf("-", "😦", "😐", "😊") - // 10 point decimal - POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } - else -> throw Exception("Unknown score type") - } - } - - override fun indexToScore(index: Int): Float { - return when (scorePreference.getOrDefault()) { - // 10 point - POINT_10 -> index * 10f - // 100 point - POINT_100 -> index.toFloat() - // 5 stars - POINT_5 -> when { - index == 0 -> 0f - else -> index * 20f - 10f - } - // Smiley - POINT_3 -> when { - index == 0 -> 0f - else -> index * 25f + 10f - } - // 10 point decimal - POINT_10_DECIMAL -> index.toFloat() - else -> throw Exception("Unknown score type") - } - } - - override fun displayScore(track: Track): String { - val score = track.score - - return when (scorePreference.getOrDefault()) { - POINT_5 -> when { - score == 0f -> "0 ★" - else -> "${((score + 10) / 20).toInt()} ★" - } - POINT_3 -> when { - score == 0f -> "0" - score <= 35 -> "😦" - score <= 60 -> "😐" - else -> "😊" - } - else -> track.toAnilistScore() - } - } - - override fun add(track: Track): Observable { - return api.addLibManga(track) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - // If user was using API v1 fetch library_id - if (track.library_id == null || track.library_id!! == 0L){ - return api.findLibManga(track, getUsername().toInt()).flatMap { - if (it == null) { - throw Exception("$track not found on user library") - } - track.library_id = it.library_id - api.updateLibManga(track) - } - } - - return api.updateLibManga(track) - } - - override fun bind(track: Track): Observable { - return api.findLibManga(track, getUsername().toInt()) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.library_id = remoteTrack.library_id - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.getLibManga(track, getUsername().toInt()) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } - } - - override fun login(username: String, password: String) = login(password) - - fun login(token: String): Completable { - val oauth = api.createOAuth(token) - interceptor.setAuth(oauth) - return api.getCurrentUser().map { (username, scoreType) -> - scorePreference.set(scoreType) - saveCredentials(username.toString(), oauth.access_token) - }.doOnError{ - logout() - }.toCompletable() - } - - override fun logout() { - super.logout() - preferences.trackToken(this).set(null) - interceptor.setAuth(null) - } - - fun saveOAuth(oAuth: OAuth?) { - preferences.trackToken(this).set(gson.toJson(oAuth)) - } - - fun loadOAuth(): OAuth? { - return try { - gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) - } catch (e: Exception) { - null - } - } - -} - +package eu.kanade.tachiyomi.data.track.anilist + +import android.content.Context +import android.graphics.Color +import com.google.gson.Gson +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class Anilist(private val context: Context, id: Int) : TrackService(id) { + + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLANNING = 5 + const val REPEATING = 6 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + + const val POINT_100 = "POINT_100" + const val POINT_10 = "POINT_10" + const val POINT_10_DECIMAL = "POINT_10_DECIMAL" + const val POINT_5 = "POINT_5" + const val POINT_3 = "POINT_3" + } + + override val name = "AniList" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } + + private val api by lazy { AnilistApi(client, interceptor) } + + private val scorePreference = preferences.anilistScoreType() + + init { + // If the preference is an int from APIv1, logout user to force using APIv2 + try { + scorePreference.get() + } catch (e: ClassCastException) { + logout() + scorePreference.delete() + } + } + + override fun getLogo() = R.drawable.al + + override fun getLogoColor() = Color.rgb(18, 25, 35) + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + PLANNING -> getString(R.string.plan_to_read) + REPEATING -> getString(R.string.repeating) + else -> "" + } + } + + override fun getScoreList(): List { + return when (scorePreference.getOrDefault()) { + // 10 point + POINT_10 -> IntRange(0, 10).map(Int::toString) + // 100 point + POINT_100 -> IntRange(0, 100).map(Int::toString) + // 5 stars + POINT_5 -> IntRange(0, 5).map { "$it ★" } + // Smiley + POINT_3 -> listOf("-", "😦", "😐", "😊") + // 10 point decimal + POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } + else -> throw Exception("Unknown score type") + } + } + + override fun indexToScore(index: Int): Float { + return when (scorePreference.getOrDefault()) { + // 10 point + POINT_10 -> index * 10f + // 100 point + POINT_100 -> index.toFloat() + // 5 stars + POINT_5 -> when { + index == 0 -> 0f + else -> index * 20f - 10f + } + // Smiley + POINT_3 -> when { + index == 0 -> 0f + else -> index * 25f + 10f + } + // 10 point decimal + POINT_10_DECIMAL -> index.toFloat() + else -> throw Exception("Unknown score type") + } + } + + override fun displayScore(track: Track): String { + val score = track.score + + return when (scorePreference.getOrDefault()) { + POINT_5 -> when { + score == 0f -> "0 ★" + else -> "${((score + 10) / 20).toInt()} ★" + } + POINT_3 -> when { + score == 0f -> "0" + score <= 35 -> "😦" + score <= 60 -> "😐" + else -> "😊" + } + else -> track.toAnilistScore() + } + } + + override fun add(track: Track): Observable { + return api.addLibManga(track) + } + + override fun update(track: Track): Observable { + if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { + track.status = COMPLETED + } + // If user was using API v1 fetch library_id + if (track.library_id == null || track.library_id!! == 0L){ + return api.findLibManga(track, getUsername().toInt()).flatMap { + if (it == null) { + throw Exception("$track not found on user library") + } + track.library_id = it.library_id + api.updateLibManga(track) + } + } + + return api.updateLibManga(track) + } + + override fun bind(track: Track): Observable { + return api.findLibManga(track, getUsername().toInt()) + .flatMap { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + update(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.getLibManga(track, getUsername().toInt()) + .map { remoteTrack -> + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + track + } + } + + override fun login(username: String, password: String) = login(password) + + fun login(token: String): Completable { + val oauth = api.createOAuth(token) + interceptor.setAuth(oauth) + return api.getCurrentUser().map { (username, scoreType) -> + scorePreference.set(scoreType) + saveCredentials(username.toString(), oauth.access_token) + }.doOnError{ + logout() + }.toCompletable() + } + + override fun logout() { + super.logout() + preferences.trackToken(this).set(null) + interceptor.setAuth(null) + } + + fun saveOAuth(oAuth: OAuth?) { + preferences.trackToken(this).set(gson.toJson(oAuth)) + } + + fun loadOAuth(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } + +} + 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 6698b1cde..11ef51952 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 @@ -1,291 +1,291 @@ -package eu.kanade.tachiyomi.data.track.anilist - -import android.net.Uri -import com.github.salomonbrys.kotson.array -import com.github.salomonbrys.kotson.get -import com.github.salomonbrys.kotson.jsonObject -import com.github.salomonbrys.kotson.nullInt -import com.github.salomonbrys.kotson.nullString -import com.github.salomonbrys.kotson.obj -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.network.asObservableSuccess -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import rx.Observable -import java.util.Calendar - - -class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { - - private val parser = JsonParser() - private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull() - private val authClient = client.newBuilder().addInterceptor(interceptor).build() - - fun addLibManga(track: Track): Observable { - val query = """ - |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { - |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) { - | id - | status - |} - |} - |""".trimMargin() - val variables = jsonObject( - "mangaId" to track.media_id, - "progress" to track.last_chapter_read, - "status" to track.toAnilistStatus() - ) - val payload = jsonObject( - "query" to query, - "variables" to variables - ) - val body = RequestBody.create(jsonMime, payload.toString()) - val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() - return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - netResponse.close() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).obj - track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong - track - } - } - - fun updateLibManga(track: Track): Observable { - val query = """ - |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { - |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { - |id - |status - |progress - |} - |} - |""".trimMargin() - val variables = jsonObject( - "listId" to track.library_id, - "progress" to track.last_chapter_read, - "status" to track.toAnilistStatus(), - "score" to track.score.toInt() - ) - val payload = jsonObject( - "query" to query, - "variables" to variables - ) - val body = RequestBody.create(jsonMime, payload.toString()) - val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() - return authClient.newCall(request) - .asObservableSuccess() - .map { - track - } - } - - fun search(search: String): Observable> { - val query = """ - |query Search(${'$'}query: String) { - |Page (perPage: 50) { - |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { - |id - |title { - |romaji - |} - |coverImage { - |large - |} - |type - |status - |chapters - |description - |startDate { - |year - |month - |day - |} - |} - |} - |} - |""".trimMargin() - val variables = jsonObject( - "query" to search - ) - val payload = jsonObject( - "query" to query, - "variables" to variables - ) - val body = RequestBody.create(jsonMime, payload.toString()) - val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() - return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).obj - val data = response["data"]!!.obj - val page = data["Page"].obj - val media = page["media"].array - val entries = media.map { jsonToALManga(it.obj) } - entries.map { it.toTrack() } - } - } - - - fun findLibManga(track: Track, userid: Int): Observable { - val query = """ - |query (${'$'}id: Int!, ${'$'}manga_id: Int!) { - |Page { - |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { - |id - |status - |scoreRaw: score(format: POINT_100) - |progress - |media { - |id - |title { - |romaji - |} - |coverImage { - |large - |} - |type - |status - |chapters - |description - |startDate { - |year - |month - |day - |} - |} - |} - |} - |} - |""".trimMargin() - val variables = jsonObject( - "id" to userid, - "manga_id" to track.media_id - ) - val payload = jsonObject( - "query" to query, - "variables" to variables - ) - val body = RequestBody.create(jsonMime, payload.toString()) - val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() - return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).obj - val data = response["data"]!!.obj - val page = data["Page"].obj - val media = page["mediaList"].array - val entries = media.map { jsonToALUserManga(it.obj) } - entries.firstOrNull()?.toTrack() - - } - } - - fun getLibManga(track: Track, userid: Int): Observable { - return findLibManga(track, userid) - .map { it ?: throw Exception("Could not find manga") } - } - - fun createOAuth(token: String): OAuth { - return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000) - } - - fun getCurrentUser(): Observable> { - val query = """ - |query User { - |Viewer { - |id - |mediaListOptions { - |scoreFormat - |} - |} - |} - |""".trimMargin() - val payload = jsonObject( - "query" to query - ) - val body = RequestBody.create(jsonMime, payload.toString()) - val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() - return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).obj - val data = response["data"]!!.obj - val viewer = data["Viewer"].obj - Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) - } - } - - private fun jsonToALManga(struct: JsonObject): ALManga { - val date = try { - val date = Calendar.getInstance() - date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1, - struct["startDate"]["day"].nullInt ?: 0) - date.timeInMillis - } catch (_: Exception) { - 0L - } - - return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, - struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, - date, struct["chapters"].nullInt ?: 0) - } - - private fun jsonToALUserManga(struct: JsonObject): ALUserManga { - return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj)) - } - - companion object { - private const val clientId = "385" - private const val clientUrl = "tachiyomi://anilist-auth" - private const val apiUrl = "https://graphql.anilist.co/" - private const val baseUrl = "https://anilist.co/api/v2/" - private const val baseMangaUrl = "https://anilist.co/manga/" - - fun mangaUrl(mediaId: Int): String { - return baseMangaUrl + mediaId - } - - fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon() - .appendQueryParameter("client_id", clientId) - .appendQueryParameter("response_type", "token") - .build() - } - -} +package eu.kanade.tachiyomi.data.track.anilist + +import android.net.Uri +import com.github.salomonbrys.kotson.array +import com.github.salomonbrys.kotson.get +import com.github.salomonbrys.kotson.jsonObject +import com.github.salomonbrys.kotson.nullInt +import com.github.salomonbrys.kotson.nullString +import com.github.salomonbrys.kotson.obj +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.network.asObservableSuccess +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import rx.Observable +import java.util.Calendar + + +class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { + + private val parser = JsonParser() + private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull() + private val authClient = client.newBuilder().addInterceptor(interceptor).build() + + fun addLibManga(track: Track): Observable { + val query = """ + |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { + |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) { + | id + | status + |} + |} + |""".trimMargin() + val variables = jsonObject( + "mangaId" to track.media_id, + "progress" to track.last_chapter_read, + "status" to track.toAnilistStatus() + ) + val payload = jsonObject( + "query" to query, + "variables" to variables + ) + val body = RequestBody.create(jsonMime, payload.toString()) + val request = Request.Builder() + .url(apiUrl) + .post(body) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + netResponse.close() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + val response = parser.parse(responseBody).obj + track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong + track + } + } + + fun updateLibManga(track: Track): Observable { + val query = """ + |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { + |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { + |id + |status + |progress + |} + |} + |""".trimMargin() + val variables = jsonObject( + "listId" to track.library_id, + "progress" to track.last_chapter_read, + "status" to track.toAnilistStatus(), + "score" to track.score.toInt() + ) + val payload = jsonObject( + "query" to query, + "variables" to variables + ) + val body = RequestBody.create(jsonMime, payload.toString()) + val request = Request.Builder() + .url(apiUrl) + .post(body) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { + track + } + } + + fun search(search: String): Observable> { + val query = """ + |query Search(${'$'}query: String) { + |Page (perPage: 50) { + |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { + |id + |title { + |romaji + |} + |coverImage { + |large + |} + |type + |status + |chapters + |description + |startDate { + |year + |month + |day + |} + |} + |} + |} + |""".trimMargin() + val variables = jsonObject( + "query" to search + ) + val payload = jsonObject( + "query" to query, + "variables" to variables + ) + val body = RequestBody.create(jsonMime, payload.toString()) + val request = Request.Builder() + .url(apiUrl) + .post(body) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + val response = parser.parse(responseBody).obj + val data = response["data"]!!.obj + val page = data["Page"].obj + val media = page["media"].array + val entries = media.map { jsonToALManga(it.obj) } + entries.map { it.toTrack() } + } + } + + + fun findLibManga(track: Track, userid: Int): Observable { + val query = """ + |query (${'$'}id: Int!, ${'$'}manga_id: Int!) { + |Page { + |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { + |id + |status + |scoreRaw: score(format: POINT_100) + |progress + |media { + |id + |title { + |romaji + |} + |coverImage { + |large + |} + |type + |status + |chapters + |description + |startDate { + |year + |month + |day + |} + |} + |} + |} + |} + |""".trimMargin() + val variables = jsonObject( + "id" to userid, + "manga_id" to track.media_id + ) + val payload = jsonObject( + "query" to query, + "variables" to variables + ) + val body = RequestBody.create(jsonMime, payload.toString()) + val request = Request.Builder() + .url(apiUrl) + .post(body) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + val response = parser.parse(responseBody).obj + val data = response["data"]!!.obj + val page = data["Page"].obj + val media = page["mediaList"].array + val entries = media.map { jsonToALUserManga(it.obj) } + entries.firstOrNull()?.toTrack() + + } + } + + fun getLibManga(track: Track, userid: Int): Observable { + return findLibManga(track, userid) + .map { it ?: throw Exception("Could not find manga") } + } + + fun createOAuth(token: String): OAuth { + return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000) + } + + fun getCurrentUser(): Observable> { + val query = """ + |query User { + |Viewer { + |id + |mediaListOptions { + |scoreFormat + |} + |} + |} + |""".trimMargin() + val payload = jsonObject( + "query" to query + ) + val body = RequestBody.create(jsonMime, payload.toString()) + val request = Request.Builder() + .url(apiUrl) + .post(body) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + val response = parser.parse(responseBody).obj + val data = response["data"]!!.obj + val viewer = data["Viewer"].obj + Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) + } + } + + private fun jsonToALManga(struct: JsonObject): ALManga { + val date = try { + val date = Calendar.getInstance() + date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1, + struct["startDate"]["day"].nullInt ?: 0) + date.timeInMillis + } catch (_: Exception) { + 0L + } + + return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, + struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, + date, struct["chapters"].nullInt ?: 0) + } + + private fun jsonToALUserManga(struct: JsonObject): ALUserManga { + return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj)) + } + + companion object { + private const val clientId = "385" + private const val clientUrl = "tachiyomi://anilist-auth" + private const val apiUrl = "https://graphql.anilist.co/" + private const val baseUrl = "https://anilist.co/api/v2/" + private const val baseMangaUrl = "https://anilist.co/manga/" + + fun mangaUrl(mediaId: Int): String { + return baseMangaUrl + mediaId + } + + fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("response_type", "token") + .build() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt index 427b0acfe..ff416a1c5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt @@ -1,58 +1,58 @@ -package eu.kanade.tachiyomi.data.track.anilist - -import okhttp3.Interceptor -import okhttp3.Response - - -class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { - - /** - * OAuth object used for authenticated requests. - * - * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute - * before its original expiration date. - */ - private var oauth: OAuth? = null - set(value) { - field = value?.copy(expires = value.expires * 1000 - 60 * 1000) - } - - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest = chain.request() - - if (token.isNullOrEmpty()) { - throw Exception("Not authenticated with Anilist") - } - if (oauth == null){ - oauth = anilist.loadOAuth() - } - // Refresh access token if null or expired. - if (oauth!!.isExpired()) { - anilist.logout() - throw Exception("Token expired") - } - - // Throw on null auth. - if (oauth == null) { - throw Exception("No authentication token") - } - - // Add the authorization header to the original request. - val authRequest = originalRequest.newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.access_token}") - .build() - - return chain.proceed(authRequest) - } - - /** - * Called when the user authenticates with Anilist for the first time. Sets the refresh token - * and the oauth object. - */ - fun setAuth(oauth: OAuth?) { - token = oauth?.access_token - this.oauth = oauth - anilist.saveOAuth(oauth) - } - +package eu.kanade.tachiyomi.data.track.anilist + +import okhttp3.Interceptor +import okhttp3.Response + + +class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { + + /** + * OAuth object used for authenticated requests. + * + * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute + * before its original expiration date. + */ + private var oauth: OAuth? = null + set(value) { + field = value?.copy(expires = value.expires * 1000 - 60 * 1000) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (token.isNullOrEmpty()) { + throw Exception("Not authenticated with Anilist") + } + if (oauth == null){ + oauth = anilist.loadOAuth() + } + // Refresh access token if null or expired. + if (oauth!!.isExpired()) { + anilist.logout() + throw Exception("Token expired") + } + + // Throw on null auth. + if (oauth == null) { + throw Exception("No authentication token") + } + + // Add the authorization header to the original request. + val authRequest = originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .build() + + return chain.proceed(authRequest) + } + + /** + * Called when the user authenticates with Anilist for the first time. Sets the refresh token + * and the oauth object. + */ + fun setAuth(oauth: OAuth?) { + token = oauth?.access_token + this.oauth = oauth + anilist.saveOAuth(oauth) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/OAuth.kt index 1d7a31ac5..a53760ba5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/OAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/OAuth.kt @@ -1,10 +1,10 @@ -package eu.kanade.tachiyomi.data.track.anilist - -data class OAuth( - val access_token: String, - val token_type: String, - val expires: Long, - val expires_in: Long) { - - fun isExpired() = System.currentTimeMillis() > expires +package eu.kanade.tachiyomi.data.track.anilist + +data class OAuth( + val access_token: String, + val token_type: String, + val expires: Long, + val expires_in: Long) { + + fun isExpired() = System.currentTimeMillis() > expires } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt index 1eb6fff59..0d93e1fb7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt @@ -1,144 +1,144 @@ -package eu.kanade.tachiyomi.data.track.bangumi - -import android.content.Context -import android.graphics.Color -import com.google.gson.Gson -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy - -class Bangumi(private val context: Context, id: Int) : TrackService(id) { - - override fun getScoreList(): List { - return IntRange(0, 10).map(Int::toString) - } - - override fun displayScore(track: Track): String { - return track.score.toInt().toString() - } - - override fun add(track: Track): Observable { - return api.addLibManga(track) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - return api.updateLibManga(track) - } - - override fun bind(track: Track): Observable { - return api.statusLibManga(track) - .flatMap { - api.findLibManga(track).flatMap { remoteTrack -> - if (remoteTrack != null && it != null) { - track.copyPersonalFrom(remoteTrack) - track.library_id = remoteTrack.library_id - track.status = remoteTrack.status - track.last_chapter_read = remoteTrack.last_chapter_read - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - update(track) - } - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.statusLibManga(track) - .flatMap { - track.copyPersonalFrom(it!!) - api.findLibManga(track) - .map { remoteTrack -> - if (remoteTrack != null) { - track.total_chapters = remoteTrack.total_chapters - track.status = remoteTrack.status - } - track - } - } - } - - companion object { - const val READING = 3 - const val COMPLETED = 2 - const val ON_HOLD = 4 - const val DROPPED = 5 - const val PLANNING = 1 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0 - } - - override val name = "Bangumi" - - private val gson: Gson by injectLazy() - - private val interceptor by lazy { BangumiInterceptor(this, gson) } - - private val api by lazy { BangumiApi(client, interceptor) } - - override fun getLogo() = R.drawable.bangumi - - override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99) - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) - } - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLANNING -> getString(R.string.plan_to_read) - else -> "" - } - } - - override fun login(username: String, password: String) = login(password) - - fun login(code: String): Completable { - return api.accessToken(code).map { oauth: OAuth? -> - interceptor.newAuth(oauth) - if (oauth != null) { - saveCredentials(oauth.user_id.toString(), oauth.access_token) - } - }.doOnError { - logout() - }.toCompletable() - } - - fun saveToken(oauth: OAuth?) { - val json = gson.toJson(oauth) - preferences.trackToken(this).set(json) - } - - fun restoreToken(): OAuth? { - return try { - gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) - } catch (e: Exception) { - null - } - } - - override fun logout() { - super.logout() - preferences.trackToken(this).set(null) - interceptor.newAuth(null) - } -} +package eu.kanade.tachiyomi.data.track.bangumi + +import android.content.Context +import android.graphics.Color +import com.google.gson.Gson +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class Bangumi(private val context: Context, id: Int) : TrackService(id) { + + override fun getScoreList(): List { + return IntRange(0, 10).map(Int::toString) + } + + override fun displayScore(track: Track): String { + return track.score.toInt().toString() + } + + override fun add(track: Track): Observable { + return api.addLibManga(track) + } + + override fun update(track: Track): Observable { + if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { + track.status = COMPLETED + } + return api.updateLibManga(track) + } + + override fun bind(track: Track): Observable { + return api.statusLibManga(track) + .flatMap { + api.findLibManga(track).flatMap { remoteTrack -> + if (remoteTrack != null && it != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + track.status = remoteTrack.status + track.last_chapter_read = remoteTrack.last_chapter_read + update(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) + update(track) + } + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.statusLibManga(track) + .flatMap { + track.copyPersonalFrom(it!!) + api.findLibManga(track) + .map { remoteTrack -> + if (remoteTrack != null) { + track.total_chapters = remoteTrack.total_chapters + track.status = remoteTrack.status + } + track + } + } + } + + companion object { + const val READING = 3 + const val COMPLETED = 2 + const val ON_HOLD = 4 + const val DROPPED = 5 + const val PLANNING = 1 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + } + + override val name = "Bangumi" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { BangumiInterceptor(this, gson) } + + private val api by lazy { BangumiApi(client, interceptor) } + + override fun getLogo() = R.drawable.bangumi + + override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99) + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + PLANNING -> getString(R.string.plan_to_read) + else -> "" + } + } + + override fun login(username: String, password: String) = login(password) + + fun login(code: String): Completable { + return api.accessToken(code).map { oauth: OAuth? -> + interceptor.newAuth(oauth) + if (oauth != null) { + saveCredentials(oauth.user_id.toString(), oauth.access_token) + } + }.doOnError { + logout() + }.toCompletable() + } + + fun saveToken(oauth: OAuth?) { + val json = gson.toJson(oauth) + preferences.trackToken(this).set(json) + } + + fun restoreToken(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } + + override fun logout() { + super.logout() + preferences.trackToken(this).set(null) + interceptor.newAuth(null) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt index 68dc7e5c4..8674b6134 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt @@ -1,16 +1,16 @@ -package eu.kanade.tachiyomi.data.track.bangumi - -data class OAuth( - val access_token: String, - val token_type: String, - val created_at: Long, - val expires_in: Long, - val refresh_token: String?, - val user_id: Long? -) { - - // Access token refersh before expired - fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) - -} - +package eu.kanade.tachiyomi.data.track.bangumi + +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String?, + val user_id: Long? +) { + + // Access token refersh before expired + fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) + +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 14be0ddb7..97741fd54 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -1,144 +1,144 @@ -package eu.kanade.tachiyomi.data.track.kitsu - -import android.content.Context -import android.graphics.Color -import com.google.gson.Gson -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy -import java.text.DecimalFormat - -class Kitsu(private val context: Context, id: Int) : TrackService(id) { - - companion object { - const val READING = 1 - const val COMPLETED = 2 - const val ON_HOLD = 3 - const val DROPPED = 4 - const val PLAN_TO_READ = 5 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0f - } - - override val name = "Kitsu" - - private val gson: Gson by injectLazy() - - private val interceptor by lazy { KitsuInterceptor(this, gson) } - - private val api by lazy { KitsuApi(client, interceptor) } - - override fun getLogo(): Int { - return R.drawable.kitsu - } - - override fun getLogoColor(): Int { - return Color.rgb(51, 37, 50) - } - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) - } - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLAN_TO_READ -> getString(R.string.plan_to_read) - else -> "" - } - } - - override fun getScoreList(): List { - val df = DecimalFormat("0.#") - return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } - } - - override fun indexToScore(index: Int): Float { - return if (index > 0) (index + 1) / 2f else 0f - } - - override fun displayScore(track: Track): String { - val df = DecimalFormat("0.#") - return df.format(track.score) - } - - override fun add(track: Track): Observable { - return api.addLibManga(track, getUserId()) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - - return api.updateLibManga(track) - } - - override fun bind(track: Track): Observable { - return api.findLibManga(track, getUserId()) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.media_id = remoteTrack.media_id - update(track) - } else { - track.score = DEFAULT_SCORE - track.status = DEFAULT_STATUS - add(track) - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.getLibManga(track) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } - } - - override fun login(username: String, password: String): Completable { - return api.login(username, password) - .doOnNext { interceptor.newAuth(it) } - .flatMap { api.getCurrentUser() } - .doOnNext { userId -> saveCredentials(username, userId) } - .doOnError { logout() } - .toCompletable() - } - - override fun logout() { - super.logout() - interceptor.newAuth(null) - } - - private fun getUserId(): String { - return getPassword() - } - - fun saveToken(oauth: OAuth?) { - val json = gson.toJson(oauth) - preferences.trackToken(this).set(json) - } - - fun restoreToken(): OAuth? { - return try { - gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) - } catch (e: Exception) { - null - } - } - -} +package eu.kanade.tachiyomi.data.track.kitsu + +import android.content.Context +import android.graphics.Color +import com.google.gson.Gson +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.text.DecimalFormat + +class Kitsu(private val context: Context, id: Int) : TrackService(id) { + + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLAN_TO_READ = 5 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0f + } + + override val name = "Kitsu" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { KitsuInterceptor(this, gson) } + + private val api by lazy { KitsuApi(client, interceptor) } + + override fun getLogo(): Int { + return R.drawable.kitsu + } + + override fun getLogoColor(): Int { + return Color.rgb(51, 37, 50) + } + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + PLAN_TO_READ -> getString(R.string.plan_to_read) + else -> "" + } + } + + override fun getScoreList(): List { + val df = DecimalFormat("0.#") + return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } + } + + override fun indexToScore(index: Int): Float { + return if (index > 0) (index + 1) / 2f else 0f + } + + override fun displayScore(track: Track): String { + val df = DecimalFormat("0.#") + return df.format(track.score) + } + + override fun add(track: Track): Observable { + return api.addLibManga(track, getUserId()) + } + + override fun update(track: Track): Observable { + if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { + track.status = COMPLETED + } + + return api.updateLibManga(track) + } + + override fun bind(track: Track): Observable { + return api.findLibManga(track, getUserId()) + .flatMap { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.media_id = remoteTrack.media_id + update(track) + } else { + track.score = DEFAULT_SCORE + track.status = DEFAULT_STATUS + add(track) + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.getLibManga(track) + .map { remoteTrack -> + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + track + } + } + + override fun login(username: String, password: String): Completable { + return api.login(username, password) + .doOnNext { interceptor.newAuth(it) } + .flatMap { api.getCurrentUser() } + .doOnNext { userId -> saveCredentials(username, userId) } + .doOnError { logout() } + .toCompletable() + } + + override fun logout() { + super.logout() + interceptor.newAuth(null) + } + + private fun getUserId(): String { + return getPassword() + } + + fun saveToken(oauth: OAuth?) { + val json = gson.toJson(oauth) + preferences.trackToken(this).set(json) + } + + fun restoreToken(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt index e9f2ae401..678567ce9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt @@ -1,11 +1,11 @@ -package eu.kanade.tachiyomi.data.track.kitsu - -data class OAuth( - val access_token: String, - val token_type: String, - val created_at: Long, - val expires_in: Long, - val refresh_token: String?) { - - fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) +package eu.kanade.tachiyomi.data.track.kitsu + +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String?) { + + fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index a57012447..083060016 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -1,163 +1,163 @@ -package eu.kanade.tachiyomi.data.track.myanimelist - -import android.content.Context -import android.graphics.Color -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import rx.Completable -import rx.Observable - -class Myanimelist(private val context: Context, id: Int) : TrackService(id) { - - companion object { - const val READING = 1 - const val COMPLETED = 2 - const val ON_HOLD = 3 - const val DROPPED = 4 - const val PLAN_TO_READ = 6 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0 - - const val BASE_URL = "https://myanimelist.net" - const val USER_SESSION_COOKIE = "MALSESSIONID" - const val LOGGED_IN_COOKIE = "is_logged_in" - } - - private val interceptor by lazy { MyAnimeListInterceptor(this) } - private val api by lazy { MyanimelistApi(client, interceptor) } - - override val name: String - get() = "MyAnimeList" - - override fun getLogo() = R.drawable.mal - - override fun getLogoColor() = Color.rgb(46, 81, 162) - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLAN_TO_READ -> getString(R.string.plan_to_read) - else -> "" - } - } - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) - } - - override fun getScoreList(): List { - return IntRange(0, 10).map(Int::toString) - } - - override fun displayScore(track: Track): String { - return track.score.toInt().toString() - } - - override fun add(track: Track): Observable { - return api.addLibManga(track) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - - return api.updateLibManga(track) - } - - override fun bind(track: Track): Observable { - return api.findLibManga(track) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.getLibManga(track) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } - } - - override fun login(username: String, password: String): Completable { - logout() - - return Observable.fromCallable { api.login(username, password) } - .doOnNext { csrf -> saveCSRF(csrf) } - .doOnNext { saveCredentials(username, password) } - .doOnError { logout() } - .toCompletable() - } - - fun refreshLogin() { - val username = getUsername() - val password = getPassword() - logout() - - try { - val csrf = api.login(username, password) - saveCSRF(csrf) - saveCredentials(username, password) - } catch (e: Exception) { - logout() - throw e - } - } - - // Attempt to login again if cookies have been cleared but credentials are still filled - fun ensureLoggedIn() { - if (isAuthorized) return - if (!isLogged) throw Exception("MAL Login Credentials not found") - - refreshLogin() - } - - override fun logout() { - super.logout() - preferences.trackToken(this).delete() - networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!) - } - - val isAuthorized: Boolean - get() = super.isLogged && - getCSRF().isNotEmpty() && - checkCookies() - - fun getCSRF(): String = preferences.trackToken(this).getOrDefault() - - private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) - - private fun checkCookies(): Boolean { - var ckCount = 0 - val url = BASE_URL.toHttpUrlOrNull()!! - for (ck in networkService.cookieManager.get(url)) { - if (ck.name == USER_SESSION_COOKIE || ck.name == LOGGED_IN_COOKIE) - ckCount++ - } - - return ckCount == 2 - } - -} +package eu.kanade.tachiyomi.data.track.myanimelist + +import android.content.Context +import android.graphics.Color +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import rx.Completable +import rx.Observable + +class Myanimelist(private val context: Context, id: Int) : TrackService(id) { + + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLAN_TO_READ = 6 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + + const val BASE_URL = "https://myanimelist.net" + const val USER_SESSION_COOKIE = "MALSESSIONID" + const val LOGGED_IN_COOKIE = "is_logged_in" + } + + private val interceptor by lazy { MyAnimeListInterceptor(this) } + private val api by lazy { MyanimelistApi(client, interceptor) } + + override val name: String + get() = "MyAnimeList" + + override fun getLogo() = R.drawable.mal + + override fun getLogoColor() = Color.rgb(46, 81, 162) + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + PLAN_TO_READ -> getString(R.string.plan_to_read) + else -> "" + } + } + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) + } + + override fun getScoreList(): List { + return IntRange(0, 10).map(Int::toString) + } + + override fun displayScore(track: Track): String { + return track.score.toInt().toString() + } + + override fun add(track: Track): Observable { + return api.addLibManga(track) + } + + override fun update(track: Track): Observable { + if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { + track.status = COMPLETED + } + + return api.updateLibManga(track) + } + + override fun bind(track: Track): Observable { + return api.findLibManga(track) + .flatMap { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + update(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.getLibManga(track) + .map { remoteTrack -> + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + track + } + } + + override fun login(username: String, password: String): Completable { + logout() + + return Observable.fromCallable { api.login(username, password) } + .doOnNext { csrf -> saveCSRF(csrf) } + .doOnNext { saveCredentials(username, password) } + .doOnError { logout() } + .toCompletable() + } + + fun refreshLogin() { + val username = getUsername() + val password = getPassword() + logout() + + try { + val csrf = api.login(username, password) + saveCSRF(csrf) + saveCredentials(username, password) + } catch (e: Exception) { + logout() + throw e + } + } + + // Attempt to login again if cookies have been cleared but credentials are still filled + fun ensureLoggedIn() { + if (isAuthorized) return + if (!isLogged) throw Exception("MAL Login Credentials not found") + + refreshLogin() + } + + override fun logout() { + super.logout() + preferences.trackToken(this).delete() + networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!) + } + + val isAuthorized: Boolean + get() = super.isLogged && + getCSRF().isNotEmpty() && + checkCookies() + + fun getCSRF(): String = preferences.trackToken(this).getOrDefault() + + private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) + + private fun checkCookies(): Boolean { + var ckCount = 0 + val url = BASE_URL.toHttpUrlOrNull()!! + for (ck in networkService.cookieManager.get(url)) { + if (ck.name == USER_SESSION_COOKIE || ck.name == LOGGED_IN_COOKIE) + ckCount++ + } + + return ckCount == 2 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/OAuth.kt index 118e584e7..1f6a38b47 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/OAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/OAuth.kt @@ -1,13 +1,13 @@ -package eu.kanade.tachiyomi.data.track.shikimori - -data class OAuth( - val access_token: String, - val token_type: String, - val created_at: Long, - val expires_in: Long, - val refresh_token: String?) { - - // Access token lives 1 day - fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) -} - +package eu.kanade.tachiyomi.data.track.shikimori + +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String?) { + + // Access token lives 1 day + fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt index 8068e6d55..4c818d5fc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt @@ -1,139 +1,139 @@ -package eu.kanade.tachiyomi.data.track.shikimori - -import android.content.Context -import android.graphics.Color -import android.util.Log -import com.google.gson.Gson -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy - -class Shikimori(private val context: Context, id: Int) : TrackService(id) { - - override fun getScoreList(): List { - return IntRange(0, 10).map(Int::toString) - } - - override fun displayScore(track: Track): String { - return track.score.toInt().toString() - } - - override fun add(track: Track): Observable { - return api.addLibManga(track, getUsername()) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - return api.updateLibManga(track, getUsername()) - } - - override fun bind(track: Track): Observable { - return api.findLibManga(track, getUsername()) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.library_id = remoteTrack.library_id - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.findLibManga(track, getUsername()) - .map { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - } - track - } - } - - companion object { - const val READING = 1 - const val COMPLETED = 2 - const val ON_HOLD = 3 - const val DROPPED = 4 - const val PLANNING = 5 - const val REPEATING = 6 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0 - } - - override val name = "Shikimori" - - private val gson: Gson by injectLazy() - - private val interceptor by lazy { ShikimoriInterceptor(this, gson) } - - private val api by lazy { ShikimoriApi(client, interceptor) } - - override fun getLogo() = R.drawable.shikimori - - override fun getLogoColor() = Color.rgb(40, 40, 40) - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) - } - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLANNING -> getString(R.string.plan_to_read) - REPEATING -> getString(R.string.repeating) - else -> "" - } - } - - override fun login(username: String, password: String) = login(password) - - fun login(code: String): Completable { - return api.accessToken(code).map { oauth: OAuth? -> - interceptor.newAuth(oauth) - if (oauth != null) { - val user = api.getCurrentUser() - saveCredentials(user.toString(), oauth.access_token) - } - }.doOnError { - logout() - }.toCompletable() - } - - fun saveToken(oauth: OAuth?) { - val json = gson.toJson(oauth) - preferences.trackToken(this).set(json) - } - - fun restoreToken(): OAuth? { - return try { - gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) - } catch (e: Exception) { - null - } - } - - override fun logout() { - super.logout() - preferences.trackToken(this).set(null) - interceptor.newAuth(null) - } -} +package eu.kanade.tachiyomi.data.track.shikimori + +import android.content.Context +import android.graphics.Color +import android.util.Log +import com.google.gson.Gson +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class Shikimori(private val context: Context, id: Int) : TrackService(id) { + + override fun getScoreList(): List { + return IntRange(0, 10).map(Int::toString) + } + + override fun displayScore(track: Track): String { + return track.score.toInt().toString() + } + + override fun add(track: Track): Observable { + return api.addLibManga(track, getUsername()) + } + + override fun update(track: Track): Observable { + if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { + track.status = COMPLETED + } + return api.updateLibManga(track, getUsername()) + } + + override fun bind(track: Track): Observable { + return api.findLibManga(track, getUsername()) + .flatMap { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + update(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.findLibManga(track, getUsername()) + .map { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + } + track + } + } + + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLANNING = 5 + const val REPEATING = 6 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + } + + override val name = "Shikimori" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { ShikimoriInterceptor(this, gson) } + + private val api by lazy { ShikimoriApi(client, interceptor) } + + override fun getLogo() = R.drawable.shikimori + + override fun getLogoColor() = Color.rgb(40, 40, 40) + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + PLANNING -> getString(R.string.plan_to_read) + REPEATING -> getString(R.string.repeating) + else -> "" + } + } + + override fun login(username: String, password: String) = login(password) + + fun login(code: String): Completable { + return api.accessToken(code).map { oauth: OAuth? -> + interceptor.newAuth(oauth) + if (oauth != null) { + val user = api.getCurrentUser() + saveCredentials(user.toString(), oauth.access_token) + } + }.doOnError { + logout() + }.toCompletable() + } + + fun saveToken(oauth: OAuth?) { + val json = gson.toJson(oauth) + preferences.trackToken(this).set(json) + } + + fun restoreToken(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } + + override fun logout() { + super.logout() + preferences.trackToken(this).set(null) + interceptor.newAuth(null) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt index a34475d4c..7a320ba49 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt @@ -1,154 +1,154 @@ -package eu.kanade.tachiyomi.network - -import android.annotation.SuppressLint -import android.content.Context -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.webkit.WebResourceResponse -import android.webkit.WebSettings -import android.webkit.WebView -import eu.kanade.tachiyomi.util.WebViewClientCompat -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response -import java.io.IOException -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -class CloudflareInterceptor(private val context: Context) : Interceptor { - - private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") - - private val handler = Handler(Looper.getMainLooper()) - - /** - * When this is called, it initializes the WebView if it wasn't already. We use this to avoid - * blocking the main thread too much. If used too often we could consider moving it to the - * Application class. - */ - private val initWebView by lazy { - if (Build.VERSION.SDK_INT >= 17) { - WebSettings.getDefaultUserAgent(context) - } else { - null - } - } - - @Synchronized - override fun intercept(chain: Interceptor.Chain): Response { - initWebView - - val response = chain.proceed(chain.request()) - - // Check if Cloudflare anti-bot is on - if (response.code == 503 && response.header("Server") in serverCheck) { - try { - response.close() - val solutionRequest = resolveWithWebView(chain.request()) - return chain.proceed(solutionRequest) - } catch (e: Exception) { - // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that - // we don't crash the entire app - throw IOException(e) - } - } - - return response - } - - private fun isChallengeSolutionUrl(url: String): Boolean { - return "chk_jschl" in url - } - - @SuppressLint("SetJavaScriptEnabled") - private fun resolveWithWebView(request: Request): Request { - // We need to lock this thread until the WebView finds the challenge solution url, because - // OkHttp doesn't support asynchronous interceptors. - val latch = CountDownLatch(1) - - var webView: WebView? = null - var solutionUrl: String? = null - var challengeFound = false - - val origRequestUrl = request.url.toString() - val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } - - handler.post { - val view = WebView(context) - webView = view - view.settings.javaScriptEnabled = true - view.settings.userAgentString = request.header("User-Agent") - view.webViewClient = object : WebViewClientCompat() { - - override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { - if (isChallengeSolutionUrl(url)) { - solutionUrl = url - latch.countDown() - } - return solutionUrl != null - } - - override fun shouldInterceptRequestCompat( - view: WebView, - url: String - ): WebResourceResponse? { - if (solutionUrl != null) { - // Intercept any request when we have the solution. - return WebResourceResponse("text/plain", "UTF-8", null) - } - return null - } - - override fun onPageFinished(view: WebView, url: String) { - // Http error codes are only received since M - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && - url == origRequestUrl && !challengeFound - ) { - // The first request didn't return the challenge, abort. - latch.countDown() - } - } - - override fun onReceivedErrorCompat( - view: WebView, - errorCode: Int, - description: String?, - failingUrl: String, - isMainFrame: Boolean - ) { - if (isMainFrame) { - if (errorCode == 503) { - // Found the cloudflare challenge page. - challengeFound = true - } else { - // Unlock thread, the challenge wasn't found. - latch.countDown() - } - } - } - } - webView?.loadUrl(origRequestUrl, headers) - } - - // Wait a reasonable amount of time to retrieve the solution. The minimum should be - // around 4 seconds but it can take more due to slow networks or server issues. - latch.await(12, TimeUnit.SECONDS) - - handler.post { - webView?.stopLoading() - webView?.destroy() - } - - val solution = solutionUrl ?: throw Exception("Challenge not found") - - return Request.Builder().get() - .url(solution) - .headers(request.headers) - .addHeader("Referer", origRequestUrl) - .addHeader("Accept", "text/html,application/xhtml+xml,application/xml") - .addHeader("Accept-Language", "en") - .build() - } - -} +package eu.kanade.tachiyomi.network + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import eu.kanade.tachiyomi.util.WebViewClientCompat +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class CloudflareInterceptor(private val context: Context) : Interceptor { + + private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") + + private val handler = Handler(Looper.getMainLooper()) + + /** + * When this is called, it initializes the WebView if it wasn't already. We use this to avoid + * blocking the main thread too much. If used too often we could consider moving it to the + * Application class. + */ + private val initWebView by lazy { + if (Build.VERSION.SDK_INT >= 17) { + WebSettings.getDefaultUserAgent(context) + } else { + null + } + } + + @Synchronized + override fun intercept(chain: Interceptor.Chain): Response { + initWebView + + val response = chain.proceed(chain.request()) + + // Check if Cloudflare anti-bot is on + if (response.code == 503 && response.header("Server") in serverCheck) { + try { + response.close() + val solutionRequest = resolveWithWebView(chain.request()) + return chain.proceed(solutionRequest) + } catch (e: Exception) { + // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that + // we don't crash the entire app + throw IOException(e) + } + } + + return response + } + + private fun isChallengeSolutionUrl(url: String): Boolean { + return "chk_jschl" in url + } + + @SuppressLint("SetJavaScriptEnabled") + private fun resolveWithWebView(request: Request): Request { + // We need to lock this thread until the WebView finds the challenge solution url, because + // OkHttp doesn't support asynchronous interceptors. + val latch = CountDownLatch(1) + + var webView: WebView? = null + var solutionUrl: String? = null + var challengeFound = false + + val origRequestUrl = request.url.toString() + val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } + + handler.post { + val view = WebView(context) + webView = view + view.settings.javaScriptEnabled = true + view.settings.userAgentString = request.header("User-Agent") + view.webViewClient = object : WebViewClientCompat() { + + override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { + if (isChallengeSolutionUrl(url)) { + solutionUrl = url + latch.countDown() + } + return solutionUrl != null + } + + override fun shouldInterceptRequestCompat( + view: WebView, + url: String + ): WebResourceResponse? { + if (solutionUrl != null) { + // Intercept any request when we have the solution. + return WebResourceResponse("text/plain", "UTF-8", null) + } + return null + } + + override fun onPageFinished(view: WebView, url: String) { + // Http error codes are only received since M + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + url == origRequestUrl && !challengeFound + ) { + // The first request didn't return the challenge, abort. + latch.countDown() + } + } + + override fun onReceivedErrorCompat( + view: WebView, + errorCode: Int, + description: String?, + failingUrl: String, + isMainFrame: Boolean + ) { + if (isMainFrame) { + if (errorCode == 503) { + // Found the cloudflare challenge page. + challengeFound = true + } else { + // Unlock thread, the challenge wasn't found. + latch.countDown() + } + } + } + } + webView?.loadUrl(origRequestUrl, headers) + } + + // Wait a reasonable amount of time to retrieve the solution. The minimum should be + // around 4 seconds but it can take more due to slow networks or server issues. + latch.await(12, TimeUnit.SECONDS) + + handler.post { + webView?.stopLoading() + webView?.destroy() + } + + val solution = solutionUrl ?: throw Exception("Challenge not found") + + return Request.Builder().get() + .url(solution) + .headers(request.headers) + .addHeader("Referer", origRequestUrl) + .addHeader("Accept", "text/html,application/xhtml+xml,application/xml") + .addHeader("Accept-Language", "en") + .build() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index 60893a7e3..fa0b70660 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -1,117 +1,117 @@ -package eu.kanade.tachiyomi.network - -import android.content.Context -import android.os.Build -import okhttp3.* -import java.io.File -import java.io.IOException -import java.net.InetAddress -import java.net.Socket -import java.net.UnknownHostException -import java.security.KeyManagementException -import java.security.KeyStore -import java.security.NoSuchAlgorithmException -import javax.net.ssl.* - -class NetworkHelper(context: Context) { - - private val cacheDir = File(context.cacheDir, "network_cache") - - private val cacheSize = 5L * 1024 * 1024 // 5 MiB - - val cookieManager = AndroidCookieJar(context) - - val client = OkHttpClient.Builder() - .cookieJar(cookieManager) - .cache(Cache(cacheDir, cacheSize)) - .enableTLS12() - .build() - - val cloudflareClient = client.newBuilder() - .addInterceptor(CloudflareInterceptor(context)) - .build() - - private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { - return this - } - - val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - trustManagerFactory.init(null as KeyStore?) - val trustManagers = trustManagerFactory.trustManagers - if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { - class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) - constructor() : SSLSocketFactory() { - - private val internalSSLSocketFactory: SSLSocketFactory - - init { - val context = SSLContext.getInstance("TLS") - context.init(null, null, null) - internalSSLSocketFactory = context.socketFactory - } - - override fun getDefaultCipherSuites(): Array { - return internalSSLSocketFactory.defaultCipherSuites - } - - override fun getSupportedCipherSuites(): Array { - return internalSSLSocketFactory.supportedCipherSuites - } - - @Throws(IOException::class) - override fun createSocket(): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket()) - } - - @Throws(IOException::class) - override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)) - } - - @Throws(IOException::class, UnknownHostException::class) - override fun createSocket(host: String, port: Int): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) - } - - @Throws(IOException::class, UnknownHostException::class) - override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)) - } - - @Throws(IOException::class) - override fun createSocket(host: InetAddress, port: Int): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) - } - - @Throws(IOException::class) - override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)) - } - - private fun enableTLSOnSocket(socket: Socket?): Socket? { - if (socket != null && socket is SSLSocket) { - socket.enabledProtocols = socket.supportedProtocols - } - return socket - } - } - - sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager) - } - - val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) - .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) - .cipherSuites( - *ConnectionSpec.MODERN_TLS.cipherSuites.orEmpty().toTypedArray(), - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA - ) - .build() - - val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT) - connectionSpecs(specs) - - return this - } -} +package eu.kanade.tachiyomi.network + +import android.content.Context +import android.os.Build +import okhttp3.* +import java.io.File +import java.io.IOException +import java.net.InetAddress +import java.net.Socket +import java.net.UnknownHostException +import java.security.KeyManagementException +import java.security.KeyStore +import java.security.NoSuchAlgorithmException +import javax.net.ssl.* + +class NetworkHelper(context: Context) { + + private val cacheDir = File(context.cacheDir, "network_cache") + + private val cacheSize = 5L * 1024 * 1024 // 5 MiB + + val cookieManager = AndroidCookieJar(context) + + val client = OkHttpClient.Builder() + .cookieJar(cookieManager) + .cache(Cache(cacheDir, cacheSize)) + .enableTLS12() + .build() + + val cloudflareClient = client.newBuilder() + .addInterceptor(CloudflareInterceptor(context)) + .build() + + private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { + return this + } + + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + val trustManagers = trustManagerFactory.trustManagers + if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { + class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) + constructor() : SSLSocketFactory() { + + private val internalSSLSocketFactory: SSLSocketFactory + + init { + val context = SSLContext.getInstance("TLS") + context.init(null, null, null) + internalSSLSocketFactory = context.socketFactory + } + + override fun getDefaultCipherSuites(): Array { + return internalSSLSocketFactory.defaultCipherSuites + } + + override fun getSupportedCipherSuites(): Array { + return internalSSLSocketFactory.supportedCipherSuites + } + + @Throws(IOException::class) + override fun createSocket(): Socket? { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket()) + } + + @Throws(IOException::class) + override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)) + } + + @Throws(IOException::class, UnknownHostException::class) + override fun createSocket(host: String, port: Int): Socket? { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) + } + + @Throws(IOException::class, UnknownHostException::class) + override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)) + } + + @Throws(IOException::class) + override fun createSocket(host: InetAddress, port: Int): Socket? { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) + } + + @Throws(IOException::class) + override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)) + } + + private fun enableTLSOnSocket(socket: Socket?): Socket? { + if (socket != null && socket is SSLSocket) { + socket.enabledProtocols = socket.supportedProtocols + } + return socket + } + } + + sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager) + } + + val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) + .cipherSuites( + *ConnectionSpec.MODERN_TLS.cipherSuites.orEmpty().toTypedArray(), + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA + ) + .build() + + val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT) + connectionSpecs(specs) + + return this + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 905113352..6f0bb5f08 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -1,70 +1,70 @@ -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.unsafeCreate { 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.unsafeCreate { 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) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt index 113f99763..4bebcf87d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt @@ -1,5 +1,5 @@ -package eu.kanade.tachiyomi.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/network/ProgressResponseBody.kt b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt index a90a04576..8308acc1c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt @@ -1,40 +1,40 @@ -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 { - source(responseBody.source()).buffer() - } - - 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) { - 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 { + source(responseBody.source()).buffer() + } + + 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) { + 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 + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt index 3b89d0d88..9b2697a51 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt @@ -1,32 +1,32 @@ -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() -} +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 index f8d0ea464..f5f11a00b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt @@ -1,46 +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 +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/Source.kt b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt index 666621bb4..7a5f43a84 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -1,44 +1,44 @@ -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> - +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 index a8d0fed16..969f789ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -1,74 +1,74 @@ -package eu.kanade.tachiyomi.source - -import android.content.Context -import eu.kanade.tachiyomi.R -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.HttpSource -import rx.Observable - -open class SourceManager(private val context: Context) { - - private val sourcesMap = mutableMapOf() - - private val stubSourcesMap = mutableMapOf() - - init { - createInternalSources().forEach { registerSource(it) } - } - - open fun get(sourceKey: Long): Source? { - return sourcesMap[sourceKey] - } - - fun getOrStub(sourceKey: Long): Source { - return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) { - StubSource(sourceKey) - } - } - - fun getOnlineSources() = sourcesMap.values.filterIsInstance() - - fun getCatalogueSources() = sourcesMap.values.filterIsInstance() - - internal fun registerSource(source: Source, overwrite: Boolean = false) { - if (overwrite || !sourcesMap.containsKey(source.id)) { - sourcesMap[source.id] = source - } - } - - internal fun unregisterSource(source: Source) { - sourcesMap.remove(source.id) - } - - private fun createInternalSources(): List = listOf( - LocalSource(context) - ) - - private inner class StubSource(override val id: Long) : Source { - - override val name: String - get() = id.toString() - - override fun fetchMangaDetails(manga: SManga): Observable { - return Observable.error(getSourceNotInstalledException()) - } - - override fun fetchChapterList(manga: SManga): Observable> { - return Observable.error(getSourceNotInstalledException()) - } - - override fun fetchPageList(chapter: SChapter): Observable> { - return Observable.error(getSourceNotInstalledException()) - } - - override fun toString(): String { - return name - } - - private fun getSourceNotInstalledException(): Exception { - return Exception(context.getString(R.string.source_not_installed, id.toString())) - } - } -} +package eu.kanade.tachiyomi.source + +import android.content.Context +import eu.kanade.tachiyomi.R +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.HttpSource +import rx.Observable + +open class SourceManager(private val context: Context) { + + private val sourcesMap = mutableMapOf() + + private val stubSourcesMap = mutableMapOf() + + init { + createInternalSources().forEach { registerSource(it) } + } + + open fun get(sourceKey: Long): Source? { + return sourcesMap[sourceKey] + } + + fun getOrStub(sourceKey: Long): Source { + return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) { + StubSource(sourceKey) + } + } + + fun getOnlineSources() = sourcesMap.values.filterIsInstance() + + fun getCatalogueSources() = sourcesMap.values.filterIsInstance() + + internal fun registerSource(source: Source, overwrite: Boolean = false) { + if (overwrite || !sourcesMap.containsKey(source.id)) { + sourcesMap[source.id] = source + } + } + + internal fun unregisterSource(source: Source) { + sourcesMap.remove(source.id) + } + + private fun createInternalSources(): List = listOf( + LocalSource(context) + ) + + private inner class StubSource(override val id: Long) : Source { + + override val name: String + get() = id.toString() + + override fun fetchMangaDetails(manga: SManga): Observable { + return Observable.error(getSourceNotInstalledException()) + } + + override fun fetchChapterList(manga: SManga): Observable> { + return Observable.error(getSourceNotInstalledException()) + } + + override fun fetchPageList(chapter: SChapter): Observable> { + return Observable.error(getSourceNotInstalledException()) + } + + override fun toString(): String { + return name + } + + private fun getSourceNotInstalledException(): Exception { + return Exception(context.getString(R.string.source_not_installed, id.toString())) + } + } +} 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 index 1664d67eb..8cd520d22 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt @@ -1,40 +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 - } - +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 index 36d8e144a..e24db65b6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt @@ -1,7 +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()) - +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 index e359619fb..12dd172a7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt @@ -1,3 +1,3 @@ -package eu.kanade.tachiyomi.source.model - +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/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt index 00cb40e55..c06a59a88 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt @@ -1,48 +1,48 @@ -package eu.kanade.tachiyomi.source.model - -import android.net.Uri -import eu.kanade.tachiyomi.network.ProgressListener -import rx.subjects.Subject - -open class Page( - val index: Int, - val url: String = "", - var imageUrl: String? = null, - @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions -) : ProgressListener { - - val number: Int - get() = index + 1 - - @Transient @Volatile var status: Int = 0 - set(value) { - field = value - statusSubject?.onNext(value) - } - - @Transient @Volatile var progress: Int = 0 - - @Transient private var statusSubject: Subject? = null - - override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { - progress = if (contentLength > 0) { - (100 * bytesRead / contentLength).toInt() - } else { - -1 - } - } - - fun setStatusSubject(subject: Subject?) { - this.statusSubject = subject - } - - companion object { - - const val QUEUE = 0 - const val LOAD_PAGE = 1 - const val DOWNLOAD_IMAGE = 2 - const val READY = 3 - const val ERROR = 4 - } - -} +package eu.kanade.tachiyomi.source.model + +import android.net.Uri +import eu.kanade.tachiyomi.network.ProgressListener +import rx.subjects.Subject + +open class Page( + val index: Int, + val url: String = "", + var imageUrl: String? = null, + @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions +) : ProgressListener { + + val number: Int + get() = index + 1 + + @Transient @Volatile var status: Int = 0 + set(value) { + field = value + statusSubject?.onNext(value) + } + + @Transient @Volatile var progress: Int = 0 + + @Transient private var statusSubject: Subject? = null + + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + progress = if (contentLength > 0) { + (100 * bytesRead / contentLength).toInt() + } else { + -1 + } + } + + fun setStatusSubject(subject: Subject?) { + this.statusSubject = subject + } + + companion object { + + const val QUEUE = 0 + const val LOAD_PAGE = 1 + const val DOWNLOAD_IMAGE = 2 + const val READY = 3 + const val ERROR = 4 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt index 991d24d41..0017e51d6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt @@ -1,31 +1,31 @@ -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 - - var scanlator: String? - - fun copyFrom(other: SChapter) { - name = other.name - url = other.url - date_upload = other.date_upload - chapter_number = other.chapter_number - scanlator = other.scanlator - } - - companion object { - fun create(): SChapter { - return SChapterImpl() - } - } - +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 + + var scanlator: String? + + fun copyFrom(other: SChapter) { + name = other.name + url = other.url + date_upload = other.date_upload + chapter_number = other.chapter_number + scanlator = other.scanlator + } + + 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 index cfc4c3999..4fa55141f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt @@ -1,15 +1,15 @@ -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 - - override var scanlator: String? = null - +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 + + override var scanlator: String? = null + } \ 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 index 8a1ba1af0..3e3ef8206 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt @@ -1,58 +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() - } - } - +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 index 30635897b..3dbba4b99 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt @@ -1,23 +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 - +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 index cb76f1162..86b020be3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -1,367 +1,367 @@ -package eu.kanade.tachiyomi.source.online - -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.lang.Exception -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 that a source may need. -// */ -// val preferences: SharedPreferences by lazy { -// Injekt.get().getSharedPreferences("source_$id", Context.MODE_PRIVATE) -// } - - /** - * 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. If a manga is licensed an empty chapter list observable is returned - * - * @param manga the manga to look for chapters. - */ - override fun fetchChapterList(manga: SManga): Observable> { - if (manga.status != SManga.LICENSED) { - return client.newCall(chapterListRequest(manga)) - .asObservableSuccess() - .map { response -> - chapterListParse(response) - } - } else { - return Observable.error(Exception("Licensed - No chapters to show")) - } - } - - /** - * 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() -} +package eu.kanade.tachiyomi.source.online + +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.lang.Exception +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 that a source may need. +// */ +// val preferences: SharedPreferences by lazy { +// Injekt.get().getSharedPreferences("source_$id", Context.MODE_PRIVATE) +// } + + /** + * 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. If a manga is licensed an empty chapter list observable is returned + * + * @param manga the manga to look for chapters. + */ + override fun fetchChapterList(manga: SManga): Observable> { + if (manga.status != SManga.LICENSED) { + return client.newCall(chapterListRequest(manga)) + .asObservableSuccess() + .map { response -> + chapterListParse(response) + } + } else { + return Observable.error(Exception("Licensed - No chapters to show")) + } + } + + /** + * 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 index e69581df3..2c8f2d0b8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt @@ -1,25 +1,25 @@ -package eu.kanade.tachiyomi.source.online - -import eu.kanade.tachiyomi.source.model.Page -import rx.Observable - -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 } -} - -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) } -} +package eu.kanade.tachiyomi.source.online + +import eu.kanade.tachiyomi.source.model.Page +import rx.Observable + +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 } +} + +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/source/online/LoginSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt index 61ec4fd35..8aae073e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt @@ -1,15 +1,15 @@ -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 - +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 index 6053fc2b6..03d58d56a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt @@ -1,200 +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 -} +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/ui/base/controller/NucleusController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt index 3f252409c..4df8dbd3f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt @@ -1,21 +1,21 @@ -package eu.kanade.tachiyomi.ui.base.controller - -import android.os.Bundle -import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate -import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener -import nucleus.factory.PresenterFactory -import nucleus.presenter.Presenter - -@Suppress("LeakingThis") -abstract class NucleusController

>(val bundle: Bundle? = null) : RxController(bundle), - PresenterFactory

{ - - private val delegate = NucleusConductorDelegate(this) - - val presenter: P - get() = delegate.presenter - - init { - addLifecycleListener(NucleusConductorLifecycleListener(delegate)) - } -} +package eu.kanade.tachiyomi.ui.base.controller + +import android.os.Bundle +import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate +import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener +import nucleus.factory.PresenterFactory +import nucleus.presenter.Presenter + +@Suppress("LeakingThis") +abstract class NucleusController

>(val bundle: Bundle? = null) : RxController(bundle), + PresenterFactory

{ + + private val delegate = NucleusConductorDelegate(this) + + val presenter: P + get() = delegate.presenter + + init { + addLifecycleListener(NucleusConductorLifecycleListener(delegate)) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.java b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.java index 99642a501..46034fbf8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.java +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.java @@ -1,61 +1,61 @@ -package eu.kanade.tachiyomi.ui.base.presenter; - -import android.os.Bundle; -import androidx.annotation.Nullable; - -import nucleus.factory.PresenterFactory; -import nucleus.presenter.Presenter; - -public class NucleusConductorDelegate

{ - - @Nullable private P presenter; - @Nullable private Bundle bundle; - - private PresenterFactory

factory; - - public NucleusConductorDelegate(PresenterFactory

creator) { - this.factory = creator; - } - - public P getPresenter() { - if (presenter == null) { - presenter = factory.createPresenter(); - presenter.create(bundle); - bundle = null; - } - return presenter; - } - - Bundle onSaveInstanceState() { - Bundle bundle = new Bundle(); -// getPresenter(); // Workaround a crash related to saving instance state with child routers - if (presenter != null) { - presenter.save(bundle); - } - return bundle; - } - - void onRestoreInstanceState(Bundle presenterState) { - bundle = presenterState; - } - - void onTakeView(Object view) { - getPresenter(); - if (presenter != null) { - //noinspection unchecked - presenter.takeView(view); - } - } - - void onDropView() { - if (presenter != null) { - presenter.dropView(); - } - } - - void onDestroy() { - if (presenter != null) { - presenter.destroy(); - } - } -} +package eu.kanade.tachiyomi.ui.base.presenter; + +import android.os.Bundle; +import androidx.annotation.Nullable; + +import nucleus.factory.PresenterFactory; +import nucleus.presenter.Presenter; + +public class NucleusConductorDelegate

{ + + @Nullable private P presenter; + @Nullable private Bundle bundle; + + private PresenterFactory

factory; + + public NucleusConductorDelegate(PresenterFactory

creator) { + this.factory = creator; + } + + public P getPresenter() { + if (presenter == null) { + presenter = factory.createPresenter(); + presenter.create(bundle); + bundle = null; + } + return presenter; + } + + Bundle onSaveInstanceState() { + Bundle bundle = new Bundle(); +// getPresenter(); // Workaround a crash related to saving instance state with child routers + if (presenter != null) { + presenter.save(bundle); + } + return bundle; + } + + void onRestoreInstanceState(Bundle presenterState) { + bundle = presenterState; + } + + void onTakeView(Object view) { + getPresenter(); + if (presenter != null) { + //noinspection unchecked + presenter.takeView(view); + } + } + + void onDropView() { + if (presenter != null) { + presenter.dropView(); + } + } + + void onDestroy() { + if (presenter != null) { + presenter.destroy(); + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.java b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.java index 36890cd1b..90d94e5d4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.java +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.java @@ -1,44 +1,44 @@ -package eu.kanade.tachiyomi.ui.base.presenter; - -import android.os.Bundle; -import androidx.annotation.NonNull; -import android.view.View; - -import com.bluelinelabs.conductor.Controller; - -public class NucleusConductorLifecycleListener extends Controller.LifecycleListener { - - private static final String PRESENTER_STATE_KEY = "presenter_state"; - - private NucleusConductorDelegate delegate; - - public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) { - this.delegate = delegate; - } - - @Override - public void postCreateView(@NonNull Controller controller, @NonNull View view) { - delegate.onTakeView(controller); - } - - @Override - public void preDestroyView(@NonNull Controller controller, @NonNull View view) { - delegate.onDropView(); - } - - @Override - public void preDestroy(@NonNull Controller controller) { - delegate.onDestroy(); - } - - @Override - public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) { - outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState()); - } - - @Override - public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) { - delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY)); - } - -} +package eu.kanade.tachiyomi.ui.base.presenter; + +import android.os.Bundle; +import androidx.annotation.NonNull; +import android.view.View; + +import com.bluelinelabs.conductor.Controller; + +public class NucleusConductorLifecycleListener extends Controller.LifecycleListener { + + private static final String PRESENTER_STATE_KEY = "presenter_state"; + + private NucleusConductorDelegate delegate; + + public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) { + this.delegate = delegate; + } + + @Override + public void postCreateView(@NonNull Controller controller, @NonNull View view) { + delegate.onTakeView(controller); + } + + @Override + public void preDestroyView(@NonNull Controller controller, @NonNull View view) { + delegate.onDropView(); + } + + @Override + public void preDestroy(@NonNull Controller controller) { + delegate.onDestroy(); + } + + @Override + public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) { + outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState()); + } + + @Override + public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) { + delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY)); + } + +} 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 index c55113c16..27e1efa5e 100644 --- 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 @@ -1,88 +1,88 @@ -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 (javaClass != other?.javaClass) return false - return filter == (other as TriStateSectionItem).filter - } - - 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 (javaClass != other?.javaClass) return false - return filter == (other as TextSectionItem).filter - } - - 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 (javaClass != other?.javaClass) return false - return filter == (other as CheckboxSectionItem).filter - } - - 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 (javaClass != other?.javaClass) return false - return filter == (other as SelectSectionItem).filter - } - - override fun hashCode(): Int { - return filter.hashCode() - } -} +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 (javaClass != other?.javaClass) return false + return filter == (other as TriStateSectionItem).filter + } + + 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 (javaClass != other?.javaClass) return false + return filter == (other as TextSectionItem).filter + } + + 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 (javaClass != other?.javaClass) return false + return filter == (other as CheckboxSectionItem).filter + } + + 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 (javaClass != other?.javaClass) return false + return filter == (other as SelectSectionItem).filter + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} 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 index fba525832..bb253d7b4 100644 --- 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 @@ -1,54 +1,54 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem -import eu.davidea.flexibleadapter.items.IFlexible -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>() { - - init { - isExpanded = false - } - - override fun getLayoutRes(): Int { - return R.layout.navigation_view_group - } - - override fun getItemViewType(): Int { - return 100 - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { - return Holder(view, 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) - - holder.itemView.setOnClickListener(holder) - - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return filter == (other as SortGroup).filter - } - - override fun hashCode(): Int { - return filter.hashCode() - } - - class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter) -} +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem +import eu.davidea.flexibleadapter.items.IFlexible +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>() { + + init { + isExpanded = false + } + + override fun getLayoutRes(): Int { + return R.layout.navigation_view_group + } + + override fun getItemViewType(): Int { + return 100 + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { + return Holder(view, 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) + + holder.itemView.setOnClickListener(holder) + + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return filter == (other as SortGroup).filter + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardAdapter.kt index 9b3b71b0a..07ba3b8e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardAdapter.kt @@ -1,28 +1,28 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search - -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.data.database.models.Manga - -/** - * Adapter that holds the manga items from search results. - * - * @param controller instance of [CatalogueSearchController]. - */ -class CatalogueSearchCardAdapter(controller: CatalogueSearchController) : - FlexibleAdapter(null, controller, true) { - - /** - * Listen for browse item clicks. - */ - val mangaClickListener: OnMangaClickListener = controller - - /** - * Listener which should be called when user clicks browse. - * Note: Should only be handled by [CatalogueSearchController] - */ - interface OnMangaClickListener { - fun onMangaClick(manga: Manga) - fun onMangaLongClick(manga: Manga) - } - +package eu.kanade.tachiyomi.ui.catalogue.global_search + +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.database.models.Manga + +/** + * Adapter that holds the manga items from search results. + * + * @param controller instance of [CatalogueSearchController]. + */ +class CatalogueSearchCardAdapter(controller: CatalogueSearchController) : + FlexibleAdapter(null, controller, true) { + + /** + * Listen for browse item clicks. + */ + val mangaClickListener: OnMangaClickListener = controller + + /** + * Listener which should be called when user clicks browse. + * Note: Should only be handled by [CatalogueSearchController] + */ + interface OnMangaClickListener { + fun onMangaClick(manga: Manga) + fun onMangaLongClick(manga: Manga) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardHolder.kt index cf21e7437..f6ad6f6eb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardHolder.kt @@ -1,52 +1,52 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search - -import android.view.View -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.widget.StateImageViewTarget -import kotlinx.android.synthetic.main.catalogue_global_search_controller_card_item.* - -class CatalogueSearchCardHolder(view: View, adapter: CatalogueSearchCardAdapter) - : BaseFlexibleViewHolder(view, adapter) { - - init { - // Call onMangaClickListener when item is pressed. - itemView.setOnClickListener { - val item = adapter.getItem(adapterPosition) - if (item != null) { - adapter.mangaClickListener.onMangaClick(item.manga) - } - } - itemView.setOnLongClickListener { - val item = adapter.getItem(adapterPosition) - if (item != null) { - adapter.mangaClickListener.onMangaLongClick(item.manga) - } - true - } - } - - fun bind(manga: Manga) { - tvTitle.text = manga.title - // Set alpha of thumbnail. - itemImage.alpha = if (manga.favorite) 0.3f else 1.0f - - setImage(manga) - } - - fun setImage(manga: Manga) { - GlideApp.with(itemView.context).clear(itemImage) - if (!manga.thumbnail_url.isNullOrEmpty()) { - GlideApp.with(itemView.context) - .load(manga) - .diskCacheStrategy(DiskCacheStrategy.DATA) - .centerCrop() - .skipMemoryCache(true) - .placeholder(android.R.color.transparent) - .into(StateImageViewTarget(itemImage, progress)) - } - } - +package eu.kanade.tachiyomi.ui.catalogue.global_search + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.widget.StateImageViewTarget +import kotlinx.android.synthetic.main.catalogue_global_search_controller_card_item.* + +class CatalogueSearchCardHolder(view: View, adapter: CatalogueSearchCardAdapter) + : BaseFlexibleViewHolder(view, adapter) { + + init { + // Call onMangaClickListener when item is pressed. + itemView.setOnClickListener { + val item = adapter.getItem(adapterPosition) + if (item != null) { + adapter.mangaClickListener.onMangaClick(item.manga) + } + } + itemView.setOnLongClickListener { + val item = adapter.getItem(adapterPosition) + if (item != null) { + adapter.mangaClickListener.onMangaLongClick(item.manga) + } + true + } + } + + fun bind(manga: Manga) { + tvTitle.text = manga.title + // Set alpha of thumbnail. + itemImage.alpha = if (manga.favorite) 0.3f else 1.0f + + setImage(manga) + } + + fun setImage(manga: Manga) { + GlideApp.with(itemView.context).clear(itemImage) + if (!manga.thumbnail_url.isNullOrEmpty()) { + GlideApp.with(itemView.context) + .load(manga) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .centerCrop() + .skipMemoryCache(true) + .placeholder(android.R.color.transparent) + .into(StateImageViewTarget(itemImage, progress)) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardItem.kt index 44e790fba..532879e40 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardItem.kt @@ -1,37 +1,37 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga - -class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem() { - - override fun getLayoutRes(): Int { - return R.layout.catalogue_global_search_controller_card_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): CatalogueSearchCardHolder { - return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter) - } - - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: CatalogueSearchCardHolder, - position: Int, payloads: List?) { - holder.bind(manga) - } - - override fun equals(other: Any?): Boolean { - if (other is CatalogueSearchCardItem) { - return manga.id == other.manga.id - } - return false - } - - override fun hashCode(): Int { - return manga.id?.toInt() ?: 0 - } - -} +package eu.kanade.tachiyomi.ui.catalogue.global_search + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga + +class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.catalogue_global_search_controller_card_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): CatalogueSearchCardHolder { + return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: CatalogueSearchCardHolder, + position: Int, payloads: List?) { + holder.bind(manga) + } + + override fun equals(other: Any?): Boolean { + if (other is CatalogueSearchCardItem) { + return manga.id == other.manga.id + } + return false + } + + override fun hashCode(): Int { + return manga.id?.toInt() ?: 0 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt index 8cc6e4aa3..87851de69 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt @@ -1,247 +1,247 @@ -package eu.kanade.tachiyomi.ui.download - -import androidx.recyclerview.widget.LinearLayoutManager -import android.view.* -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import kotlinx.android.synthetic.main.download_controller.* -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import java.util.* -import java.util.concurrent.TimeUnit - -/** - * Controller that shows the currently active downloads. - * Uses R.layout.fragment_download_queue. - */ -class DownloadController : NucleusController() { - - /** - * Adapter containing the active downloads. - */ - private var adapter: DownloadAdapter? = null - - /** - * Map of subscriptions for active downloads. - */ - private val progressSubscriptions by lazy { HashMap() } - - /** - * Whether the download queue is running or not. - */ - private var isRunning: Boolean = false - - init { - setHasOptionsMenu(true) - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.download_controller, container, false) - } - - override fun createPresenter(): DownloadPresenter { - return DownloadPresenter() - } - - override fun getTitle(): String? { - return resources?.getString(R.string.label_download_queue) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - // Check if download queue is empty and update information accordingly. - setInformationView() - - // Initialize adapter. - adapter = DownloadAdapter() - recycler.adapter = adapter - - // Set the layout manager for the recycler and fixed size. - recycler.layoutManager = LinearLayoutManager(view.context) - recycler.setHasFixedSize(true) - - // Suscribe to changes - DownloadService.runningRelay - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { onQueueStatusChange(it) } - - presenter.getDownloadStatusObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { onStatusChange(it) } - - presenter.getDownloadProgressObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { onUpdateDownloadedPages(it) } - } - - override fun onDestroyView(view: View) { - for (subscription in progressSubscriptions.values) { - subscription.unsubscribe() - } - progressSubscriptions.clear() - adapter = null - super.onDestroyView(view) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.download_queue, menu) - } - - override fun onPrepareOptionsMenu(menu: Menu) { - // Set start button visibility. - menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty() - - // Set pause button visibility. - menu.findItem(R.id.pause_queue).isVisible = isRunning - - // Set clear button visibility. - menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val context = applicationContext ?: return false - when (item.itemId) { - R.id.start_queue -> DownloadService.start(context) - R.id.pause_queue -> { - DownloadService.stop(context) - presenter.pauseDownloads() - } - R.id.clear_queue -> { - DownloadService.stop(context) - presenter.clearQueue() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - /** - * Called when the status of a download changes. - * - * @param download the download whose status has changed. - */ - private fun onStatusChange(download: Download) { - when (download.status) { - Download.DOWNLOADING -> { - observeProgress(download) - // Initial update of the downloaded pages - onUpdateDownloadedPages(download) - } - Download.DOWNLOADED -> { - unsubscribeProgress(download) - onUpdateProgress(download) - onUpdateDownloadedPages(download) - } - Download.ERROR -> unsubscribeProgress(download) - } - } - - /** - * Observe the progress of a download and notify the view. - * - * @param download the download to observe its progress. - */ - private fun observeProgress(download: Download) { - val subscription = Observable.interval(50, TimeUnit.MILLISECONDS) - // Get the sum of percentages for all the pages. - .flatMap { - Observable.from(download.pages) - .map(Page::progress) - .reduce { x, y -> x + y } - } - // Keep only the latest emission to avoid backpressure. - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { progress -> - // Update the view only if the progress has changed. - if (download.totalProgress != progress) { - download.totalProgress = progress - onUpdateProgress(download) - } - } - - // Avoid leaking subscriptions - progressSubscriptions.remove(download)?.unsubscribe() - - progressSubscriptions.put(download, subscription) - } - - /** - * Unsubscribes the given download from the progress subscriptions. - * - * @param download the download to unsubscribe. - */ - private fun unsubscribeProgress(download: Download) { - progressSubscriptions.remove(download)?.unsubscribe() - } - - /** - * Called when the queue's status has changed. Updates the visibility of the buttons. - * - * @param running whether the queue is now running or not. - */ - private fun onQueueStatusChange(running: Boolean) { - isRunning = running - activity?.invalidateOptionsMenu() - - // Check if download queue is empty and update information accordingly. - setInformationView() - } - - /** - * Called from the presenter to assign the downloads for the adapter. - * - * @param downloads the downloads from the queue. - */ - fun onNextDownloads(downloads: List) { - activity?.invalidateOptionsMenu() - setInformationView() - adapter?.setItems(downloads) - } - - /** - * Called when the progress of a download changes. - * - * @param download the download whose progress has changed. - */ - fun onUpdateProgress(download: Download) { - getHolder(download)?.notifyProgress() - } - - /** - * Called when a page of a download is downloaded. - * - * @param download the download whose page has been downloaded. - */ - fun onUpdateDownloadedPages(download: Download) { - getHolder(download)?.notifyDownloadedPages() - } - - /** - * Returns the holder for the given download. - * - * @param download the download to find. - * @return the holder of the download or null if it's not bound. - */ - private fun getHolder(download: Download): DownloadHolder? { - return recycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder - } - - /** - * Set information view when queue is empty - */ - private fun setInformationView() { - if (presenter.downloadQueue.isEmpty()) { - empty_view?.show(R.drawable.ic_file_download_black_128dp, - R.string.information_no_downloads) - } else { - empty_view?.hide() - } - } - -} +package eu.kanade.tachiyomi.ui.download + +import androidx.recyclerview.widget.LinearLayoutManager +import android.view.* +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import kotlinx.android.synthetic.main.download_controller.* +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * Controller that shows the currently active downloads. + * Uses R.layout.fragment_download_queue. + */ +class DownloadController : NucleusController() { + + /** + * Adapter containing the active downloads. + */ + private var adapter: DownloadAdapter? = null + + /** + * Map of subscriptions for active downloads. + */ + private val progressSubscriptions by lazy { HashMap() } + + /** + * Whether the download queue is running or not. + */ + private var isRunning: Boolean = false + + init { + setHasOptionsMenu(true) + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.download_controller, container, false) + } + + override fun createPresenter(): DownloadPresenter { + return DownloadPresenter() + } + + override fun getTitle(): String? { + return resources?.getString(R.string.label_download_queue) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + // Check if download queue is empty and update information accordingly. + setInformationView() + + // Initialize adapter. + adapter = DownloadAdapter() + recycler.adapter = adapter + + // Set the layout manager for the recycler and fixed size. + recycler.layoutManager = LinearLayoutManager(view.context) + recycler.setHasFixedSize(true) + + // Suscribe to changes + DownloadService.runningRelay + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { onQueueStatusChange(it) } + + presenter.getDownloadStatusObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { onStatusChange(it) } + + presenter.getDownloadProgressObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { onUpdateDownloadedPages(it) } + } + + override fun onDestroyView(view: View) { + for (subscription in progressSubscriptions.values) { + subscription.unsubscribe() + } + progressSubscriptions.clear() + adapter = null + super.onDestroyView(view) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.download_queue, menu) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + // Set start button visibility. + menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty() + + // Set pause button visibility. + menu.findItem(R.id.pause_queue).isVisible = isRunning + + // Set clear button visibility. + menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val context = applicationContext ?: return false + when (item.itemId) { + R.id.start_queue -> DownloadService.start(context) + R.id.pause_queue -> { + DownloadService.stop(context) + presenter.pauseDownloads() + } + R.id.clear_queue -> { + DownloadService.stop(context) + presenter.clearQueue() + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + /** + * Called when the status of a download changes. + * + * @param download the download whose status has changed. + */ + private fun onStatusChange(download: Download) { + when (download.status) { + Download.DOWNLOADING -> { + observeProgress(download) + // Initial update of the downloaded pages + onUpdateDownloadedPages(download) + } + Download.DOWNLOADED -> { + unsubscribeProgress(download) + onUpdateProgress(download) + onUpdateDownloadedPages(download) + } + Download.ERROR -> unsubscribeProgress(download) + } + } + + /** + * Observe the progress of a download and notify the view. + * + * @param download the download to observe its progress. + */ + private fun observeProgress(download: Download) { + val subscription = Observable.interval(50, TimeUnit.MILLISECONDS) + // Get the sum of percentages for all the pages. + .flatMap { + Observable.from(download.pages) + .map(Page::progress) + .reduce { x, y -> x + y } + } + // Keep only the latest emission to avoid backpressure. + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { progress -> + // Update the view only if the progress has changed. + if (download.totalProgress != progress) { + download.totalProgress = progress + onUpdateProgress(download) + } + } + + // Avoid leaking subscriptions + progressSubscriptions.remove(download)?.unsubscribe() + + progressSubscriptions.put(download, subscription) + } + + /** + * Unsubscribes the given download from the progress subscriptions. + * + * @param download the download to unsubscribe. + */ + private fun unsubscribeProgress(download: Download) { + progressSubscriptions.remove(download)?.unsubscribe() + } + + /** + * Called when the queue's status has changed. Updates the visibility of the buttons. + * + * @param running whether the queue is now running or not. + */ + private fun onQueueStatusChange(running: Boolean) { + isRunning = running + activity?.invalidateOptionsMenu() + + // Check if download queue is empty and update information accordingly. + setInformationView() + } + + /** + * Called from the presenter to assign the downloads for the adapter. + * + * @param downloads the downloads from the queue. + */ + fun onNextDownloads(downloads: List) { + activity?.invalidateOptionsMenu() + setInformationView() + adapter?.setItems(downloads) + } + + /** + * Called when the progress of a download changes. + * + * @param download the download whose progress has changed. + */ + fun onUpdateProgress(download: Download) { + getHolder(download)?.notifyProgress() + } + + /** + * Called when a page of a download is downloaded. + * + * @param download the download whose page has been downloaded. + */ + fun onUpdateDownloadedPages(download: Download) { + getHolder(download)?.notifyDownloadedPages() + } + + /** + * Returns the holder for the given download. + * + * @param download the download to find. + * @return the holder of the download or null if it's not bound. + */ + private fun getHolder(download: Download): DownloadHolder? { + return recycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder + } + + /** + * Set information view when queue is empty + */ + private fun setInformationView() { + if (presenter.downloadQueue.isEmpty()) { + empty_view?.show(R.drawable.ic_file_download_black_128dp, + R.string.information_no_downloads) + } else { + empty_view?.hide() + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt index 08f933c8e..8513ac91e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt @@ -1,48 +1,48 @@ -package eu.kanade.tachiyomi.ui.library - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class ChangeMangaCategoriesDialog(bundle: Bundle? = null) : - DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { - - private var mangas = emptyList() - - private var categories = emptyList() - - private var preselected = emptyArray() - - constructor(target: T, mangas: List, categories: List, - preselected: Array) : this() { - - this.mangas = mangas - this.categories = categories - this.preselected = preselected - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.action_move_category) - .items(categories.map { it.name }) - .itemsCallbackMultiChoice(preselected) { dialog, _, _ -> - val newCategories = dialog.selectedIndices?.map { categories[it] }.orEmpty() - (targetController as? Listener)?.updateCategoriesForMangas(mangas, newCategories) - true - } - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .build() - } - - interface Listener { - fun updateCategoriesForMangas(mangas: List, categories: List) - } - +package eu.kanade.tachiyomi.ui.library + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class ChangeMangaCategoriesDialog(bundle: Bundle? = null) : + DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { + + private var mangas = emptyList() + + private var categories = emptyList() + + private var preselected = emptyArray() + + constructor(target: T, mangas: List, categories: List, + preselected: Array) : this() { + + this.mangas = mangas + this.categories = categories + this.preselected = preselected + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog.Builder(activity!!) + .title(R.string.action_move_category) + .items(categories.map { it.name }) + .itemsCallbackMultiChoice(preselected) { dialog, _, _ -> + val newCategories = dialog.selectedIndices?.map { categories[it] }.orEmpty() + (targetController as? Listener)?.updateCategoriesForMangas(mangas, newCategories) + true + } + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .build() + } + + interface Listener { + fun updateCategoriesForMangas(mangas: List, categories: List) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/DeleteLibraryMangasDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/DeleteLibraryMangasDialog.kt index 1aa376eb8..1cf40828a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/DeleteLibraryMangasDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/DeleteLibraryMangasDialog.kt @@ -1,43 +1,43 @@ -package eu.kanade.tachiyomi.ui.library - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.widget.DialogCheckboxView - -class DeleteLibraryMangasDialog(bundle: Bundle? = null) : - DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener { - - private var mangas = emptyList() - - constructor(target: T, mangas: List) : this() { - this.mangas = mangas - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val view = DialogCheckboxView(activity!!).apply { - setDescription(R.string.confirm_delete_manga) - setOptionDescription(R.string.also_delete_chapters) - } - - return MaterialDialog.Builder(activity!!) - .title(R.string.action_remove) - .customView(view, true) - .positiveText(android.R.string.yes) - .negativeText(android.R.string.no) - .onPositive { _, _ -> - val deleteChapters = view.isChecked() - (targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters) - } - .build() - } - - interface Listener { - fun deleteMangasFromLibrary(mangas: List, deleteChapters: Boolean) - } +package eu.kanade.tachiyomi.ui.library + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.widget.DialogCheckboxView + +class DeleteLibraryMangasDialog(bundle: Bundle? = null) : + DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener { + + private var mangas = emptyList() + + constructor(target: T, mangas: List) : this() { + this.mangas = mangas + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val view = DialogCheckboxView(activity!!).apply { + setDescription(R.string.confirm_delete_manga) + setOptionDescription(R.string.also_delete_chapters) + } + + return MaterialDialog.Builder(activity!!) + .title(R.string.action_remove) + .customView(view, true) + .positiveText(android.R.string.yes) + .negativeText(android.R.string.no) + .onPositive { _, _ -> + val deleteChapters = view.isChecked() + (targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters) + } + .build() + } + + interface Listener { + fun deleteMangasFromLibrary(mangas: List, deleteChapters: Boolean) + } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt index 1557a0edd..061ac919a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt @@ -1,103 +1,103 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.View -import android.view.ViewGroup -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.util.inflate -import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter - -/** - * This adapter stores the categories from the library, used with a ViewPager. - * - * @constructor creates an instance of the adapter. - */ -class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() { - - /** - * The categories to bind in the adapter. - */ - var categories: List = emptyList() - // This setter helps to not refresh the adapter if the reference to the list doesn't change. - set(value) { - if (field !== value) { - field = value - notifyDataSetChanged() - } - } - - private var boundViews = arrayListOf() - - /** - * Creates a new view for this adapter. - * - * @return a new view. - */ - override fun createView(container: ViewGroup): View { - val view = container.inflate(R.layout.library_category) as LibraryCategoryView - view.onCreate(controller) - return view - } - - /** - * Binds a view with a position. - * - * @param view the view to bind. - * @param position the position in the adapter. - */ - override fun bindView(view: View, position: Int) { - (view as LibraryCategoryView).onBind(categories[position]) - boundViews.add(view) - } - - /** - * Recycles a view. - * - * @param view the view to recycle. - * @param position the position in the adapter. - */ - override fun recycleView(view: View, position: Int) { - (view as LibraryCategoryView).onRecycle() - boundViews.remove(view) - } - - /** - * Returns the number of categories. - * - * @return the number of categories or 0 if the list is null. - */ - override fun getCount(): Int { - return categories.size - } - - /** - * Returns the title to display for a category. - * - * @param position the position of the element. - * @return the title to display. - */ - override fun getPageTitle(position: Int): CharSequence { - return categories[position].name - } - - /** - * Returns the position of the view. - */ - override fun getItemPosition(obj: Any): Int { - val view = obj as? LibraryCategoryView ?: return POSITION_NONE - val index = categories.indexOfFirst { it.id == view.category.id } - return if (index == -1) POSITION_NONE else index - } - - /** - * Called when the view of this adapter is being destroyed. - */ - fun onDestroy() { - for (view in boundViews) { - if (view is LibraryCategoryView) { - view.unsubscribe() - } - } - } - +package eu.kanade.tachiyomi.ui.library + +import android.view.View +import android.view.ViewGroup +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter + +/** + * This adapter stores the categories from the library, used with a ViewPager. + * + * @constructor creates an instance of the adapter. + */ +class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() { + + /** + * The categories to bind in the adapter. + */ + var categories: List = emptyList() + // This setter helps to not refresh the adapter if the reference to the list doesn't change. + set(value) { + if (field !== value) { + field = value + notifyDataSetChanged() + } + } + + private var boundViews = arrayListOf() + + /** + * Creates a new view for this adapter. + * + * @return a new view. + */ + override fun createView(container: ViewGroup): View { + val view = container.inflate(R.layout.library_category) as LibraryCategoryView + view.onCreate(controller) + return view + } + + /** + * Binds a view with a position. + * + * @param view the view to bind. + * @param position the position in the adapter. + */ + override fun bindView(view: View, position: Int) { + (view as LibraryCategoryView).onBind(categories[position]) + boundViews.add(view) + } + + /** + * Recycles a view. + * + * @param view the view to recycle. + * @param position the position in the adapter. + */ + override fun recycleView(view: View, position: Int) { + (view as LibraryCategoryView).onRecycle() + boundViews.remove(view) + } + + /** + * Returns the number of categories. + * + * @return the number of categories or 0 if the list is null. + */ + override fun getCount(): Int { + return categories.size + } + + /** + * Returns the title to display for a category. + * + * @param position the position of the element. + * @return the title to display. + */ + override fun getPageTitle(position: Int): CharSequence { + return categories[position].name + } + + /** + * Returns the position of the view. + */ + override fun getItemPosition(obj: Any): Int { + val view = obj as? LibraryCategoryView ?: return POSITION_NONE + val index = categories.indexOfFirst { it.id == view.category.id } + return if (index == -1) POSITION_NONE else index + } + + /** + * Called when the view of this adapter is being destroyed. + */ + fun onDestroy() { + for (view in boundViews) { + if (view is LibraryCategoryView) { + view.unsubscribe() + } + } + } + } \ 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 f98de90a3..455cd304e 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 @@ -1,48 +1,48 @@ -package eu.kanade.tachiyomi.ui.library - -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.data.database.models.Manga - -/** - * Adapter storing a list of manga in a certain category. - * - * @param view the fragment containing this adapter. - */ -class LibraryCategoryAdapter(view: LibraryCategoryView) : - FlexibleAdapter(null, view, true) { - - /** - * The list of manga in this category. - */ - private var mangas: List = emptyList() - - /** - * Sets a list of manga in the adapter. - * - * @param list the list to set. - */ - fun setItems(list: List) { - // A copy of manga always unfiltered. - mangas = list.toList() - - performFilter() - } - - /** - * Returns the position in the adapter for the given manga. - * - * @param manga the manga to find. - */ - fun indexOf(manga: Manga): Int { - return currentItems.indexOfFirst { it.manga.id == manga.id } - } - - fun performFilter() { - var s = getFilter(String::class.java) - if (s == null) { - s = "" - } - updateDataSet(mangas.filter { it.filter(s) }) - } - -} +package eu.kanade.tachiyomi.ui.library + +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.database.models.Manga + +/** + * Adapter storing a list of manga in a certain category. + * + * @param view the fragment containing this adapter. + */ +class LibraryCategoryAdapter(view: LibraryCategoryView) : + FlexibleAdapter(null, view, true) { + + /** + * The list of manga in this category. + */ + private var mangas: List = emptyList() + + /** + * Sets a list of manga in the adapter. + * + * @param list the list to set. + */ + fun setItems(list: List) { + // A copy of manga always unfiltered. + mangas = list.toList() + + performFilter() + } + + /** + * Returns the position in the adapter for the given manga. + * + * @param manga the manga to find. + */ + fun indexOf(manga: Manga): Int { + return currentItems.indexOfFirst { it.manga.id == manga.id } + } + + fun performFilter() { + var s = getFilter(String::class.java) + if (s == null) { + s = "" + } + updateDataSet(mangas.filter { it.filter(s) }) + } + +} 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 6221ca00f..bf3859dba 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 @@ -1,248 +1,248 @@ -package eu.kanade.tachiyomi.ui.library - -import android.content.Context -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import android.util.AttributeSet -import android.view.View -import android.widget.FrameLayout -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.SelectableAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.util.inflate -import eu.kanade.tachiyomi.util.plusAssign -import eu.kanade.tachiyomi.util.toast -import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import kotlinx.android.synthetic.main.library_category.view.* -import rx.subscriptions.CompositeSubscription -import uy.kohesive.injekt.injectLazy - -/** - * Fragment containing the library manga for a certain category. - */ -class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - FrameLayout(context, attrs), - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener { - - /** - * Preferences. - */ - private val preferences: PreferencesHelper by injectLazy() - - /** - * The fragment containing this view. - */ - private lateinit var controller: LibraryController - - /** - * Category for this view. - */ - lateinit var category: Category - private set - - /** - * Recycler view of the list of manga. - */ - private lateinit var recycler: RecyclerView - - /** - * Adapter to hold the manga in this category. - */ - private lateinit var adapter: LibraryCategoryAdapter - - /** - * Subscriptions while the view is bound. - */ - private var subscriptions = CompositeSubscription() - - fun onCreate(controller: LibraryController) { - this.controller = controller - - recycler = if (preferences.libraryAsList().getOrDefault()) { - (swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply { - layoutManager = LinearLayoutManager(context) - } - } else { - (swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply { - spanCount = controller.mangaPerRow - } - } - - adapter = LibraryCategoryAdapter(this) - - recycler.setHasFixedSize(true) - recycler.adapter = adapter - swipe_refresh.addView(recycler) - - recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) { - // Disable swipe refresh when view is not at the top - val firstPos = (recycler.layoutManager as LinearLayoutManager) - .findFirstCompletelyVisibleItemPosition() - swipe_refresh.isEnabled = firstPos <= 0 - } - }) - - // Double the distance required to trigger sync - swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt()) - swipe_refresh.setOnRefreshListener { - if (!LibraryUpdateService.isRunning(context)) { - LibraryUpdateService.start(context, category) - context.toast(R.string.updating_category) - } - // It can be a very long operation, so we disable swipe refresh and show a toast. - swipe_refresh.isRefreshing = false - } - } - - fun onBind(category: Category) { - this.category = category - - adapter.mode = if (controller.selectedMangas.isNotEmpty()) { - SelectableAdapter.Mode.MULTI - } else { - SelectableAdapter.Mode.SINGLE - } - - subscriptions += controller.searchRelay - .doOnNext { adapter.setFilter(it) } - .skip(1) - .subscribe { adapter.performFilter() } - - subscriptions += controller.libraryMangaRelay - .subscribe { onNextLibraryManga(it) } - - subscriptions += controller.selectionRelay - .subscribe { onSelectionChanged(it) } - } - - fun onRecycle() { - adapter.setItems(emptyList()) - adapter.clearSelection() - unsubscribe() - } - - fun unsubscribe() { - subscriptions.clear() - } - - /** - * Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the - * adapter. - * - * @param event the event received. - */ - fun onNextLibraryManga(event: LibraryMangaEvent) { - // Get the manga list for this category. - val mangaForCategory = event.getMangaForCategory(category).orEmpty() - - // Update the category with its manga. - adapter.setItems(mangaForCategory) - - if (adapter.mode == SelectableAdapter.Mode.MULTI) { - controller.selectedMangas.forEach { manga -> - val position = adapter.indexOf(manga) - if (position != -1 && !adapter.isSelected(position)) { - adapter.toggleSelection(position) - (recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation() - } - } - } - } - - /** - * Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection - * depending on the type of event received. - * - * @param event the selection event received. - */ - private fun onSelectionChanged(event: LibrarySelectionEvent) { - when (event) { - is LibrarySelectionEvent.Selected -> { - if (adapter.mode != SelectableAdapter.Mode.MULTI) { - adapter.mode = SelectableAdapter.Mode.MULTI - } - findAndToggleSelection(event.manga) - } - is LibrarySelectionEvent.Unselected -> { - findAndToggleSelection(event.manga) - if (controller.selectedMangas.isEmpty()) { - adapter.mode = SelectableAdapter.Mode.SINGLE - } - } - is LibrarySelectionEvent.Cleared -> { - adapter.mode = SelectableAdapter.Mode.SINGLE - adapter.clearSelection() - } - } - } - - /** - * Toggles the selection for the given manga and updates the view if needed. - * - * @param manga the manga to toggle. - */ - private fun findAndToggleSelection(manga: Manga) { - val position = adapter.indexOf(manga) - if (position != -1) { - adapter.toggleSelection(position) - (recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation() - } - } - - /** - * Called when a manga is clicked. - * - * @param position the position of the element clicked. - * @return true if the item should be selected, false otherwise. - */ - override fun onItemClick(view: View, position: Int): Boolean { - // If the action mode is created and the position is valid, toggle the selection. - val item = adapter.getItem(position) ?: return false - if (adapter.mode == SelectableAdapter.Mode.MULTI) { - toggleSelection(position) - return true - } else { - openManga(item.manga) - return false - } - } - - /** - * Called when a manga is long clicked. - * - * @param position the position of the element clicked. - */ - override fun onItemLongClick(position: Int) { - controller.createActionModeIfNeeded() - toggleSelection(position) - } - - /** - * Opens a manga. - * - * @param manga the manga to open. - */ - private fun openManga(manga: Manga) { - controller.openManga(manga) - } - - /** - * Tells the presenter to toggle the selection for the given position. - * - * @param position the position to toggle. - */ - private fun toggleSelection(position: Int) { - val item = adapter.getItem(position) ?: return - - controller.setSelection(item.manga, !adapter.isSelected(position)) - controller.invalidateActionMode() - } - -} +package eu.kanade.tachiyomi.ui.library + +import android.content.Context +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.SelectableAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.util.plusAssign +import eu.kanade.tachiyomi.util.toast +import eu.kanade.tachiyomi.widget.AutofitRecyclerView +import kotlinx.android.synthetic.main.library_category.view.* +import rx.subscriptions.CompositeSubscription +import uy.kohesive.injekt.injectLazy + +/** + * Fragment containing the library manga for a certain category. + */ +class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + FrameLayout(context, attrs), + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener { + + /** + * Preferences. + */ + private val preferences: PreferencesHelper by injectLazy() + + /** + * The fragment containing this view. + */ + private lateinit var controller: LibraryController + + /** + * Category for this view. + */ + lateinit var category: Category + private set + + /** + * Recycler view of the list of manga. + */ + private lateinit var recycler: RecyclerView + + /** + * Adapter to hold the manga in this category. + */ + private lateinit var adapter: LibraryCategoryAdapter + + /** + * Subscriptions while the view is bound. + */ + private var subscriptions = CompositeSubscription() + + fun onCreate(controller: LibraryController) { + this.controller = controller + + recycler = if (preferences.libraryAsList().getOrDefault()) { + (swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply { + layoutManager = LinearLayoutManager(context) + } + } else { + (swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply { + spanCount = controller.mangaPerRow + } + } + + adapter = LibraryCategoryAdapter(this) + + recycler.setHasFixedSize(true) + recycler.adapter = adapter + swipe_refresh.addView(recycler) + + recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) { + // Disable swipe refresh when view is not at the top + val firstPos = (recycler.layoutManager as LinearLayoutManager) + .findFirstCompletelyVisibleItemPosition() + swipe_refresh.isEnabled = firstPos <= 0 + } + }) + + // Double the distance required to trigger sync + swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt()) + swipe_refresh.setOnRefreshListener { + if (!LibraryUpdateService.isRunning(context)) { + LibraryUpdateService.start(context, category) + context.toast(R.string.updating_category) + } + // It can be a very long operation, so we disable swipe refresh and show a toast. + swipe_refresh.isRefreshing = false + } + } + + fun onBind(category: Category) { + this.category = category + + adapter.mode = if (controller.selectedMangas.isNotEmpty()) { + SelectableAdapter.Mode.MULTI + } else { + SelectableAdapter.Mode.SINGLE + } + + subscriptions += controller.searchRelay + .doOnNext { adapter.setFilter(it) } + .skip(1) + .subscribe { adapter.performFilter() } + + subscriptions += controller.libraryMangaRelay + .subscribe { onNextLibraryManga(it) } + + subscriptions += controller.selectionRelay + .subscribe { onSelectionChanged(it) } + } + + fun onRecycle() { + adapter.setItems(emptyList()) + adapter.clearSelection() + unsubscribe() + } + + fun unsubscribe() { + subscriptions.clear() + } + + /** + * Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the + * adapter. + * + * @param event the event received. + */ + fun onNextLibraryManga(event: LibraryMangaEvent) { + // Get the manga list for this category. + val mangaForCategory = event.getMangaForCategory(category).orEmpty() + + // Update the category with its manga. + adapter.setItems(mangaForCategory) + + if (adapter.mode == SelectableAdapter.Mode.MULTI) { + controller.selectedMangas.forEach { manga -> + val position = adapter.indexOf(manga) + if (position != -1 && !adapter.isSelected(position)) { + adapter.toggleSelection(position) + (recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation() + } + } + } + } + + /** + * Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection + * depending on the type of event received. + * + * @param event the selection event received. + */ + private fun onSelectionChanged(event: LibrarySelectionEvent) { + when (event) { + is LibrarySelectionEvent.Selected -> { + if (adapter.mode != SelectableAdapter.Mode.MULTI) { + adapter.mode = SelectableAdapter.Mode.MULTI + } + findAndToggleSelection(event.manga) + } + is LibrarySelectionEvent.Unselected -> { + findAndToggleSelection(event.manga) + if (controller.selectedMangas.isEmpty()) { + adapter.mode = SelectableAdapter.Mode.SINGLE + } + } + is LibrarySelectionEvent.Cleared -> { + adapter.mode = SelectableAdapter.Mode.SINGLE + adapter.clearSelection() + } + } + } + + /** + * Toggles the selection for the given manga and updates the view if needed. + * + * @param manga the manga to toggle. + */ + private fun findAndToggleSelection(manga: Manga) { + val position = adapter.indexOf(manga) + if (position != -1) { + adapter.toggleSelection(position) + (recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation() + } + } + + /** + * Called when a manga is clicked. + * + * @param position the position of the element clicked. + * @return true if the item should be selected, false otherwise. + */ + override fun onItemClick(view: View, position: Int): Boolean { + // If the action mode is created and the position is valid, toggle the selection. + val item = adapter.getItem(position) ?: return false + if (adapter.mode == SelectableAdapter.Mode.MULTI) { + toggleSelection(position) + return true + } else { + openManga(item.manga) + return false + } + } + + /** + * Called when a manga is long clicked. + * + * @param position the position of the element clicked. + */ + override fun onItemLongClick(position: Int) { + controller.createActionModeIfNeeded() + toggleSelection(position) + } + + /** + * Opens a manga. + * + * @param manga the manga to open. + */ + private fun openManga(manga: Manga) { + controller.openManga(manga) + } + + /** + * Tells the presenter to toggle the selection for the given position. + * + * @param position the position to toggle. + */ + private fun toggleSelection(position: Int) { + val item = adapter.getItem(position) ?: return + + controller.setSelection(item.manga, !adapter.isSelected(position)) + controller.invalidateActionMode() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index a5eb2c310..4ff8beb2b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -1,524 +1,524 @@ -package eu.kanade.tachiyomi.ui.library - -import android.app.Activity -import android.content.Intent -import android.content.res.Configuration -import android.graphics.Color -import android.os.Bundle -import com.google.android.material.tabs.TabLayout -import androidx.core.graphics.drawable.DrawableCompat -import androidx.drawerlayout.widget.DrawerLayout -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ActionMode -import androidx.appcompat.widget.SearchView -import android.view.* -import androidx.core.view.GravityCompat -import com.bluelinelabs.conductor.ControllerChangeHandler -import com.bluelinelabs.conductor.ControllerChangeType -import com.f2prateek.rx.preferences.Preference -import com.jakewharton.rxbinding.support.v4.view.pageSelections -import com.jakewharton.rxbinding.support.v7.widget.queryTextChanges -import com.jakewharton.rxrelay.BehaviorRelay -import com.jakewharton.rxrelay.PublishRelay -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController -import eu.kanade.tachiyomi.ui.base.controller.TabbedController -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.category.CategoryController -import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.migration.MigrationController -import eu.kanade.tachiyomi.util.inflate -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.library_controller.* -import kotlinx.android.synthetic.main.main_activity.* -import rx.Subscription -import timber.log.Timber -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.io.IOException - - -class LibraryController( - bundle: Bundle? = null, - private val preferences: PreferencesHelper = Injekt.get() -) : NucleusController(bundle), - TabbedController, - SecondaryDrawerController, - ActionMode.Callback, - ChangeMangaCategoriesDialog.Listener, - DeleteLibraryMangasDialog.Listener { - - /** - * Position of the active category. - */ - var activeCategory: Int = preferences.lastUsedCategory().getOrDefault() - private set - - /** - * Action mode for selections. - */ - private var actionMode: ActionMode? = null - - /** - * Library search query. - */ - private var query = "" - - /** - * Currently selected mangas. - */ - val selectedMangas = mutableSetOf() - - private var selectedCoverManga: Manga? = null - - /** - * Relay to notify the UI of selection updates. - */ - val selectionRelay: PublishRelay = PublishRelay.create() - - /** - * Relay to notify search query changes. - */ - val searchRelay: BehaviorRelay = BehaviorRelay.create() - - /** - * Relay to notify the library's viewpager for updates. - */ - val libraryMangaRelay: BehaviorRelay = BehaviorRelay.create() - - /** - * Number of manga per row in grid mode. - */ - var mangaPerRow = 0 - private set - - /** - * Adapter of the view pager. - */ - private var adapter: LibraryAdapter? = null - - /** - * Navigation view containing filter/sort/display items. - */ - private var navView: LibraryNavigationView? = null - - /** - * Drawer listener to allow swipe only for closing the drawer. - */ - private var drawerListener: DrawerLayout.DrawerListener? = null - - private var tabsVisibilityRelay: BehaviorRelay = BehaviorRelay.create(false) - - private var tabsVisibilitySubscription: Subscription? = null - - private var searchViewSubscription: Subscription? = null - - init { - setHasOptionsMenu(true) - retainViewMode = RetainViewMode.RETAIN_DETACH - } - - override fun getTitle(): String? { - return resources?.getString(R.string.label_library) - } - - override fun createPresenter(): LibraryPresenter { - return LibraryPresenter() - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.library_controller, container, false) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - adapter = LibraryAdapter(this) - library_pager.adapter = adapter - library_pager.pageSelections().skip(1).subscribeUntilDestroy { - preferences.lastUsedCategory().set(it) - activeCategory = it - } - - getColumnsPreferenceForCurrentOrientation().asObservable() - .doOnNext { mangaPerRow = it } - .skip(1) - // Set again the adapter to recalculate the covers height - .subscribeUntilDestroy { reattachAdapter() } - - if (selectedMangas.isNotEmpty()) { - createActionModeIfNeeded() - } - } - - override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeStarted(handler, type) - if (type.isEnter) { - activity?.tabs?.setupWithViewPager(library_pager) - presenter.subscribeLibrary() - } - } - - override fun onDestroyView(view: View) { - adapter?.onDestroy() - adapter = null - actionMode = null - tabsVisibilitySubscription?.unsubscribe() - tabsVisibilitySubscription = null - super.onDestroyView(view) - } - - override fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup { - val view = drawer.inflate(R.layout.library_drawer) as LibraryNavigationView - navView = view - drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, Gravity.END) - - navView?.onGroupClicked = { group -> - when (group) { - is LibraryNavigationView.FilterGroup -> onFilterChanged() - is LibraryNavigationView.SortGroup -> onSortChanged() - is LibraryNavigationView.DisplayGroup -> reattachAdapter() - is LibraryNavigationView.BadgeGroup -> onDownloadBadgeChanged() - } - } - - return view - } - - override fun cleanupSecondaryDrawer(drawer: DrawerLayout) { - navView = null - } - - override fun configureTabs(tabs: TabLayout) { - with(tabs) { - tabGravity = TabLayout.GRAVITY_CENTER - tabMode = TabLayout.MODE_SCROLLABLE - } - tabsVisibilitySubscription?.unsubscribe() - tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible -> - val tabAnimator = (activity as? MainActivity)?.tabAnimator - if (visible) { - tabAnimator?.expand() - } else { - tabAnimator?.collapse() - } - } - } - - override fun cleanupTabs(tabs: TabLayout) { - tabsVisibilitySubscription?.unsubscribe() - tabsVisibilitySubscription = null - } - - fun onNextLibraryUpdate(categories: List, mangaMap: Map>) { - val view = view ?: return - val adapter = adapter ?: return - - // Show empty view if needed - if (mangaMap.isNotEmpty()) { - empty_view.hide() - } else { - empty_view.show(R.drawable.ic_book_black_128dp, R.string.information_empty_library) - } - - // Get the current active category. - val activeCat = if (adapter.categories.isNotEmpty()) - library_pager.currentItem - else - activeCategory - - // Set the categories - adapter.categories = categories - - // Restore active category. - library_pager.setCurrentItem(activeCat, false) - - tabsVisibilityRelay.call(categories.size > 1) - - // Delay the scroll position to allow the view to be properly measured. - view.post { - if (isAttached) { - activity?.tabs?.setScrollPosition(library_pager.currentItem, 0f, true) - } - } - - // Send the manga map to child fragments after the adapter is updated. - libraryMangaRelay.call(LibraryMangaEvent(mangaMap)) - } - - /** - * Returns a preference for the number of manga per row based on the current orientation. - * - * @return the preference. - */ - private fun getColumnsPreferenceForCurrentOrientation(): Preference { - return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) - preferences.portraitColumns() - else - preferences.landscapeColumns() - } - - /** - * Called when a filter is changed. - */ - private fun onFilterChanged() { - presenter.requestFilterUpdate() - activity?.invalidateOptionsMenu() - } - - private fun onDownloadBadgeChanged() { - presenter.requestDownloadBadgesUpdate() - } - - /** - * Called when the sorting mode is changed. - */ - private fun onSortChanged() { - presenter.requestSortUpdate() - } - - /** - * Reattaches the adapter to the view pager to recreate fragments - */ - private fun reattachAdapter() { - val adapter = adapter ?: return - - val position = library_pager.currentItem - - adapter.recycle = false - library_pager.adapter = adapter - library_pager.currentItem = position - adapter.recycle = true - } - - /** - * Creates the action mode if it's not created already. - */ - fun createActionModeIfNeeded() { - if (actionMode == null) { - actionMode = (activity as AppCompatActivity).startSupportActionMode(this) - } - } - - /** - * Destroys the action mode. - */ - fun destroyActionModeIfNeeded() { - actionMode?.finish() - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.library, menu) - - val searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - - if (!query.isEmpty()) { - searchItem.expandActionView() - searchView.setQuery(query, true) - searchView.clearFocus() - } - - // Mutate the filter icon because it needs to be tinted and the resource is shared. - menu.findItem(R.id.action_filter).icon.mutate() - - searchViewSubscription?.unsubscribe() - searchViewSubscription = searchView.queryTextChanges() - // Ignore events if this controller isn't at the top - .filter { router.backstack.lastOrNull()?.controller() == this } - .subscribeUntilDestroy { - query = it.toString() - searchRelay.call(query) - } - - searchItem.fixExpand() - } - - override fun onPrepareOptionsMenu(menu: Menu) { - val navView = navView ?: return - - val filterItem = menu.findItem(R.id.action_filter) - - // Tint icon if there's a filter active - val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE - DrawableCompat.setTint(filterItem.icon, filterColor) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_filter -> { - navView?.let { activity?.drawer?.openDrawer(GravityCompat.END) } - } - R.id.action_update_library -> { - activity?.let { LibraryUpdateService.start(it) } - } - R.id.action_edit_categories -> { - router.pushController(CategoryController().withFadeTransaction()) - } - R.id.action_source_migration -> { - router.pushController(MigrationController().withFadeTransaction()) - } - else -> return super.onOptionsItemSelected(item) - } - - return true - } - - /** - * Invalidates the action mode, forcing it to refresh its content. - */ - fun invalidateActionMode() { - actionMode?.invalidate() - } - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.library_selection, menu) - return true - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val count = selectedMangas.size - if (count == 0) { - // Destroy action mode if there are no items selected. - destroyActionModeIfNeeded() - } else { - mode.title = resources?.getString(R.string.label_selected, count) - menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1 - } - return false - } - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_edit_cover -> { - changeSelectedCover() - destroyActionModeIfNeeded() - } - R.id.action_move_to_category -> showChangeMangaCategoriesDialog() - R.id.action_delete -> showDeleteMangaDialog() - else -> return false - } - return true - } - - override fun onDestroyActionMode(mode: ActionMode?) { - // Clear all the manga selections and notify child views. - selectedMangas.clear() - selectionRelay.call(LibrarySelectionEvent.Cleared()) - actionMode = null - } - - fun openManga(manga: Manga) { - // Notify the presenter a manga is being opened. - presenter.onOpenManga() - - router.pushController(MangaController(manga).withFadeTransaction()) - } - - /** - * Sets the selection for a given manga. - * - * @param manga the manga whose selection has changed. - * @param selected whether it's now selected or not. - */ - fun setSelection(manga: Manga, selected: Boolean) { - if (selected) { - if (selectedMangas.add(manga)) { - selectionRelay.call(LibrarySelectionEvent.Selected(manga)) - } - } else { - if (selectedMangas.remove(manga)) { - selectionRelay.call(LibrarySelectionEvent.Unselected(manga)) - } - } - } - - /** - * Move the selected manga to a list of categories. - */ - private fun showChangeMangaCategoriesDialog() { - // Create a copy of selected manga - val mangas = selectedMangas.toList() - - // Hide the default category because it has a different behavior than the ones from db. - val categories = presenter.categories.filter { it.id != 0 } - - // Get indexes of the common categories to preselect. - val commonCategoriesIndexes = presenter.getCommonCategories(mangas) - .map { categories.indexOf(it) } - .toTypedArray() - - ChangeMangaCategoriesDialog(this, mangas, categories, commonCategoriesIndexes) - .showDialog(router) - } - - private fun showDeleteMangaDialog() { - DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router) - } - - override fun updateCategoriesForMangas(mangas: List, categories: List) { - presenter.moveMangasToCategories(categories, mangas) - destroyActionModeIfNeeded() - } - - override fun deleteMangasFromLibrary(mangas: List, deleteChapters: Boolean) { - presenter.removeMangaFromLibrary(mangas, deleteChapters) - destroyActionModeIfNeeded() - } - - /** - * Changes the cover for the selected manga. - */ - private fun changeSelectedCover() { - val manga = selectedMangas.firstOrNull() ?: return - selectedCoverManga = manga - - if (manga.favorite) { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = "image/*" - startActivityForResult(Intent.createChooser(intent, - resources?.getString(R.string.file_select_cover)), REQUEST_IMAGE_OPEN) - } else { - activity?.toast(R.string.notification_first_add_to_library) - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == REQUEST_IMAGE_OPEN) { - if (data == null || resultCode != Activity.RESULT_OK) return - val activity = activity ?: return - val manga = selectedCoverManga ?: return - - try { - // Get the file's input stream from the incoming Intent - activity.contentResolver.openInputStream(data.data).use { - // Update cover to selected file, show error if something went wrong - if (presenter.editCoverWithStream(it, manga)) { - // TODO refresh cover - } else { - activity.toast(R.string.notification_cover_update_failed) - } - } - } catch (error: IOException) { - activity.toast(R.string.notification_cover_update_failed) - Timber.e(error) - } - selectedCoverManga = null - } - } - - private companion object { - /** - * Key to change the cover of a manga in [onActivityResult]. - */ - const val REQUEST_IMAGE_OPEN = 101 - } - -} +package eu.kanade.tachiyomi.ui.library + +import android.app.Activity +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color +import android.os.Bundle +import com.google.android.material.tabs.TabLayout +import androidx.core.graphics.drawable.DrawableCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.SearchView +import android.view.* +import androidx.core.view.GravityCompat +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType +import com.f2prateek.rx.preferences.Preference +import com.jakewharton.rxbinding.support.v4.view.pageSelections +import com.jakewharton.rxbinding.support.v7.widget.queryTextChanges +import com.jakewharton.rxrelay.BehaviorRelay +import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController +import eu.kanade.tachiyomi.ui.base.controller.TabbedController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.category.CategoryController +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.migration.MigrationController +import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.util.toast +import kotlinx.android.synthetic.main.library_controller.* +import kotlinx.android.synthetic.main.main_activity.* +import rx.Subscription +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.IOException + + +class LibraryController( + bundle: Bundle? = null, + private val preferences: PreferencesHelper = Injekt.get() +) : NucleusController(bundle), + TabbedController, + SecondaryDrawerController, + ActionMode.Callback, + ChangeMangaCategoriesDialog.Listener, + DeleteLibraryMangasDialog.Listener { + + /** + * Position of the active category. + */ + var activeCategory: Int = preferences.lastUsedCategory().getOrDefault() + private set + + /** + * Action mode for selections. + */ + private var actionMode: ActionMode? = null + + /** + * Library search query. + */ + private var query = "" + + /** + * Currently selected mangas. + */ + val selectedMangas = mutableSetOf() + + private var selectedCoverManga: Manga? = null + + /** + * Relay to notify the UI of selection updates. + */ + val selectionRelay: PublishRelay = PublishRelay.create() + + /** + * Relay to notify search query changes. + */ + val searchRelay: BehaviorRelay = BehaviorRelay.create() + + /** + * Relay to notify the library's viewpager for updates. + */ + val libraryMangaRelay: BehaviorRelay = BehaviorRelay.create() + + /** + * Number of manga per row in grid mode. + */ + var mangaPerRow = 0 + private set + + /** + * Adapter of the view pager. + */ + private var adapter: LibraryAdapter? = null + + /** + * Navigation view containing filter/sort/display items. + */ + private var navView: LibraryNavigationView? = null + + /** + * Drawer listener to allow swipe only for closing the drawer. + */ + private var drawerListener: DrawerLayout.DrawerListener? = null + + private var tabsVisibilityRelay: BehaviorRelay = BehaviorRelay.create(false) + + private var tabsVisibilitySubscription: Subscription? = null + + private var searchViewSubscription: Subscription? = null + + init { + setHasOptionsMenu(true) + retainViewMode = RetainViewMode.RETAIN_DETACH + } + + override fun getTitle(): String? { + return resources?.getString(R.string.label_library) + } + + override fun createPresenter(): LibraryPresenter { + return LibraryPresenter() + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.library_controller, container, false) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = LibraryAdapter(this) + library_pager.adapter = adapter + library_pager.pageSelections().skip(1).subscribeUntilDestroy { + preferences.lastUsedCategory().set(it) + activeCategory = it + } + + getColumnsPreferenceForCurrentOrientation().asObservable() + .doOnNext { mangaPerRow = it } + .skip(1) + // Set again the adapter to recalculate the covers height + .subscribeUntilDestroy { reattachAdapter() } + + if (selectedMangas.isNotEmpty()) { + createActionModeIfNeeded() + } + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeStarted(handler, type) + if (type.isEnter) { + activity?.tabs?.setupWithViewPager(library_pager) + presenter.subscribeLibrary() + } + } + + override fun onDestroyView(view: View) { + adapter?.onDestroy() + adapter = null + actionMode = null + tabsVisibilitySubscription?.unsubscribe() + tabsVisibilitySubscription = null + super.onDestroyView(view) + } + + override fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup { + val view = drawer.inflate(R.layout.library_drawer) as LibraryNavigationView + navView = view + drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, Gravity.END) + + navView?.onGroupClicked = { group -> + when (group) { + is LibraryNavigationView.FilterGroup -> onFilterChanged() + is LibraryNavigationView.SortGroup -> onSortChanged() + is LibraryNavigationView.DisplayGroup -> reattachAdapter() + is LibraryNavigationView.BadgeGroup -> onDownloadBadgeChanged() + } + } + + return view + } + + override fun cleanupSecondaryDrawer(drawer: DrawerLayout) { + navView = null + } + + override fun configureTabs(tabs: TabLayout) { + with(tabs) { + tabGravity = TabLayout.GRAVITY_CENTER + tabMode = TabLayout.MODE_SCROLLABLE + } + tabsVisibilitySubscription?.unsubscribe() + tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible -> + val tabAnimator = (activity as? MainActivity)?.tabAnimator + if (visible) { + tabAnimator?.expand() + } else { + tabAnimator?.collapse() + } + } + } + + override fun cleanupTabs(tabs: TabLayout) { + tabsVisibilitySubscription?.unsubscribe() + tabsVisibilitySubscription = null + } + + fun onNextLibraryUpdate(categories: List, mangaMap: Map>) { + val view = view ?: return + val adapter = adapter ?: return + + // Show empty view if needed + if (mangaMap.isNotEmpty()) { + empty_view.hide() + } else { + empty_view.show(R.drawable.ic_book_black_128dp, R.string.information_empty_library) + } + + // Get the current active category. + val activeCat = if (adapter.categories.isNotEmpty()) + library_pager.currentItem + else + activeCategory + + // Set the categories + adapter.categories = categories + + // Restore active category. + library_pager.setCurrentItem(activeCat, false) + + tabsVisibilityRelay.call(categories.size > 1) + + // Delay the scroll position to allow the view to be properly measured. + view.post { + if (isAttached) { + activity?.tabs?.setScrollPosition(library_pager.currentItem, 0f, true) + } + } + + // Send the manga map to child fragments after the adapter is updated. + libraryMangaRelay.call(LibraryMangaEvent(mangaMap)) + } + + /** + * Returns a preference for the number of manga per row based on the current orientation. + * + * @return the preference. + */ + private fun getColumnsPreferenceForCurrentOrientation(): Preference { + return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) + preferences.portraitColumns() + else + preferences.landscapeColumns() + } + + /** + * Called when a filter is changed. + */ + private fun onFilterChanged() { + presenter.requestFilterUpdate() + activity?.invalidateOptionsMenu() + } + + private fun onDownloadBadgeChanged() { + presenter.requestDownloadBadgesUpdate() + } + + /** + * Called when the sorting mode is changed. + */ + private fun onSortChanged() { + presenter.requestSortUpdate() + } + + /** + * Reattaches the adapter to the view pager to recreate fragments + */ + private fun reattachAdapter() { + val adapter = adapter ?: return + + val position = library_pager.currentItem + + adapter.recycle = false + library_pager.adapter = adapter + library_pager.currentItem = position + adapter.recycle = true + } + + /** + * Creates the action mode if it's not created already. + */ + fun createActionModeIfNeeded() { + if (actionMode == null) { + actionMode = (activity as AppCompatActivity).startSupportActionMode(this) + } + } + + /** + * Destroys the action mode. + */ + fun destroyActionModeIfNeeded() { + actionMode?.finish() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.library, menu) + + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + + if (!query.isEmpty()) { + searchItem.expandActionView() + searchView.setQuery(query, true) + searchView.clearFocus() + } + + // Mutate the filter icon because it needs to be tinted and the resource is shared. + menu.findItem(R.id.action_filter).icon.mutate() + + searchViewSubscription?.unsubscribe() + searchViewSubscription = searchView.queryTextChanges() + // Ignore events if this controller isn't at the top + .filter { router.backstack.lastOrNull()?.controller() == this } + .subscribeUntilDestroy { + query = it.toString() + searchRelay.call(query) + } + + searchItem.fixExpand() + } + + override fun onPrepareOptionsMenu(menu: Menu) { + val navView = navView ?: return + + val filterItem = menu.findItem(R.id.action_filter) + + // Tint icon if there's a filter active + val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE + DrawableCompat.setTint(filterItem.icon, filterColor) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_filter -> { + navView?.let { activity?.drawer?.openDrawer(GravityCompat.END) } + } + R.id.action_update_library -> { + activity?.let { LibraryUpdateService.start(it) } + } + R.id.action_edit_categories -> { + router.pushController(CategoryController().withFadeTransaction()) + } + R.id.action_source_migration -> { + router.pushController(MigrationController().withFadeTransaction()) + } + else -> return super.onOptionsItemSelected(item) + } + + return true + } + + /** + * Invalidates the action mode, forcing it to refresh its content. + */ + fun invalidateActionMode() { + actionMode?.invalidate() + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.library_selection, menu) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val count = selectedMangas.size + if (count == 0) { + // Destroy action mode if there are no items selected. + destroyActionModeIfNeeded() + } else { + mode.title = resources?.getString(R.string.label_selected, count) + menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1 + } + return false + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_edit_cover -> { + changeSelectedCover() + destroyActionModeIfNeeded() + } + R.id.action_move_to_category -> showChangeMangaCategoriesDialog() + R.id.action_delete -> showDeleteMangaDialog() + else -> return false + } + return true + } + + override fun onDestroyActionMode(mode: ActionMode?) { + // Clear all the manga selections and notify child views. + selectedMangas.clear() + selectionRelay.call(LibrarySelectionEvent.Cleared()) + actionMode = null + } + + fun openManga(manga: Manga) { + // Notify the presenter a manga is being opened. + presenter.onOpenManga() + + router.pushController(MangaController(manga).withFadeTransaction()) + } + + /** + * Sets the selection for a given manga. + * + * @param manga the manga whose selection has changed. + * @param selected whether it's now selected or not. + */ + fun setSelection(manga: Manga, selected: Boolean) { + if (selected) { + if (selectedMangas.add(manga)) { + selectionRelay.call(LibrarySelectionEvent.Selected(manga)) + } + } else { + if (selectedMangas.remove(manga)) { + selectionRelay.call(LibrarySelectionEvent.Unselected(manga)) + } + } + } + + /** + * Move the selected manga to a list of categories. + */ + private fun showChangeMangaCategoriesDialog() { + // Create a copy of selected manga + val mangas = selectedMangas.toList() + + // Hide the default category because it has a different behavior than the ones from db. + val categories = presenter.categories.filter { it.id != 0 } + + // Get indexes of the common categories to preselect. + val commonCategoriesIndexes = presenter.getCommonCategories(mangas) + .map { categories.indexOf(it) } + .toTypedArray() + + ChangeMangaCategoriesDialog(this, mangas, categories, commonCategoriesIndexes) + .showDialog(router) + } + + private fun showDeleteMangaDialog() { + DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router) + } + + override fun updateCategoriesForMangas(mangas: List, categories: List) { + presenter.moveMangasToCategories(categories, mangas) + destroyActionModeIfNeeded() + } + + override fun deleteMangasFromLibrary(mangas: List, deleteChapters: Boolean) { + presenter.removeMangaFromLibrary(mangas, deleteChapters) + destroyActionModeIfNeeded() + } + + /** + * Changes the cover for the selected manga. + */ + private fun changeSelectedCover() { + val manga = selectedMangas.firstOrNull() ?: return + selectedCoverManga = manga + + if (manga.favorite) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.type = "image/*" + startActivityForResult(Intent.createChooser(intent, + resources?.getString(R.string.file_select_cover)), REQUEST_IMAGE_OPEN) + } else { + activity?.toast(R.string.notification_first_add_to_library) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_IMAGE_OPEN) { + if (data == null || resultCode != Activity.RESULT_OK) return + val activity = activity ?: return + val manga = selectedCoverManga ?: return + + try { + // Get the file's input stream from the incoming Intent + activity.contentResolver.openInputStream(data.data).use { + // Update cover to selected file, show error if something went wrong + if (presenter.editCoverWithStream(it, manga)) { + // TODO refresh cover + } else { + activity.toast(R.string.notification_cover_update_failed) + } + } + } catch (error: IOException) { + activity.toast(R.string.notification_cover_update_failed) + Timber.e(error) + } + selectedCoverManga = null + } + } + + private companion object { + /** + * Key to change the cover of a manga in [onActivityResult]. + */ + const val REQUEST_IMAGE_OPEN = 101 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt index 2bc68cf3d..0584f8c7a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt @@ -1,57 +1,57 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.View -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.source.LocalSource -import kotlinx.android.synthetic.main.catalogue_grid_item.* - -/** - * Class used to hold the displayed data of a manga in the library, like the cover or the title. - * All the elements from the layout file "item_catalogue_grid" are available in this class. - * - * @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 library holder. - */ -class LibraryGridHolder( - private val view: View, - private val adapter: FlexibleAdapter<*> - -) : LibraryHolder(view, adapter) { - - /** - * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param item the manga item to bind. - */ - override fun onSetValues(item: LibraryItem) { - // Update the title of the manga. - title.text = item.manga.title - - // Update the unread count and its visibility. - with(unread_text) { - visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE - text = item.manga.unread.toString() - } - // Update the download count and its visibility. - with(download_text) { - visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE - text = item.downloadCount.toString() - } - //set local visibility if its local manga - local_text.visibility = if(item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE - - // Update the cover. - GlideApp.with(view.context).clear(thumbnail) - GlideApp.with(view.context) - .load(item.manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .into(thumbnail) - } - -} +package eu.kanade.tachiyomi.ui.library + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.source.LocalSource +import kotlinx.android.synthetic.main.catalogue_grid_item.* + +/** + * Class used to hold the displayed data of a manga in the library, like the cover or the title. + * All the elements from the layout file "item_catalogue_grid" are available in this class. + * + * @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 library holder. + */ +class LibraryGridHolder( + private val view: View, + private val adapter: FlexibleAdapter<*> + +) : LibraryHolder(view, adapter) { + + /** + * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this + * holder with the given manga. + * + * @param item the manga item to bind. + */ + override fun onSetValues(item: LibraryItem) { + // Update the title of the manga. + title.text = item.manga.title + + // Update the unread count and its visibility. + with(unread_text) { + visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE + text = item.manga.unread.toString() + } + // Update the download count and its visibility. + with(download_text) { + visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE + text = item.downloadCount.toString() + } + //set local visibility if its local manga + local_text.visibility = if(item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE + + // Update the cover. + GlideApp.with(view.context).clear(thumbnail) + GlideApp.with(view.context) + .load(item.manga) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(thumbnail) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt index 41d7f9879..4136ce312 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt @@ -1,27 +1,27 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.View -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder - -/** - * Generic class used to hold the displayed data of a manga in the library. - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @param listener a listener to react to the single tap and long tap events. - */ - -abstract class LibraryHolder( - view: View, - adapter: FlexibleAdapter<*> -) : BaseFlexibleViewHolder(view, adapter) { - - /** - * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param item the manga item to bind. - */ - abstract fun onSetValues(item: LibraryItem) - -} +package eu.kanade.tachiyomi.ui.library + +import android.view.View +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder + +/** + * Generic class used to hold the displayed data of a manga in the library. + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + * @param listener a listener to react to the single tap and long tap events. + */ + +abstract class LibraryHolder( + view: View, + adapter: FlexibleAdapter<*> +) : BaseFlexibleViewHolder(view, adapter) { + + /** + * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this + * holder with the given manga. + * + * @param item the manga item to bind. + */ + abstract fun onSetValues(item: LibraryItem) + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt index 0191e2b1b..e12a1b5c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt @@ -1,75 +1,75 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.Gravity -import android.view.View -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.FrameLayout -import androidx.recyclerview.widget.RecyclerView -import com.f2prateek.rx.preferences.Preference -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFilterable -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.LibraryManga -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import kotlinx.android.synthetic.main.catalogue_grid_item.view.* - -class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference) : - AbstractFlexibleItem(), IFilterable { - - var downloadCount = -1 - - override fun getLayoutRes(): Int { - return if (libraryAsList.getOrDefault()) - R.layout.catalogue_list_item - else - R.layout.catalogue_grid_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LibraryHolder { - val parent = adapter.recyclerView - return if (parent is AutofitRecyclerView) { - view.apply { - val coverHeight = parent.itemWidth / 3 * 4 - card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight) - gradient.layoutParams = FrameLayout.LayoutParams( - MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM) - } - LibraryGridHolder(view, adapter) - } else { - LibraryListHolder(view, adapter) - } - } - - override fun bindViewHolder(adapter: FlexibleAdapter>, - holder: LibraryHolder, - position: Int, - payloads: List?) { - - holder.onSetValues(this) - } - - /** - * Filters a manga depending on a query. - * - * @param constraint the query to apply. - * @return true if the manga should be included, false otherwise. - */ - override fun filter(constraint: String): Boolean { - return manga.title.contains(constraint, true) || - (manga.author?.contains(constraint, true) ?: false) - } - - override fun equals(other: Any?): Boolean { - if (other is LibraryItem) { - return manga.id == other.manga.id - } - return false - } - - override fun hashCode(): Int { - return manga.id!!.hashCode() - } -} +package eu.kanade.tachiyomi.ui.library + +import android.view.Gravity +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView +import com.f2prateek.rx.preferences.Preference +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFilterable +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.widget.AutofitRecyclerView +import kotlinx.android.synthetic.main.catalogue_grid_item.view.* + +class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference) : + AbstractFlexibleItem(), IFilterable { + + var downloadCount = -1 + + override fun getLayoutRes(): Int { + return if (libraryAsList.getOrDefault()) + R.layout.catalogue_list_item + else + R.layout.catalogue_grid_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LibraryHolder { + val parent = adapter.recyclerView + return if (parent is AutofitRecyclerView) { + view.apply { + val coverHeight = parent.itemWidth / 3 * 4 + card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight) + gradient.layoutParams = FrameLayout.LayoutParams( + MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM) + } + LibraryGridHolder(view, adapter) + } else { + LibraryListHolder(view, adapter) + } + } + + override fun bindViewHolder(adapter: FlexibleAdapter>, + holder: LibraryHolder, + position: Int, + payloads: List?) { + + holder.onSetValues(this) + } + + /** + * Filters a manga depending on a query. + * + * @param constraint the query to apply. + * @return true if the manga should be included, false otherwise. + */ + override fun filter(constraint: String): Boolean { + return manga.title.contains(constraint, true) || + (manga.author?.contains(constraint, true) ?: false) + } + + override fun equals(other: Any?): Boolean { + if (other is LibraryItem) { + return manga.id == other.manga.id + } + return false + } + + override fun hashCode(): Int { + return manga.id!!.hashCode() + } +} 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 83cc69e25..53b448dd7 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 @@ -1,65 +1,65 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.View -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.source.LocalSource -import kotlinx.android.synthetic.main.catalogue_list_item.* - -/** - * Class used to hold the displayed data of a manga in the library, like the cover or the title. - * All the elements from the layout file "item_library_list" are available in this class. - * - * @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 library holder. - */ - -class LibraryListHolder( - private val view: View, - private val adapter: FlexibleAdapter<*> -) : LibraryHolder(view, adapter) { - - /** - * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param item the manga item to bind. - */ - override fun onSetValues(item: LibraryItem) { - // Update the title of the manga. - title.text = item.manga.title - - // Update the unread count and its visibility. - with(unread_text) { - visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE - text = item.manga.unread.toString() - } - // Update the download count and its visibility. - with(download_text) { - visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE - text = "${item.downloadCount}" - } - //show local text badge if local manga - local_text.visibility = if (item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE - - // Create thumbnail onclick to simulate long click - thumbnail.setOnClickListener { - // Simulate long click on this view to enter selection mode - onLongClick(itemView) - } - - // Update the cover. - GlideApp.with(itemView.context).clear(thumbnail) - GlideApp.with(itemView.context) - .load(item.manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .circleCrop() - .dontAnimate() - .into(thumbnail) - } - -} +package eu.kanade.tachiyomi.ui.library + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.source.LocalSource +import kotlinx.android.synthetic.main.catalogue_list_item.* + +/** + * Class used to hold the displayed data of a manga in the library, like the cover or the title. + * All the elements from the layout file "item_library_list" are available in this class. + * + * @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 library holder. + */ + +class LibraryListHolder( + private val view: View, + private val adapter: FlexibleAdapter<*> +) : LibraryHolder(view, adapter) { + + /** + * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this + * holder with the given manga. + * + * @param item the manga item to bind. + */ + override fun onSetValues(item: LibraryItem) { + // Update the title of the manga. + title.text = item.manga.title + + // Update the unread count and its visibility. + with(unread_text) { + visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE + text = item.manga.unread.toString() + } + // Update the download count and its visibility. + with(download_text) { + visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE + text = "${item.downloadCount}" + } + //show local text badge if local manga + local_text.visibility = if (item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE + + // Create thumbnail onclick to simulate long click + thumbnail.setOnClickListener { + // Simulate long click on this view to enter selection mode + onLongClick(itemView) + } + + // Update the cover. + GlideApp.with(itemView.context).clear(thumbnail) + GlideApp.with(itemView.context) + .load(item.manga) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .circleCrop() + .dontAnimate() + .into(thumbnail) + } + +} 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 aa9f0b666..2b8d57f8a 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 @@ -1,217 +1,217 @@ -package eu.kanade.tachiyomi.ui.library - -import android.content.Context -import android.util.AttributeSet -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.widget.ExtendedNavigationView -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE -import uy.kohesive.injekt.injectLazy - -/** - * The navigation view shown in a drawer with the different options to show the library. - */ -class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) - : ExtendedNavigationView(context, attrs) { - - /** - * Preferences helper. - */ - private val preferences: PreferencesHelper by injectLazy() - - /** - * List of groups shown in the view. - */ - private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup()) - - /** - * Adapter instance. - */ - private val adapter = Adapter(groups.map { it.createItems() }.flatten()) - - /** - * Click listener to notify the parent fragment when an item from a group is clicked. - */ - var onGroupClicked: (Group) -> Unit = {} - - init { - recycler.adapter = adapter - addView(recycler) - - groups.forEach { it.initModels() } - } - - /** - * Returns true if there's at least one filter from [FilterGroup] active. - */ - fun hasActiveFilters(): Boolean { - return (groups[0] as FilterGroup).items.any { it.checked } - } - - /** - * Adapter of the recycler view. - */ - inner class Adapter(items: List) : ExtendedNavigationView.Adapter(items) { - - override fun onItemClicked(item: Item) { - if (item is GroupedItem) { - item.group.onItemClicked(item) - onGroupClicked(item.group) - } - } - } - - /** - * Filters group (unread, downloaded, ...). - */ - inner class FilterGroup : Group { - - private val downloaded = Item.CheckboxGroup(R.string.action_filter_downloaded, this) - - private val unread = Item.CheckboxGroup(R.string.action_filter_unread, this) - - private val completed = Item.CheckboxGroup(R.string.completed, this) - - override val items = listOf(downloaded, unread, completed) - - override val header = Item.Header(R.string.action_filter) - - override val footer = Item.Separator() - - override fun initModels() { - downloaded.checked = preferences.filterDownloaded().getOrDefault() - unread.checked = preferences.filterUnread().getOrDefault() - completed.checked = preferences.filterCompleted().getOrDefault() - } - - override fun onItemClicked(item: Item) { - item as Item.CheckboxGroup - item.checked = !item.checked - when (item) { - downloaded -> preferences.filterDownloaded().set(item.checked) - unread -> preferences.filterUnread().set(item.checked) - completed -> preferences.filterCompleted().set(item.checked) - } - - adapter.notifyItemChanged(item) - } - } - - /** - * Sorting group (alphabetically, by last read, ...) and ascending or descending. - */ - inner class SortGroup : Group { - - private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this) - - private val total = Item.MultiSort(R.string.action_sort_total, this) - - private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this) - - private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this) - - private val unread = Item.MultiSort(R.string.action_filter_unread, this) - - private val source = Item.MultiSort(R.string.manga_info_source_label, this) - - override val items = listOf(alphabetically, lastRead, lastUpdated, unread, total, source) - - override val header = Item.Header(R.string.action_sort) - - override val footer = Item.Separator() - - override fun initModels() { - val sorting = preferences.librarySortingMode().getOrDefault() - val order = if (preferences.librarySortingAscending().getOrDefault()) - SORT_ASC else SORT_DESC - - alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE - lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE - lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE - unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE - total.state = if (sorting == LibrarySort.TOTAL) order else SORT_NONE - source.state = if (sorting == LibrarySort.SOURCE) order else SORT_NONE - } - - override fun onItemClicked(item: Item) { - item as Item.MultiStateGroup - val prevState = item.state - - item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE } - item.state = when (prevState) { - SORT_NONE -> SORT_ASC - SORT_ASC -> SORT_DESC - SORT_DESC -> SORT_ASC - else -> throw Exception("Unknown state") - } - - preferences.librarySortingMode().set(when (item) { - alphabetically -> LibrarySort.ALPHA - lastRead -> LibrarySort.LAST_READ - lastUpdated -> LibrarySort.LAST_UPDATED - unread -> LibrarySort.UNREAD - total -> LibrarySort.TOTAL - source -> LibrarySort.SOURCE - else -> throw Exception("Unknown sorting") - }) - preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false) - - item.group.items.forEach { adapter.notifyItemChanged(it) } - } - - } - - inner class BadgeGroup : Group { - private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this) - override val header = null - override val footer = null - override val items = listOf(downloadBadge) - override fun initModels() { - downloadBadge.checked = preferences.downloadBadge().getOrDefault() - } - - override fun onItemClicked(item: Item) { - item as Item.CheckboxGroup - item.checked = !item.checked - preferences.downloadBadge().set((item.checked)) - adapter.notifyItemChanged(item) - } - } - - /** - * Display group, to show the library as a list or a grid. - */ - inner class DisplayGroup : Group { - - private val grid = Item.Radio(R.string.action_display_grid, this) - - private val list = Item.Radio(R.string.action_display_list, this) - - override val items = listOf(grid, list) - - override val header = Item.Header(R.string.action_display) - - override val footer = null - - override fun initModels() { - val asList = preferences.libraryAsList().getOrDefault() - grid.checked = !asList - list.checked = asList - } - - override fun onItemClicked(item: Item) { - item as Item.Radio - if (item.checked) return - - item.group.items.forEach { (it as Item.Radio).checked = false } - item.checked = true - - preferences.libraryAsList().set(if (item == list) true else false) - - item.group.items.forEach { adapter.notifyItemChanged(it) } - } - } +package eu.kanade.tachiyomi.ui.library + +import android.content.Context +import android.util.AttributeSet +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.widget.ExtendedNavigationView +import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC +import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC +import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE +import uy.kohesive.injekt.injectLazy + +/** + * The navigation view shown in a drawer with the different options to show the library. + */ +class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) + : ExtendedNavigationView(context, attrs) { + + /** + * Preferences helper. + */ + private val preferences: PreferencesHelper by injectLazy() + + /** + * List of groups shown in the view. + */ + private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup()) + + /** + * Adapter instance. + */ + private val adapter = Adapter(groups.map { it.createItems() }.flatten()) + + /** + * Click listener to notify the parent fragment when an item from a group is clicked. + */ + var onGroupClicked: (Group) -> Unit = {} + + init { + recycler.adapter = adapter + addView(recycler) + + groups.forEach { it.initModels() } + } + + /** + * Returns true if there's at least one filter from [FilterGroup] active. + */ + fun hasActiveFilters(): Boolean { + return (groups[0] as FilterGroup).items.any { it.checked } + } + + /** + * Adapter of the recycler view. + */ + inner class Adapter(items: List) : ExtendedNavigationView.Adapter(items) { + + override fun onItemClicked(item: Item) { + if (item is GroupedItem) { + item.group.onItemClicked(item) + onGroupClicked(item.group) + } + } + } + + /** + * Filters group (unread, downloaded, ...). + */ + inner class FilterGroup : Group { + + private val downloaded = Item.CheckboxGroup(R.string.action_filter_downloaded, this) + + private val unread = Item.CheckboxGroup(R.string.action_filter_unread, this) + + private val completed = Item.CheckboxGroup(R.string.completed, this) + + override val items = listOf(downloaded, unread, completed) + + override val header = Item.Header(R.string.action_filter) + + override val footer = Item.Separator() + + override fun initModels() { + downloaded.checked = preferences.filterDownloaded().getOrDefault() + unread.checked = preferences.filterUnread().getOrDefault() + completed.checked = preferences.filterCompleted().getOrDefault() + } + + override fun onItemClicked(item: Item) { + item as Item.CheckboxGroup + item.checked = !item.checked + when (item) { + downloaded -> preferences.filterDownloaded().set(item.checked) + unread -> preferences.filterUnread().set(item.checked) + completed -> preferences.filterCompleted().set(item.checked) + } + + adapter.notifyItemChanged(item) + } + } + + /** + * Sorting group (alphabetically, by last read, ...) and ascending or descending. + */ + inner class SortGroup : Group { + + private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this) + + private val total = Item.MultiSort(R.string.action_sort_total, this) + + private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this) + + private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this) + + private val unread = Item.MultiSort(R.string.action_filter_unread, this) + + private val source = Item.MultiSort(R.string.manga_info_source_label, this) + + override val items = listOf(alphabetically, lastRead, lastUpdated, unread, total, source) + + override val header = Item.Header(R.string.action_sort) + + override val footer = Item.Separator() + + override fun initModels() { + val sorting = preferences.librarySortingMode().getOrDefault() + val order = if (preferences.librarySortingAscending().getOrDefault()) + SORT_ASC else SORT_DESC + + alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE + lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE + lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE + unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE + total.state = if (sorting == LibrarySort.TOTAL) order else SORT_NONE + source.state = if (sorting == LibrarySort.SOURCE) order else SORT_NONE + } + + override fun onItemClicked(item: Item) { + item as Item.MultiStateGroup + val prevState = item.state + + item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE } + item.state = when (prevState) { + SORT_NONE -> SORT_ASC + SORT_ASC -> SORT_DESC + SORT_DESC -> SORT_ASC + else -> throw Exception("Unknown state") + } + + preferences.librarySortingMode().set(when (item) { + alphabetically -> LibrarySort.ALPHA + lastRead -> LibrarySort.LAST_READ + lastUpdated -> LibrarySort.LAST_UPDATED + unread -> LibrarySort.UNREAD + total -> LibrarySort.TOTAL + source -> LibrarySort.SOURCE + else -> throw Exception("Unknown sorting") + }) + preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false) + + item.group.items.forEach { adapter.notifyItemChanged(it) } + } + + } + + inner class BadgeGroup : Group { + private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this) + override val header = null + override val footer = null + override val items = listOf(downloadBadge) + override fun initModels() { + downloadBadge.checked = preferences.downloadBadge().getOrDefault() + } + + override fun onItemClicked(item: Item) { + item as Item.CheckboxGroup + item.checked = !item.checked + preferences.downloadBadge().set((item.checked)) + adapter.notifyItemChanged(item) + } + } + + /** + * Display group, to show the library as a list or a grid. + */ + inner class DisplayGroup : Group { + + private val grid = Item.Radio(R.string.action_display_grid, this) + + private val list = Item.Radio(R.string.action_display_list, this) + + override val items = listOf(grid, list) + + override val header = Item.Header(R.string.action_display) + + override val footer = null + + override fun initModels() { + val asList = preferences.libraryAsList().getOrDefault() + grid.checked = !asList + list.checked = asList + } + + override fun onItemClicked(item: Item) { + item as Item.Radio + if (item.checked) return + + item.group.items.forEach { (it as Item.Radio).checked = false } + item.checked = true + + preferences.libraryAsList().set(if (item == list) true else false) + + item.group.items.forEach { adapter.notifyItemChanged(it) } + } + } } \ No newline at end of file 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 17ac0cba5..4e8bf01a2 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 @@ -1,371 +1,371 @@ -package eu.kanade.tachiyomi.ui.library - -import android.os.Bundle -import com.jakewharton.rxrelay.BehaviorRelay -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.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.source.LocalSource -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.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.combineLatest -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.Injekt -import uy.kohesive.injekt.api.get -import java.io.IOException -import java.io.InputStream -import java.util.ArrayList -import java.util.Collections -import java.util.Comparator - -/** - * Class containing library information. - */ -private data class Library(val categories: List, val mangaMap: LibraryMap) - -/** - * Typealias for the library manga, using the category as keys, and list of manga as values. - */ -private typealias LibraryMap = Map> - -/** - * Presenter of [LibraryController]. - */ -class LibraryPresenter( - private val db: DatabaseHelper = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get(), - private val coverCache: CoverCache = Injekt.get(), - private val sourceManager: SourceManager = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get() -) : BasePresenter() { - - private val context = preferences.context - - /** - * Categories of the library. - */ - var categories: List = emptyList() - private set - - /** - * Relay used to apply the UI filters to the last emission of the library. - */ - private val filterTriggerRelay = BehaviorRelay.create(Unit) - - /** - * Relay used to apply the UI update to the last emission of the library. - */ - private val downloadTriggerRelay = BehaviorRelay.create(Unit) - - /** - * Relay used to apply the selected sorting method to the last emission of the library. - */ - private val sortTriggerRelay = BehaviorRelay.create(Unit) - - /** - * Library subscription. - */ - private var librarySubscription: Subscription? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - subscribeLibrary() - } - - /** - * Subscribes to library if needed. - */ - fun subscribeLibrary() { - if (librarySubscription.isNullOrUnsubscribed()) { - librarySubscription = getLibraryObservable() - .combineLatest(downloadTriggerRelay.observeOn(Schedulers.io()), - { lib, _ -> lib.apply { setDownloadCount(mangaMap) } }) - .combineLatest(filterTriggerRelay.observeOn(Schedulers.io()), - { lib, _ -> lib.copy(mangaMap = applyFilters(lib.mangaMap)) }) - .combineLatest(sortTriggerRelay.observeOn(Schedulers.io()), - { lib, _ -> lib.copy(mangaMap = applySort(lib.mangaMap)) }) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache({ view, (categories, mangaMap) -> - view.onNextLibraryUpdate(categories, mangaMap) - }) - } - } - - /** - * Applies library filters to the given map of manga. - * - * @param map the map to filter. - */ - private fun applyFilters(map: LibraryMap): LibraryMap { - val filterDownloaded = preferences.filterDownloaded().getOrDefault() - - val filterUnread = preferences.filterUnread().getOrDefault() - - val filterCompleted = preferences.filterCompleted().getOrDefault() - - val filterFn: (LibraryItem) -> Boolean = f@ { item -> - // Filter when there isn't unread chapters. - if (filterUnread && item.manga.unread == 0) { - return@f false - } - - if (filterCompleted && item.manga.status != SManga.COMPLETED) { - return@f false - } - - // Filter when there are no downloads. - if (filterDownloaded) { - // Local manga are always downloaded - if (item.manga.source == LocalSource.ID) { - return@f true - } - // Don't bother with directory checking if download count has been set. - if (item.downloadCount != -1) { - return@f item.downloadCount > 0 - } - - return@f downloadManager.getDownloadCount(item.manga) > 0 - } - true - } - - return map.mapValues { entry -> entry.value.filter(filterFn) } - } - - /** - * Sets downloaded chapter count to each manga. - * - * @param map the map of manga. - */ - private fun setDownloadCount(map: LibraryMap) { - if (!preferences.downloadBadge().getOrDefault()) { - // Unset download count if the preference is not enabled. - for ((_, itemList) in map) { - for (item in itemList) { - item.downloadCount = -1 - } - } - return - } - - for ((_, itemList) in map) { - for (item in itemList) { - item.downloadCount = downloadManager.getDownloadCount(item.manga) - } - } - } - - /** - * Applies library sorting to the given map of manga. - * - * @param map the map to sort. - */ - private fun applySort(map: LibraryMap): LibraryMap { - val sortingMode = preferences.librarySortingMode().getOrDefault() - - val lastReadManga by lazy { - var counter = 0 - db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ } - } - val totalChapterManga by lazy { - var counter = 0 - db.getTotalChapterManga().executeAsBlocking().associate { it.id!! to counter++ } - } - - val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> - when (sortingMode) { - LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true) - LibrarySort.LAST_READ -> { - // Get index of manga, set equal to list if size unknown. - val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size - val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size - manga1LastRead.compareTo(manga2LastRead) - } - LibrarySort.LAST_UPDATED -> i2.manga.last_update.compareTo(i1.manga.last_update) - LibrarySort.UNREAD -> i1.manga.unread.compareTo(i2.manga.unread) - LibrarySort.TOTAL -> { - val manga1TotalChapter = totalChapterManga[i1.manga.id!!] ?: 0 - val mange2TotalChapter = totalChapterManga[i2.manga.id!!] ?: 0 - manga1TotalChapter.compareTo(mange2TotalChapter) - } - LibrarySort.SOURCE -> { - val source1Name = sourceManager.getOrStub(i1.manga.source).name - val source2Name = sourceManager.getOrStub(i2.manga.source).name - source1Name.compareTo(source2Name) - } - else -> throw Exception("Unknown sorting mode") - } - } - - val comparator = if (preferences.librarySortingAscending().getOrDefault()) - Comparator(sortFn) - else - Collections.reverseOrder(sortFn) - - return map.mapValues { entry -> entry.value.sortedWith(comparator) } - } - - /** - * Get the categories and all its manga from the database. - * - * @return an observable of the categories and its manga. - */ - private fun getLibraryObservable(): Observable { - return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(), - { dbCategories, libraryManga -> - val categories = if (libraryManga.containsKey(0)) - arrayListOf(Category.createDefault()) + dbCategories - else - dbCategories - - this.categories = categories - Library(categories, libraryManga) - }) - } - - /** - * Get the categories from the database. - * - * @return an observable of the categories. - */ - private fun getCategoriesObservable(): Observable> { - return db.getCategories().asRxObservable() - } - - /** - * Get the manga grouped by categories. - * - * @return an observable containing a map with the category id as key and a list of manga as the - * value. - */ - private fun getLibraryMangasObservable(): Observable { - val libraryAsList = preferences.libraryAsList() - return db.getLibraryMangas().asRxObservable() - .map { list -> - list.map { LibraryItem(it, libraryAsList) }.groupBy { it.manga.category } - } - } - - /** - * Requests the library to be filtered. - */ - fun requestFilterUpdate() { - filterTriggerRelay.call(Unit) - } - - /** - * Requests the library to have download badges added. - */ - fun requestDownloadBadgesUpdate() { - downloadTriggerRelay.call(Unit) - } - - /** - * Requests the library to be sorted. - */ - fun requestSortUpdate() { - sortTriggerRelay.call(Unit) - } - - /** - * Called when a manga is opened. - */ - fun onOpenManga() { - // Avoid further db updates for the library when it's not needed - librarySubscription?.let { remove(it) } - } - - /** - * Returns the common categories for the given list of manga. - * - * @param mangas the list of manga. - */ - 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 mangas the list of manga to delete. - * @param deleteChapters whether to also delete downloaded chapters. - */ - fun removeMangaFromLibrary(mangas: List, deleteChapters: Boolean) { - // Create a set of the list - val mangaToDelete = mangas.distinctBy { it.id } - mangaToDelete.forEach { it.favorite = false } - - Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() } - .onErrorResumeNext { Observable.empty() } - .subscribeOn(Schedulers.io()) - .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.deleteManga(manga, source) - } - } - } - } - .subscribeOn(Schedulers.io()) - .subscribe() - } - - /** - * Move the given list of manga to categories. - * - * @param categories the selected categories. - * @param mangas the list of manga to move. - */ - fun moveMangasToCategories(categories: List, mangas: List) { - val mc = ArrayList() - - for (manga in mangas) { - for (cat in categories) { - mc.add(MangaCategory.create(manga, cat)) - } - } - - db.setMangaCategories(mc, mangas) - } - - /** - * Update cover with local file. - * - * @param inputStream the new cover. - * @param manga the manga edited. - * @return true if the cover is updated, false otherwise - */ - @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 - } - return false - } - -} +package eu.kanade.tachiyomi.ui.library + +import android.os.Bundle +import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.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.source.LocalSource +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.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.combineLatest +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.Injekt +import uy.kohesive.injekt.api.get +import java.io.IOException +import java.io.InputStream +import java.util.ArrayList +import java.util.Collections +import java.util.Comparator + +/** + * Class containing library information. + */ +private data class Library(val categories: List, val mangaMap: LibraryMap) + +/** + * Typealias for the library manga, using the category as keys, and list of manga as values. + */ +private typealias LibraryMap = Map> + +/** + * Presenter of [LibraryController]. + */ +class LibraryPresenter( + private val db: DatabaseHelper = Injekt.get(), + private val preferences: PreferencesHelper = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get() +) : BasePresenter() { + + private val context = preferences.context + + /** + * Categories of the library. + */ + var categories: List = emptyList() + private set + + /** + * Relay used to apply the UI filters to the last emission of the library. + */ + private val filterTriggerRelay = BehaviorRelay.create(Unit) + + /** + * Relay used to apply the UI update to the last emission of the library. + */ + private val downloadTriggerRelay = BehaviorRelay.create(Unit) + + /** + * Relay used to apply the selected sorting method to the last emission of the library. + */ + private val sortTriggerRelay = BehaviorRelay.create(Unit) + + /** + * Library subscription. + */ + private var librarySubscription: Subscription? = null + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + subscribeLibrary() + } + + /** + * Subscribes to library if needed. + */ + fun subscribeLibrary() { + if (librarySubscription.isNullOrUnsubscribed()) { + librarySubscription = getLibraryObservable() + .combineLatest(downloadTriggerRelay.observeOn(Schedulers.io()), + { lib, _ -> lib.apply { setDownloadCount(mangaMap) } }) + .combineLatest(filterTriggerRelay.observeOn(Schedulers.io()), + { lib, _ -> lib.copy(mangaMap = applyFilters(lib.mangaMap)) }) + .combineLatest(sortTriggerRelay.observeOn(Schedulers.io()), + { lib, _ -> lib.copy(mangaMap = applySort(lib.mangaMap)) }) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache({ view, (categories, mangaMap) -> + view.onNextLibraryUpdate(categories, mangaMap) + }) + } + } + + /** + * Applies library filters to the given map of manga. + * + * @param map the map to filter. + */ + private fun applyFilters(map: LibraryMap): LibraryMap { + val filterDownloaded = preferences.filterDownloaded().getOrDefault() + + val filterUnread = preferences.filterUnread().getOrDefault() + + val filterCompleted = preferences.filterCompleted().getOrDefault() + + val filterFn: (LibraryItem) -> Boolean = f@ { item -> + // Filter when there isn't unread chapters. + if (filterUnread && item.manga.unread == 0) { + return@f false + } + + if (filterCompleted && item.manga.status != SManga.COMPLETED) { + return@f false + } + + // Filter when there are no downloads. + if (filterDownloaded) { + // Local manga are always downloaded + if (item.manga.source == LocalSource.ID) { + return@f true + } + // Don't bother with directory checking if download count has been set. + if (item.downloadCount != -1) { + return@f item.downloadCount > 0 + } + + return@f downloadManager.getDownloadCount(item.manga) > 0 + } + true + } + + return map.mapValues { entry -> entry.value.filter(filterFn) } + } + + /** + * Sets downloaded chapter count to each manga. + * + * @param map the map of manga. + */ + private fun setDownloadCount(map: LibraryMap) { + if (!preferences.downloadBadge().getOrDefault()) { + // Unset download count if the preference is not enabled. + for ((_, itemList) in map) { + for (item in itemList) { + item.downloadCount = -1 + } + } + return + } + + for ((_, itemList) in map) { + for (item in itemList) { + item.downloadCount = downloadManager.getDownloadCount(item.manga) + } + } + } + + /** + * Applies library sorting to the given map of manga. + * + * @param map the map to sort. + */ + private fun applySort(map: LibraryMap): LibraryMap { + val sortingMode = preferences.librarySortingMode().getOrDefault() + + val lastReadManga by lazy { + var counter = 0 + db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ } + } + val totalChapterManga by lazy { + var counter = 0 + db.getTotalChapterManga().executeAsBlocking().associate { it.id!! to counter++ } + } + + val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> + when (sortingMode) { + LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true) + LibrarySort.LAST_READ -> { + // Get index of manga, set equal to list if size unknown. + val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size + val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size + manga1LastRead.compareTo(manga2LastRead) + } + LibrarySort.LAST_UPDATED -> i2.manga.last_update.compareTo(i1.manga.last_update) + LibrarySort.UNREAD -> i1.manga.unread.compareTo(i2.manga.unread) + LibrarySort.TOTAL -> { + val manga1TotalChapter = totalChapterManga[i1.manga.id!!] ?: 0 + val mange2TotalChapter = totalChapterManga[i2.manga.id!!] ?: 0 + manga1TotalChapter.compareTo(mange2TotalChapter) + } + LibrarySort.SOURCE -> { + val source1Name = sourceManager.getOrStub(i1.manga.source).name + val source2Name = sourceManager.getOrStub(i2.manga.source).name + source1Name.compareTo(source2Name) + } + else -> throw Exception("Unknown sorting mode") + } + } + + val comparator = if (preferences.librarySortingAscending().getOrDefault()) + Comparator(sortFn) + else + Collections.reverseOrder(sortFn) + + return map.mapValues { entry -> entry.value.sortedWith(comparator) } + } + + /** + * Get the categories and all its manga from the database. + * + * @return an observable of the categories and its manga. + */ + private fun getLibraryObservable(): Observable { + return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(), + { dbCategories, libraryManga -> + val categories = if (libraryManga.containsKey(0)) + arrayListOf(Category.createDefault()) + dbCategories + else + dbCategories + + this.categories = categories + Library(categories, libraryManga) + }) + } + + /** + * Get the categories from the database. + * + * @return an observable of the categories. + */ + private fun getCategoriesObservable(): Observable> { + return db.getCategories().asRxObservable() + } + + /** + * Get the manga grouped by categories. + * + * @return an observable containing a map with the category id as key and a list of manga as the + * value. + */ + private fun getLibraryMangasObservable(): Observable { + val libraryAsList = preferences.libraryAsList() + return db.getLibraryMangas().asRxObservable() + .map { list -> + list.map { LibraryItem(it, libraryAsList) }.groupBy { it.manga.category } + } + } + + /** + * Requests the library to be filtered. + */ + fun requestFilterUpdate() { + filterTriggerRelay.call(Unit) + } + + /** + * Requests the library to have download badges added. + */ + fun requestDownloadBadgesUpdate() { + downloadTriggerRelay.call(Unit) + } + + /** + * Requests the library to be sorted. + */ + fun requestSortUpdate() { + sortTriggerRelay.call(Unit) + } + + /** + * Called when a manga is opened. + */ + fun onOpenManga() { + // Avoid further db updates for the library when it's not needed + librarySubscription?.let { remove(it) } + } + + /** + * Returns the common categories for the given list of manga. + * + * @param mangas the list of manga. + */ + 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 mangas the list of manga to delete. + * @param deleteChapters whether to also delete downloaded chapters. + */ + fun removeMangaFromLibrary(mangas: List, deleteChapters: Boolean) { + // Create a set of the list + val mangaToDelete = mangas.distinctBy { it.id } + mangaToDelete.forEach { it.favorite = false } + + Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() } + .onErrorResumeNext { Observable.empty() } + .subscribeOn(Schedulers.io()) + .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.deleteManga(manga, source) + } + } + } + } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Move the given list of manga to categories. + * + * @param categories the selected categories. + * @param mangas the list of manga to move. + */ + fun moveMangasToCategories(categories: List, mangas: List) { + val mc = ArrayList() + + for (manga in mangas) { + for (cat in categories) { + mc.add(MangaCategory.create(manga, cat)) + } + } + + db.setMangaCategories(mc, mangas) + } + + /** + * Update cover with local file. + * + * @param inputStream the new cover. + * @param manga the manga edited. + * @return true if the cover is updated, false otherwise + */ + @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 + } + return false + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt index 57c9f28b1..a67b793fe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt @@ -1,11 +1,11 @@ -package eu.kanade.tachiyomi.ui.library - -object LibrarySort { - - const val ALPHA = 0 - const val LAST_READ = 1 - const val LAST_UPDATED = 2 - const val UNREAD = 3 - const val TOTAL = 4 - const val SOURCE = 5 +package eu.kanade.tachiyomi.ui.library + +object LibrarySort { + + const val ALPHA = 0 + const val LAST_READ = 1 + const val LAST_UPDATED = 2 + const val UNREAD = 3 + const val TOTAL = 4 + const val SOURCE = 5 } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt index 4d24b7e20..60627bee5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt @@ -1,32 +1,32 @@ -package eu.kanade.tachiyomi.ui.main - -import android.app.Dialog -import android.content.Context -import android.os.Bundle -import android.util.AttributeSet -import com.afollestad.materialdialogs.MaterialDialog -import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView - -class ChangelogDialogController : DialogController() { - - override fun onCreateDialog(savedState: Bundle?): Dialog { - val activity = activity!! - val view = WhatsNewRecyclerView(activity) - return MaterialDialog.Builder(activity) - .title(if (BuildConfig.DEBUG) "Notices" else "Changelog") - .customView(view, false) - .positiveText(android.R.string.yes) - .build() - } - - class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) { - override fun initAttrs(attrs: AttributeSet?, defStyle: Int) { - mRowLayoutId = R.layout.changelog_row_layout - mRowHeaderLayoutId = R.layout.changelog_header_layout - mChangeLogFileResourceId = if (BuildConfig.DEBUG) R.raw.changelog_debug else R.raw.changelog_release - } - } +package eu.kanade.tachiyomi.ui.main + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView + +class ChangelogDialogController : DialogController() { + + override fun onCreateDialog(savedState: Bundle?): Dialog { + val activity = activity!! + val view = WhatsNewRecyclerView(activity) + return MaterialDialog.Builder(activity) + .title(if (BuildConfig.DEBUG) "Notices" else "Changelog") + .customView(view, false) + .positiveText(android.R.string.yes) + .build() + } + + class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) { + override fun initAttrs(attrs: AttributeSet?, defStyle: Int) { + mRowLayoutId = R.layout.changelog_row_layout + mRowHeaderLayoutId = R.layout.changelog_header_layout + mChangeLogFileResourceId = if (BuildConfig.DEBUG) R.raw.changelog_debug else R.raw.changelog_release + } + } } \ No newline at end of file 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 c8cfd2676..397d2cb03 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 @@ -1,282 +1,282 @@ -package eu.kanade.tachiyomi.ui.main - -import android.animation.ObjectAnimator -import android.app.SearchManager -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import androidx.core.view.GravityCompat -import androidx.drawerlayout.widget.DrawerLayout -import androidx.appcompat.graphics.drawable.DrawerArrowDrawable -import android.view.ViewGroup -import com.bluelinelabs.conductor.* -import eu.kanade.tachiyomi.Migrations -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.ui.base.activity.BaseActivity -import eu.kanade.tachiyomi.ui.base.controller.* -import eu.kanade.tachiyomi.ui.catalogue.CatalogueController -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController -import eu.kanade.tachiyomi.ui.download.DownloadController -import eu.kanade.tachiyomi.ui.extension.ExtensionController -import eu.kanade.tachiyomi.ui.library.LibraryController -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController -import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController -import eu.kanade.tachiyomi.ui.setting.SettingsMainController -import eu.kanade.tachiyomi.util.openInBrowser -import kotlinx.android.synthetic.main.main_activity.* -import uy.kohesive.injekt.injectLazy - - -class MainActivity : BaseActivity() { - - private lateinit var router: Router - - val preferences: PreferencesHelper by injectLazy() - - private var drawerArrow: DrawerArrowDrawable? = null - - private var secondaryDrawer: ViewGroup? = null - - private val startScreenId by lazy { - when (preferences.startScreen()) { - 2 -> R.id.nav_drawer_recently_read - 3 -> R.id.nav_drawer_recent_updates - else -> R.id.nav_drawer_library - } - } - - lateinit var tabAnimator: TabsAnimator - - override fun onCreate(savedInstanceState: Bundle?) { - setTheme(when (preferences.theme()) { - 2 -> R.style.Theme_Tachiyomi_Dark - 3 -> R.style.Theme_Tachiyomi_Amoled - 4 -> R.style.Theme_Tachiyomi_DarkBlue - else -> R.style.Theme_Tachiyomi - }) - super.onCreate(savedInstanceState) - - // Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079 - if (!isTaskRoot) { - finish() - return - } - - setContentView(R.layout.main_activity) - - setSupportActionBar(toolbar) - - drawerArrow = DrawerArrowDrawable(this) - drawerArrow?.color = Color.WHITE - toolbar.navigationIcon = drawerArrow - - tabAnimator = TabsAnimator(tabs) - - // Set behavior of Navigation drawer - nav_view.setNavigationItemSelectedListener { item -> - val id = item.itemId - - val currentRoot = router.backstack.firstOrNull() - if (currentRoot?.tag()?.toIntOrNull() != id) { - when (id) { - R.id.nav_drawer_library -> setRoot(LibraryController(), id) - R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id) - R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id) - R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id) - R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id) - R.id.nav_drawer_downloads -> { - router.pushController(DownloadController().withFadeTransaction()) - } - R.id.nav_drawer_settings -> { - router.pushController(SettingsMainController().withFadeTransaction()) - } - R.id.nav_drawer_help -> { - openInBrowser(URL_HELP) - } - } - } - drawer.closeDrawer(GravityCompat.START) - true - } - - val container: ViewGroup = findViewById(R.id.controller_container) - - router = Conductor.attachRouter(this, container, savedInstanceState) - if (!router.hasRootController()) { - // Set start screen - if (!handleIntentAction(intent)) { - setSelectedDrawerItem(startScreenId) - } - } - - toolbar.setNavigationOnClickListener { - if (router.backstackSize == 1) { - drawer.openDrawer(GravityCompat.START) - } else { - onBackPressed() - } - } - - router.addChangeListener(object : ControllerChangeHandler.ControllerChangeListener { - override fun onChangeStarted(to: Controller?, from: Controller?, isPush: Boolean, - container: ViewGroup, handler: ControllerChangeHandler) { - - syncActivityViewWithController(to, from) - } - - override fun onChangeCompleted(to: Controller?, from: Controller?, isPush: Boolean, - container: ViewGroup, handler: ControllerChangeHandler) { - - } - - }) - - syncActivityViewWithController(router.backstack.lastOrNull()?.controller()) - - if (savedInstanceState == null) { - // Show changelog if needed - if (Migrations.upgrade(preferences)) { - ChangelogDialogController().showDialog(router) - } - } - } - - override fun onNewIntent(intent: Intent) { - if (!handleIntentAction(intent)) { - super.onNewIntent(intent) - } - } - - private fun handleIntentAction(intent: Intent): Boolean { - when (intent.action) { - SHORTCUT_LIBRARY -> setSelectedDrawerItem(R.id.nav_drawer_library) - SHORTCUT_RECENTLY_UPDATED -> setSelectedDrawerItem(R.id.nav_drawer_recent_updates) - SHORTCUT_RECENTLY_READ -> setSelectedDrawerItem(R.id.nav_drawer_recently_read) - SHORTCUT_CATALOGUES -> setSelectedDrawerItem(R.id.nav_drawer_catalogues) - SHORTCUT_MANGA -> { - val extras = intent.extras ?: return false - router.setRoot(RouterTransaction.with(MangaController(extras))) - } - SHORTCUT_DOWNLOADS -> { - if (router.backstack.none { it.controller() is DownloadController }) { - setSelectedDrawerItem(R.id.nav_drawer_downloads) - } - } - Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> { - //If the intent match the "standard" Android search intent - // or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant) - - //Get the search query provided in extras, and if not null, perform a global search with it. - val query = intent.getStringExtra(SearchManager.QUERY) - if (query != null && !query.isEmpty()) { - if (router.backstackSize > 1) { - router.popToRoot() - } - router.pushController(CatalogueSearchController(query).withFadeTransaction()) - } - } - INTENT_SEARCH -> { - val query = intent.getStringExtra(INTENT_SEARCH_QUERY) - val filter = intent.getStringExtra(INTENT_SEARCH_FILTER) - if (query != null && !query.isEmpty()) { - if (router.backstackSize > 1) { - router.popToRoot() - } - router.pushController(CatalogueSearchController(query, filter).withFadeTransaction()) - } - } - else -> return false - } - return true - } - - override fun onDestroy() { - super.onDestroy() - nav_view?.setNavigationItemSelectedListener(null) - toolbar?.setNavigationOnClickListener(null) - } - - override fun onBackPressed() { - val backstackSize = router.backstackSize - if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) { - drawer.closeDrawers() - } else if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { - setSelectedDrawerItem(startScreenId) - } else if (backstackSize == 1 || !router.handleBack()) { - super.onBackPressed() - } - } - - private fun setSelectedDrawerItem(itemId: Int) { - if (!isFinishing) { - nav_view.setCheckedItem(itemId) - nav_view.menu.performIdentifierAction(itemId, 0) - } - } - - private fun setRoot(controller: Controller, id: Int) { - router.setRoot(controller.withFadeTransaction().tag(id.toString())) - } - - private fun syncActivityViewWithController(to: Controller?, from: Controller? = null) { - if (from is DialogController || to is DialogController) { - return - } - - val showHamburger = router.backstackSize == 1 - if (showHamburger) { - drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) - } else { - drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - } - - ObjectAnimator.ofFloat(drawerArrow, "progress", if (showHamburger) 0f else 1f).start() - - if (from is TabbedController) { - from.cleanupTabs(tabs) - } - if (to is TabbedController) { - tabAnimator.expand() - to.configureTabs(tabs) - } else { - tabAnimator.collapse() - tabs.setupWithViewPager(null) - } - - if (from is SecondaryDrawerController) { - if (secondaryDrawer != null) { - from.cleanupSecondaryDrawer(drawer) - drawer.removeView(secondaryDrawer) - secondaryDrawer = null - } - } - if (to is SecondaryDrawerController) { - secondaryDrawer = to.createSecondaryDrawer(drawer)?.also { drawer.addView(it) } - } - - if (to is NoToolbarElevationController) { - appbar.disableElevation() - } else { - appbar.enableElevation() - } - } - - companion object { - // Shortcut actions - const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" - const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED" - const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ" - const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES" - const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS" - const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA" - - const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH" - const val INTENT_SEARCH_QUERY = "query" - const val INTENT_SEARCH_FILTER = "filter" - - private const val URL_HELP = "https://tachiyomi.org/help/" - } - -} +package eu.kanade.tachiyomi.ui.main + +import android.animation.ObjectAnimator +import android.app.SearchManager +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.appcompat.graphics.drawable.DrawerArrowDrawable +import android.view.ViewGroup +import com.bluelinelabs.conductor.* +import eu.kanade.tachiyomi.Migrations +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.base.activity.BaseActivity +import eu.kanade.tachiyomi.ui.base.controller.* +import eu.kanade.tachiyomi.ui.catalogue.CatalogueController +import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController +import eu.kanade.tachiyomi.ui.download.DownloadController +import eu.kanade.tachiyomi.ui.extension.ExtensionController +import eu.kanade.tachiyomi.ui.library.LibraryController +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController +import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController +import eu.kanade.tachiyomi.ui.setting.SettingsMainController +import eu.kanade.tachiyomi.util.openInBrowser +import kotlinx.android.synthetic.main.main_activity.* +import uy.kohesive.injekt.injectLazy + + +class MainActivity : BaseActivity() { + + private lateinit var router: Router + + val preferences: PreferencesHelper by injectLazy() + + private var drawerArrow: DrawerArrowDrawable? = null + + private var secondaryDrawer: ViewGroup? = null + + private val startScreenId by lazy { + when (preferences.startScreen()) { + 2 -> R.id.nav_drawer_recently_read + 3 -> R.id.nav_drawer_recent_updates + else -> R.id.nav_drawer_library + } + } + + lateinit var tabAnimator: TabsAnimator + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(when (preferences.theme()) { + 2 -> R.style.Theme_Tachiyomi_Dark + 3 -> R.style.Theme_Tachiyomi_Amoled + 4 -> R.style.Theme_Tachiyomi_DarkBlue + else -> R.style.Theme_Tachiyomi + }) + super.onCreate(savedInstanceState) + + // Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079 + if (!isTaskRoot) { + finish() + return + } + + setContentView(R.layout.main_activity) + + setSupportActionBar(toolbar) + + drawerArrow = DrawerArrowDrawable(this) + drawerArrow?.color = Color.WHITE + toolbar.navigationIcon = drawerArrow + + tabAnimator = TabsAnimator(tabs) + + // Set behavior of Navigation drawer + nav_view.setNavigationItemSelectedListener { item -> + val id = item.itemId + + val currentRoot = router.backstack.firstOrNull() + if (currentRoot?.tag()?.toIntOrNull() != id) { + when (id) { + R.id.nav_drawer_library -> setRoot(LibraryController(), id) + R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id) + R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id) + R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id) + R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id) + R.id.nav_drawer_downloads -> { + router.pushController(DownloadController().withFadeTransaction()) + } + R.id.nav_drawer_settings -> { + router.pushController(SettingsMainController().withFadeTransaction()) + } + R.id.nav_drawer_help -> { + openInBrowser(URL_HELP) + } + } + } + drawer.closeDrawer(GravityCompat.START) + true + } + + val container: ViewGroup = findViewById(R.id.controller_container) + + router = Conductor.attachRouter(this, container, savedInstanceState) + if (!router.hasRootController()) { + // Set start screen + if (!handleIntentAction(intent)) { + setSelectedDrawerItem(startScreenId) + } + } + + toolbar.setNavigationOnClickListener { + if (router.backstackSize == 1) { + drawer.openDrawer(GravityCompat.START) + } else { + onBackPressed() + } + } + + router.addChangeListener(object : ControllerChangeHandler.ControllerChangeListener { + override fun onChangeStarted(to: Controller?, from: Controller?, isPush: Boolean, + container: ViewGroup, handler: ControllerChangeHandler) { + + syncActivityViewWithController(to, from) + } + + override fun onChangeCompleted(to: Controller?, from: Controller?, isPush: Boolean, + container: ViewGroup, handler: ControllerChangeHandler) { + + } + + }) + + syncActivityViewWithController(router.backstack.lastOrNull()?.controller()) + + if (savedInstanceState == null) { + // Show changelog if needed + if (Migrations.upgrade(preferences)) { + ChangelogDialogController().showDialog(router) + } + } + } + + override fun onNewIntent(intent: Intent) { + if (!handleIntentAction(intent)) { + super.onNewIntent(intent) + } + } + + private fun handleIntentAction(intent: Intent): Boolean { + when (intent.action) { + SHORTCUT_LIBRARY -> setSelectedDrawerItem(R.id.nav_drawer_library) + SHORTCUT_RECENTLY_UPDATED -> setSelectedDrawerItem(R.id.nav_drawer_recent_updates) + SHORTCUT_RECENTLY_READ -> setSelectedDrawerItem(R.id.nav_drawer_recently_read) + SHORTCUT_CATALOGUES -> setSelectedDrawerItem(R.id.nav_drawer_catalogues) + SHORTCUT_MANGA -> { + val extras = intent.extras ?: return false + router.setRoot(RouterTransaction.with(MangaController(extras))) + } + SHORTCUT_DOWNLOADS -> { + if (router.backstack.none { it.controller() is DownloadController }) { + setSelectedDrawerItem(R.id.nav_drawer_downloads) + } + } + Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> { + //If the intent match the "standard" Android search intent + // or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant) + + //Get the search query provided in extras, and if not null, perform a global search with it. + val query = intent.getStringExtra(SearchManager.QUERY) + if (query != null && !query.isEmpty()) { + if (router.backstackSize > 1) { + router.popToRoot() + } + router.pushController(CatalogueSearchController(query).withFadeTransaction()) + } + } + INTENT_SEARCH -> { + val query = intent.getStringExtra(INTENT_SEARCH_QUERY) + val filter = intent.getStringExtra(INTENT_SEARCH_FILTER) + if (query != null && !query.isEmpty()) { + if (router.backstackSize > 1) { + router.popToRoot() + } + router.pushController(CatalogueSearchController(query, filter).withFadeTransaction()) + } + } + else -> return false + } + return true + } + + override fun onDestroy() { + super.onDestroy() + nav_view?.setNavigationItemSelectedListener(null) + toolbar?.setNavigationOnClickListener(null) + } + + override fun onBackPressed() { + val backstackSize = router.backstackSize + if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) { + drawer.closeDrawers() + } else if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { + setSelectedDrawerItem(startScreenId) + } else if (backstackSize == 1 || !router.handleBack()) { + super.onBackPressed() + } + } + + private fun setSelectedDrawerItem(itemId: Int) { + if (!isFinishing) { + nav_view.setCheckedItem(itemId) + nav_view.menu.performIdentifierAction(itemId, 0) + } + } + + private fun setRoot(controller: Controller, id: Int) { + router.setRoot(controller.withFadeTransaction().tag(id.toString())) + } + + private fun syncActivityViewWithController(to: Controller?, from: Controller? = null) { + if (from is DialogController || to is DialogController) { + return + } + + val showHamburger = router.backstackSize == 1 + if (showHamburger) { + drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) + } else { + drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + } + + ObjectAnimator.ofFloat(drawerArrow, "progress", if (showHamburger) 0f else 1f).start() + + if (from is TabbedController) { + from.cleanupTabs(tabs) + } + if (to is TabbedController) { + tabAnimator.expand() + to.configureTabs(tabs) + } else { + tabAnimator.collapse() + tabs.setupWithViewPager(null) + } + + if (from is SecondaryDrawerController) { + if (secondaryDrawer != null) { + from.cleanupSecondaryDrawer(drawer) + drawer.removeView(secondaryDrawer) + secondaryDrawer = null + } + } + if (to is SecondaryDrawerController) { + secondaryDrawer = to.createSecondaryDrawer(drawer)?.also { drawer.addView(it) } + } + + if (to is NoToolbarElevationController) { + appbar.disableElevation() + } else { + appbar.enableElevation() + } + } + + companion object { + // Shortcut actions + const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" + const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED" + const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ" + const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES" + const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS" + const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA" + + const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH" + const val INTENT_SEARCH_QUERY = "query" + const val INTENT_SEARCH_FILTER = "filter" + + private const val URL_HELP = "https://tachiyomi.org/help/" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index 2990d80ac..69ab7003e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -1,193 +1,193 @@ -package eu.kanade.tachiyomi.ui.manga - -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.os.Bundle -import com.google.android.material.tabs.TabLayout -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.TextView -import com.bluelinelabs.conductor.ControllerChangeHandler -import com.bluelinelabs.conductor.ControllerChangeType -import com.bluelinelabs.conductor.Router -import com.bluelinelabs.conductor.RouterTransaction -import com.bluelinelabs.conductor.support.RouterPagerAdapter -import com.jakewharton.rxrelay.BehaviorRelay -import com.jakewharton.rxrelay.PublishRelay -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.ui.base.controller.RxController -import eu.kanade.tachiyomi.ui.base.controller.TabbedController -import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe -import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController -import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController -import eu.kanade.tachiyomi.ui.manga.track.TrackController -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.main_activity.* -import kotlinx.android.synthetic.main.manga_controller.* -import rx.Subscription -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.Date - -class MangaController : RxController, TabbedController { - - constructor(manga: Manga?, fromCatalogue: Boolean = false) : super(Bundle().apply { - putLong(MANGA_EXTRA, manga?.id ?: 0) - putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue) - }) { - this.manga = manga - if (manga != null) { - source = Injekt.get().getOrStub(manga.source) - } - } - - constructor(mangaId: Long) : this( - Injekt.get().getManga(mangaId).executeAsBlocking()) - - @Suppress("unused") - constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) - - var manga: Manga? = null - private set - - var source: Source? = null - private set - - private var adapter: MangaDetailAdapter? = null - - val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false) - - val lastUpdateRelay: BehaviorRelay = BehaviorRelay.create() - - val chapterCountRelay: BehaviorRelay = BehaviorRelay.create() - - val mangaFavoriteRelay: PublishRelay = PublishRelay.create() - - private val trackingIconRelay: BehaviorRelay = BehaviorRelay.create() - - private var trackingIconSubscription: Subscription? = null - - override fun getTitle(): String? { - return manga?.title - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.manga_controller, container, false) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - if (manga == null || source == null) return - - requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) - - adapter = MangaDetailAdapter() - manga_pager.offscreenPageLimit = 3 - manga_pager.adapter = adapter - - if (!fromCatalogue) - manga_pager.currentItem = CHAPTERS_CONTROLLER - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - adapter = null - } - - override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeStarted(handler, type) - if (type.isEnter) { - activity?.tabs?.setupWithViewPager(manga_pager) - trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) } - } - } - - override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeEnded(handler, type) - if (manga == null || source == null) { - activity?.toast(R.string.manga_not_in_db) - router.popController(this) - } - } - - override fun configureTabs(tabs: TabLayout) { - with(tabs) { - tabGravity = TabLayout.GRAVITY_FILL - tabMode = TabLayout.MODE_FIXED - } - } - - override fun cleanupTabs(tabs: TabLayout) { - trackingIconSubscription?.unsubscribe() - setTrackingIconInternal(false) - } - - fun setTrackingIcon(visible: Boolean) { - trackingIconRelay.call(visible) - } - - private fun setTrackingIconInternal(visible: Boolean) { - val tab = activity?.tabs?.getTabAt(TRACK_CONTROLLER) ?: return - val drawable = if (visible) - VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null) - else null - - val view = tabField.get(tab) as LinearLayout - val textView = view.getChildAt(1) as TextView - textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null) - textView.compoundDrawablePadding = if (visible) 4 else 0 - } - - private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) { - - private val tabCount = if (Injekt.get().hasLoggedServices()) 3 else 2 - - private val tabTitles = listOf( - R.string.manga_detail_tab, - R.string.manga_chapters_tab, - R.string.manga_tracking_tab) - .map { resources!!.getString(it) } - - override fun getCount(): Int { - return tabCount - } - - override fun configureRouter(router: Router, position: Int) { - if (!router.hasRootController()) { - val controller = when (position) { - INFO_CONTROLLER -> MangaInfoController() - CHAPTERS_CONTROLLER -> ChaptersController() - TRACK_CONTROLLER -> TrackController() - else -> error("Wrong position $position") - } - router.setRoot(RouterTransaction.with(controller)) - } - } - - override fun getPageTitle(position: Int): CharSequence { - return tabTitles[position] - } - - } - - companion object { - const val FROM_CATALOGUE_EXTRA = "from_catalogue" - const val MANGA_EXTRA = "manga" - - const val INFO_CONTROLLER = 0 - const val CHAPTERS_CONTROLLER = 1 - const val TRACK_CONTROLLER = 2 - - private val tabField = TabLayout.Tab::class.java.getDeclaredField("view") - .apply { isAccessible = true } - } - -} +package eu.kanade.tachiyomi.ui.manga + +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.os.Bundle +import com.google.android.material.tabs.TabLayout +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType +import com.bluelinelabs.conductor.Router +import com.bluelinelabs.conductor.RouterTransaction +import com.bluelinelabs.conductor.support.RouterPagerAdapter +import com.jakewharton.rxrelay.BehaviorRelay +import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.controller.RxController +import eu.kanade.tachiyomi.ui.base.controller.TabbedController +import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe +import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController +import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController +import eu.kanade.tachiyomi.ui.manga.track.TrackController +import eu.kanade.tachiyomi.util.toast +import kotlinx.android.synthetic.main.main_activity.* +import kotlinx.android.synthetic.main.manga_controller.* +import rx.Subscription +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Date + +class MangaController : RxController, TabbedController { + + constructor(manga: Manga?, fromCatalogue: Boolean = false) : super(Bundle().apply { + putLong(MANGA_EXTRA, manga?.id ?: 0) + putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue) + }) { + this.manga = manga + if (manga != null) { + source = Injekt.get().getOrStub(manga.source) + } + } + + constructor(mangaId: Long) : this( + Injekt.get().getManga(mangaId).executeAsBlocking()) + + @Suppress("unused") + constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) + + var manga: Manga? = null + private set + + var source: Source? = null + private set + + private var adapter: MangaDetailAdapter? = null + + val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false) + + val lastUpdateRelay: BehaviorRelay = BehaviorRelay.create() + + val chapterCountRelay: BehaviorRelay = BehaviorRelay.create() + + val mangaFavoriteRelay: PublishRelay = PublishRelay.create() + + private val trackingIconRelay: BehaviorRelay = BehaviorRelay.create() + + private var trackingIconSubscription: Subscription? = null + + override fun getTitle(): String? { + return manga?.title + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.manga_controller, container, false) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + if (manga == null || source == null) return + + requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) + + adapter = MangaDetailAdapter() + manga_pager.offscreenPageLimit = 3 + manga_pager.adapter = adapter + + if (!fromCatalogue) + manga_pager.currentItem = CHAPTERS_CONTROLLER + } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + adapter = null + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeStarted(handler, type) + if (type.isEnter) { + activity?.tabs?.setupWithViewPager(manga_pager) + trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) } + } + } + + override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeEnded(handler, type) + if (manga == null || source == null) { + activity?.toast(R.string.manga_not_in_db) + router.popController(this) + } + } + + override fun configureTabs(tabs: TabLayout) { + with(tabs) { + tabGravity = TabLayout.GRAVITY_FILL + tabMode = TabLayout.MODE_FIXED + } + } + + override fun cleanupTabs(tabs: TabLayout) { + trackingIconSubscription?.unsubscribe() + setTrackingIconInternal(false) + } + + fun setTrackingIcon(visible: Boolean) { + trackingIconRelay.call(visible) + } + + private fun setTrackingIconInternal(visible: Boolean) { + val tab = activity?.tabs?.getTabAt(TRACK_CONTROLLER) ?: return + val drawable = if (visible) + VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null) + else null + + val view = tabField.get(tab) as LinearLayout + val textView = view.getChildAt(1) as TextView + textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null) + textView.compoundDrawablePadding = if (visible) 4 else 0 + } + + private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) { + + private val tabCount = if (Injekt.get().hasLoggedServices()) 3 else 2 + + private val tabTitles = listOf( + R.string.manga_detail_tab, + R.string.manga_chapters_tab, + R.string.manga_tracking_tab) + .map { resources!!.getString(it) } + + override fun getCount(): Int { + return tabCount + } + + override fun configureRouter(router: Router, position: Int) { + if (!router.hasRootController()) { + val controller = when (position) { + INFO_CONTROLLER -> MangaInfoController() + CHAPTERS_CONTROLLER -> ChaptersController() + TRACK_CONTROLLER -> TrackController() + else -> error("Wrong position $position") + } + router.setRoot(RouterTransaction.with(controller)) + } + } + + override fun getPageTitle(position: Int): CharSequence { + return tabTitles[position] + } + + } + + companion object { + const val FROM_CATALOGUE_EXTRA = "from_catalogue" + const val MANGA_EXTRA = "manga" + + const val INFO_CONTROLLER = 0 + const val CHAPTERS_CONTROLLER = 1 + const val TRACK_CONTROLLER = 2 + + private val tabField = TabLayout.Tab::class.java.getDeclaredField("view") + .apply { isAccessible = true } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt index d02d95102..0a232a4af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt @@ -1,122 +1,122 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.view.View -import android.widget.PopupMenu -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.getResourceColor -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.setVectorCompat -import kotlinx.android.synthetic.main.chapters_item.* -import java.util.* - -class ChapterHolder( - private val view: View, - private val adapter: ChaptersAdapter -) : BaseFlexibleViewHolder(view, adapter) { - - 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. - chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } } - } - - fun bind(item: ChapterItem, manga: Manga) { - val chapter = item.chapter - - chapter_title.text = when (manga.displayMode) { - Manga.DISPLAY_NUMBER -> { - val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) - itemView.context.getString(R.string.display_mode_chapter, number) - } - else -> chapter.name - } - - // Set the correct drawable for dropdown and update the tint to match theme. - chapter_menu.setVectorCompat(R.drawable.ic_more_vert_black_24dp, view.context.getResourceColor(R.attr.icon_color)) - - // Set correct text color - chapter_title.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) - if (chapter.bookmark) chapter_title.setTextColor(adapter.bookmarkedColor) - - if (chapter.date_upload > 0) { - chapter_date.text = adapter.dateFormat.format(Date(chapter.date_upload)) - chapter_date.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) - } else { - chapter_date.text = "" - } - - //add scanlator if exists - chapter_scanlator.text = chapter.scanlator - //allow longer titles if there is no scanlator (most sources) - if (chapter_scanlator.text.isNullOrBlank()) { - chapter_title.maxLines = 2 - chapter_scanlator.gone() - } else { - chapter_title.maxLines = 1 - } - - chapter_pages.text = if (!chapter.read && chapter.last_page_read > 0) { - itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1) - } else { - "" - } - - notifyStatus(item.status) - } - - fun notifyStatus(status: Int) = with(download_text) { - when (status) { - Download.QUEUE -> setText(R.string.chapter_queued) - Download.DOWNLOADING -> setText(R.string.chapter_downloading) - Download.DOWNLOADED -> setText(R.string.chapter_downloaded) - Download.ERROR -> setText(R.string.chapter_error) - else -> text = "" - } - } - - private fun showPopupMenu(view: View) { - val item = adapter.getItem(adapterPosition) ?: return - - // Create a PopupMenu, giving it the clicked view for an anchor - val popup = PopupMenu(view.context, view) - - // Inflate our menu resource into the PopupMenu's Menu - popup.menuInflater.inflate(R.menu.chapter_single, popup.menu) - - val chapter = item.chapter - - // Hide download and show delete if the chapter is downloaded - if (item.isDownloaded) { - popup.menu.findItem(R.id.action_download).isVisible = false - popup.menu.findItem(R.id.action_delete).isVisible = true - } - - // Hide bookmark if bookmark - popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark - popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark - - // Hide mark as unread when the chapter is unread - if (!chapter.read && 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) { - popup.menu.findItem(R.id.action_mark_as_read).isVisible = false - } - - // Set a listener so we are notified if a menu item is clicked - popup.setOnMenuItemClickListener { menuItem -> - adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem) - true - } - - // Finally show the PopupMenu - popup.show() - } - -} +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.view.View +import android.widget.PopupMenu +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.gone +import eu.kanade.tachiyomi.util.setVectorCompat +import kotlinx.android.synthetic.main.chapters_item.* +import java.util.* + +class ChapterHolder( + private val view: View, + private val adapter: ChaptersAdapter +) : BaseFlexibleViewHolder(view, adapter) { + + 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. + chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } } + } + + fun bind(item: ChapterItem, manga: Manga) { + val chapter = item.chapter + + chapter_title.text = when (manga.displayMode) { + Manga.DISPLAY_NUMBER -> { + val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) + itemView.context.getString(R.string.display_mode_chapter, number) + } + else -> chapter.name + } + + // Set the correct drawable for dropdown and update the tint to match theme. + chapter_menu.setVectorCompat(R.drawable.ic_more_vert_black_24dp, view.context.getResourceColor(R.attr.icon_color)) + + // Set correct text color + chapter_title.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) + if (chapter.bookmark) chapter_title.setTextColor(adapter.bookmarkedColor) + + if (chapter.date_upload > 0) { + chapter_date.text = adapter.dateFormat.format(Date(chapter.date_upload)) + chapter_date.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) + } else { + chapter_date.text = "" + } + + //add scanlator if exists + chapter_scanlator.text = chapter.scanlator + //allow longer titles if there is no scanlator (most sources) + if (chapter_scanlator.text.isNullOrBlank()) { + chapter_title.maxLines = 2 + chapter_scanlator.gone() + } else { + chapter_title.maxLines = 1 + } + + chapter_pages.text = if (!chapter.read && chapter.last_page_read > 0) { + itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1) + } else { + "" + } + + notifyStatus(item.status) + } + + fun notifyStatus(status: Int) = with(download_text) { + when (status) { + Download.QUEUE -> setText(R.string.chapter_queued) + Download.DOWNLOADING -> setText(R.string.chapter_downloading) + Download.DOWNLOADED -> setText(R.string.chapter_downloaded) + Download.ERROR -> setText(R.string.chapter_error) + else -> text = "" + } + } + + private fun showPopupMenu(view: View) { + val item = adapter.getItem(adapterPosition) ?: return + + // Create a PopupMenu, giving it the clicked view for an anchor + val popup = PopupMenu(view.context, view) + + // Inflate our menu resource into the PopupMenu's Menu + popup.menuInflater.inflate(R.menu.chapter_single, popup.menu) + + val chapter = item.chapter + + // Hide download and show delete if the chapter is downloaded + if (item.isDownloaded) { + popup.menu.findItem(R.id.action_download).isVisible = false + popup.menu.findItem(R.id.action_delete).isVisible = true + } + + // Hide bookmark if bookmark + popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark + popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark + + // Hide mark as unread when the chapter is unread + if (!chapter.read && 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) { + popup.menu.findItem(R.id.action_mark_as_read).isVisible = false + } + + // Set a listener so we are notified if a menu item is clicked + popup.setOnMenuItemClickListener { menuItem -> + adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem) + true + } + + // Finally show the PopupMenu + popup.show() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt index 34fb89ff2..eae788c0a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt @@ -1,55 +1,55 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.model.Download - -class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem(), - Chapter by chapter { - - 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.chapters_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ChapterHolder { - return ChapterHolder(view, adapter as ChaptersAdapter) - } - - override fun bindViewHolder(adapter: FlexibleAdapter>, - holder: ChapterHolder, - position: Int, - payloads: List?) { - - holder.bind(this, manga) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other is ChapterItem) { - return chapter.id!! == other.chapter.id!! - } - return false - } - - override fun hashCode(): Int { - return chapter.id!!.hashCode() - } - -} +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.download.model.Download + +class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem(), + Chapter by chapter { + + 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.chapters_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ChapterHolder { + return ChapterHolder(view, adapter as ChaptersAdapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter>, + holder: ChapterHolder, + position: Int, + payloads: List?) { + + holder.bind(this, manga) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is ChapterItem) { + return chapter.id!! == other.chapter.id!! + } + return false + } + + override fun hashCode(): Int { + return chapter.id!!.hashCode() + } + +} 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 f22b57613..6bb9226d8 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,45 +1,45 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.content.Context -import android.view.MenuItem -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor -import java.text.DateFormat -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols - -class ChaptersAdapter( - controller: ChaptersController, - context: Context -) : FlexibleAdapter(null, controller, true) { - - var items: List = emptyList() - - val menuItemListener: OnMenuItemClickListener = controller - - val readColor = context.getResourceColor(android.R.attr.textColorHint) - - val unreadColor = context.getResourceColor(android.R.attr.textColorPrimary) - - val bookmarkedColor = context.getResourceColor(R.attr.colorAccent) - - val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols() - .apply { decimalSeparator = '.' }) - - val dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) - - override fun updateDataSet(items: List?) { - this.items = items ?: emptyList() - super.updateDataSet(items) - } - - fun indexOf(item: ChapterItem): Int { - return items.indexOf(item) - } - - interface OnMenuItemClickListener { - fun onMenuItemClick(position: Int, item: MenuItem) - } - -} +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.content.Context +import android.view.MenuItem +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.getResourceColor +import java.text.DateFormat +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols + +class ChaptersAdapter( + controller: ChaptersController, + context: Context +) : FlexibleAdapter(null, controller, true) { + + var items: List = emptyList() + + val menuItemListener: OnMenuItemClickListener = controller + + val readColor = context.getResourceColor(android.R.attr.textColorHint) + + val unreadColor = context.getResourceColor(android.R.attr.textColorPrimary) + + val bookmarkedColor = context.getResourceColor(R.attr.colorAccent) + + val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols() + .apply { decimalSeparator = '.' }) + + val dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) + + override fun updateDataSet(items: List?) { + this.items = items ?: emptyList() + super.updateDataSet(items) + } + + fun indexOf(item: ChapterItem): Int { + return items.indexOf(item) + } + + interface OnMenuItemClickListener { + fun onMenuItemClick(position: Int, item: MenuItem) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt index 7c5c07a21..315985106 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt @@ -1,486 +1,486 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Intent -import com.google.android.material.snackbar.Snackbar -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ActionMode -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import android.view.* -import com.jakewharton.rxbinding.support.v4.widget.refreshes -import com.jakewharton.rxbinding.view.clicks -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.SelectableAdapter -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 -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag -import eu.kanade.tachiyomi.ui.manga.MangaController -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 kotlinx.android.synthetic.main.chapters_controller.* -import timber.log.Timber - -class ChaptersController : NucleusController(), - ActionMode.Callback, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - ChaptersAdapter.OnMenuItemClickListener, - SetDisplayModeDialog.Listener, - SetSortingDialog.Listener, - DownloadChaptersDialog.Listener, - DownloadCustomChaptersDialog.Listener, - DeleteChaptersDialog.Listener { - - /** - * Adapter containing a list of chapters. - */ - private var adapter: ChaptersAdapter? = null - - /** - * Action mode for multiple selection. - */ - private var actionMode: ActionMode? = null - - /** - * Selected items. Used to restore selections after a rotation. - */ - private val selectedItems = mutableSetOf() - - init { - setHasOptionsMenu(true) - setOptionsMenuHidden(true) - } - - override fun createPresenter(): ChaptersPresenter { - val ctrl = parentController as MangaController - return ChaptersPresenter(ctrl.manga!!, ctrl.source!!, - ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay) - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.chapters_controller, container, false) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - // Init RecyclerView and adapter - adapter = ChaptersAdapter(this, view.context) - - recycler.adapter = adapter - recycler.layoutManager = LinearLayoutManager(view.context) - recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) - recycler.setHasFixedSize(true) - adapter?.fastScroller = fast_scroller - - swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() } - - fab.clicks().subscribeUntilDestroy { - val item = presenter.getNextUnreadChapter() - if (item != null) { - // Create animation listener - val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() { - override fun onAnimationStart(animation: Animator?) { - openChapter(item.chapter, true) - } - } - - // Get coordinates and start animation - val coordinates = fab.getCoordinates() - if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) { - openChapter(item.chapter) - } - } else { - view.context.toast(R.string.no_next_chapter) - } - } - } - - override fun onDestroyView(view: View) { - adapter = null - actionMode = null - super.onDestroyView(view) - } - - override fun onActivityResumed(activity: Activity) { - if (view == null) return - - // Check if animation view is visible - if (reveal_view.visibility == View.VISIBLE) { - // Show the unReveal effect - val coordinates = fab.getCoordinates() - reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920) - } - super.onActivityResumed(activity) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.chapters, menu) - } - - override fun onPrepareOptionsMenu(menu: Menu) { - // Initialize menu items. - 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) - - // Set correct checkbox values. - menuFilterRead.isChecked = presenter.onlyRead() - menuFilterUnread.isChecked = presenter.onlyUnread() - menuFilterDownloaded.isChecked = presenter.onlyDownloaded() - menuFilterBookmarked.isChecked = presenter.onlyBookmarked() - - if (presenter.onlyRead()) - //Disable unread filter option if read filter is enabled. - menuFilterUnread.isEnabled = false - if (presenter.onlyUnread()) - //Disable read filter option if unread filter is enabled. - menuFilterRead.isEnabled = false - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_display_mode -> showDisplayModeDialog() - R.id.manga_download -> showDownloadDialog() - R.id.action_sorting_mode -> showSortingDialog() - R.id.action_filter_unread -> { - item.isChecked = !item.isChecked - presenter.setUnreadFilter(item.isChecked) - activity?.invalidateOptionsMenu() - } - R.id.action_filter_read -> { - item.isChecked = !item.isChecked - presenter.setReadFilter(item.isChecked) - activity?.invalidateOptionsMenu() - } - R.id.action_filter_downloaded -> { - item.isChecked = !item.isChecked - presenter.setDownloadedFilter(item.isChecked) - } - R.id.action_filter_bookmarked -> { - item.isChecked = !item.isChecked - presenter.setBookmarkedFilter(item.isChecked) - } - R.id.action_filter_empty -> { - presenter.removeFilters() - activity?.invalidateOptionsMenu() - } - R.id.action_sort -> presenter.revertSortOrder() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - fun onNextChapters(chapters: List) { - // If the list is empty, fetch chapters from source if the conditions are met - // We use presenter chapters instead because they are always unfiltered - if (presenter.chapters.isEmpty()) - initialFetchChapters() - - val adapter = adapter ?: return - adapter.updateDataSet(chapters) - - if (selectedItems.isNotEmpty()) { - adapter.clearSelection() // we need to start from a clean state, index may have changed - createActionModeIfNeeded() - selectedItems.forEach { item -> - val position = adapter.indexOf(item) - if (position != -1 && !adapter.isSelected(position)) { - adapter.toggleSelection(position) - } - } - actionMode?.invalidate() - } - - } - - private fun initialFetchChapters() { - // Only fetch if this view is from the catalog and it hasn't requested previously - if ((parentController as MangaController).fromCatalogue && !presenter.hasRequested) { - fetchChaptersFromSource() - } - } - - private fun fetchChaptersFromSource() { - swipe_refresh?.isRefreshing = true - presenter.fetchChaptersFromSource() - } - - fun onFetchChaptersDone() { - swipe_refresh?.isRefreshing = false - } - - fun onFetchChaptersError(error: Throwable) { - swipe_refresh?.isRefreshing = false - activity?.toast(error.message) - } - - fun onChapterStatusChange(download: Download) { - getHolder(download.chapter)?.notifyStatus(download.status) - } - - private fun getHolder(chapter: Chapter): ChapterHolder? { - return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder - } - - fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) { - val activity = activity ?: return - val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter) - if (hasAnimation) { - intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) - } - startActivity(intent) - } - - override fun onItemClick(view: View, position: Int): Boolean { - val adapter = adapter ?: return false - val item = adapter.getItem(position) ?: return false - if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { - toggleSelection(position) - return true - } else { - openChapter(item.chapter) - return false - } - } - - override fun onItemLongClick(position: Int) { - createActionModeIfNeeded() - toggleSelection(position) - } - - // SELECTIONS & ACTION MODE - - private fun toggleSelection(position: Int) { - val adapter = adapter ?: return - val item = adapter.getItem(position) ?: return - adapter.toggleSelection(position) - if (adapter.isSelected(position)) { - selectedItems.add(item) - } else { - selectedItems.remove(item) - } - actionMode?.invalidate() - } - - private fun getSelectedChapters(): List { - val adapter = adapter ?: return emptyList() - return adapter.selectedPositions.mapNotNull { adapter.getItem(it) } - } - - private fun createActionModeIfNeeded() { - if (actionMode == null) { - actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) - } - } - - private fun destroyActionModeIfNeeded() { - actionMode?.finish() - } - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.chapter_selection, menu) - adapter?.mode = SelectableAdapter.Mode.MULTI - return true - } - - @SuppressLint("StringFormatInvalid") - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val count = adapter?.selectedItemCount ?: 0 - if (count == 0) { - // Destroy action mode if there are no items selected. - destroyActionModeIfNeeded() - } else { - mode.title = resources?.getString(R.string.label_selected, count) - } - return false - } - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_select_all -> selectAll() - R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) - R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) - R.id.action_download -> downloadChapters(getSelectedChapters()) - R.id.action_delete -> showDeleteChaptersConfirmationDialog() - else -> return false - } - return true - } - - override fun onDestroyActionMode(mode: ActionMode) { - adapter?.mode = SelectableAdapter.Mode.SINGLE - adapter?.clearSelection() - selectedItems.clear() - actionMode = null - } - - override fun onMenuItemClick(position: Int, item: MenuItem) { - val chapter = adapter?.getItem(position) ?: return - val chapters = listOf(chapter) - - when (item.itemId) { - R.id.action_download -> downloadChapters(chapters) - R.id.action_bookmark -> bookmarkChapters(chapters, true) - R.id.action_remove_bookmark -> bookmarkChapters(chapters, false) - R.id.action_delete -> deleteChapters(chapters) - R.id.action_mark_as_read -> markAsRead(chapters) - R.id.action_mark_as_unread -> markAsUnread(chapters) - R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter) - } - } - - // SELECTION MODE ACTIONS - - private fun selectAll() { - val adapter = adapter ?: return - adapter.selectAll() - selectedItems.addAll(adapter.items) - actionMode?.invalidate() - } - - private fun markAsRead(chapters: List) { - presenter.markChaptersRead(chapters, true) - if (presenter.preferences.removeAfterMarkedAsRead()) { - deleteChapters(chapters) - } - } - - private fun markAsUnread(chapters: List) { - presenter.markChaptersRead(chapters, false) - } - - private fun downloadChapters(chapters: List) { - val view = view - destroyActionModeIfNeeded() - presenter.downloadChapters(chapters) - if (view != null && !presenter.manga.favorite) { - recycler?.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) { - setAction(R.string.action_add) { - presenter.addToLibrary() - } - } - } - } - - - private fun showDeleteChaptersConfirmationDialog() { - DeleteChaptersDialog(this).showDialog(router) - } - - override fun deleteChapters() { - deleteChapters(getSelectedChapters()) - } - - private fun markPreviousAsRead(chapter: ChapterItem) { - val adapter = adapter ?: return - val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items - val chapterPos = chapters.indexOf(chapter) - if (chapterPos != -1) { - markAsRead(chapters.take(chapterPos)) - } - } - - private fun bookmarkChapters(chapters: List, bookmarked: Boolean) { - destroyActionModeIfNeeded() - presenter.bookmarkChapters(chapters, bookmarked) - } - - fun deleteChapters(chapters: List) { - destroyActionModeIfNeeded() - if (chapters.isEmpty()) return - - DeletingChaptersDialog().showDialog(router) - presenter.deleteChapters(chapters) - } - - fun onChaptersDeleted() { - dismissDeletingDialog() - adapter?.notifyDataSetChanged() - } - - fun onChaptersDeletedError(error: Throwable) { - dismissDeletingDialog() - Timber.e(error) - } - - private fun dismissDeletingDialog() { - router.popControllerWithTag(DeletingChaptersDialog.TAG) - } - - // OVERFLOW MENU DIALOGS - - private fun showDisplayModeDialog() { - val preselected = if (presenter.manga.displayMode == Manga.DISPLAY_NAME) 0 else 1 - SetDisplayModeDialog(this, preselected).showDialog(router) - } - - override fun setDisplayMode(id: Int) { - presenter.setDisplayMode(id) - adapter?.notifyDataSetChanged() - } - - private fun showSortingDialog() { - val preselected = if (presenter.manga.sorting == Manga.SORTING_SOURCE) 0 else 1 - SetSortingDialog(this, preselected).showDialog(router) - } - - override fun setSorting(id: Int) { - presenter.setSorting(id) - } - - private fun showDownloadDialog() { - DownloadChaptersDialog(this).showDialog(router) - } - - private fun getUnreadChaptersSorted() = presenter.chapters - .filter { !it.read && it.status == Download.NOT_DOWNLOADED } - .distinctBy { it.name } - .sortedByDescending { it.source_order } - - override fun downloadCustomChapters(amount: Int) { - val chaptersToDownload = getUnreadChaptersSorted().take(amount) - if (chaptersToDownload.isNotEmpty()) { - downloadChapters(chaptersToDownload) - } - } - - private fun showCustomDownloadDialog() { - DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router) - } - - - override fun downloadChapters(choice: Int) { - // i = 0: Download 1 - // i = 1: Download 5 - // i = 2: Download 10 - // i = 3: Download x - // i = 4: Download unread - // i = 5: Download all - val chaptersToDownload = when (choice) { - 0 -> getUnreadChaptersSorted().take(1) - 1 -> getUnreadChaptersSorted().take(5) - 2 -> getUnreadChaptersSorted().take(10) - 3 -> { - showCustomDownloadDialog() - return - } - 4 -> presenter.chapters.filter { !it.read } - 5 -> presenter.chapters - else -> emptyList() - } - if (chaptersToDownload.isNotEmpty()) { - downloadChapters(chaptersToDownload) - } - } -} +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import com.google.android.material.snackbar.Snackbar +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import android.view.* +import com.jakewharton.rxbinding.support.v4.widget.refreshes +import com.jakewharton.rxbinding.view.clicks +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.SelectableAdapter +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 +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag +import eu.kanade.tachiyomi.ui.manga.MangaController +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 kotlinx.android.synthetic.main.chapters_controller.* +import timber.log.Timber + +class ChaptersController : NucleusController(), + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + ChaptersAdapter.OnMenuItemClickListener, + SetDisplayModeDialog.Listener, + SetSortingDialog.Listener, + DownloadChaptersDialog.Listener, + DownloadCustomChaptersDialog.Listener, + DeleteChaptersDialog.Listener { + + /** + * Adapter containing a list of chapters. + */ + private var adapter: ChaptersAdapter? = null + + /** + * Action mode for multiple selection. + */ + private var actionMode: ActionMode? = null + + /** + * Selected items. Used to restore selections after a rotation. + */ + private val selectedItems = mutableSetOf() + + init { + setHasOptionsMenu(true) + setOptionsMenuHidden(true) + } + + override fun createPresenter(): ChaptersPresenter { + val ctrl = parentController as MangaController + return ChaptersPresenter(ctrl.manga!!, ctrl.source!!, + ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay) + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.chapters_controller, container, false) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + // Init RecyclerView and adapter + adapter = ChaptersAdapter(this, view.context) + + recycler.adapter = adapter + recycler.layoutManager = LinearLayoutManager(view.context) + recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + recycler.setHasFixedSize(true) + adapter?.fastScroller = fast_scroller + + swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() } + + fab.clicks().subscribeUntilDestroy { + val item = presenter.getNextUnreadChapter() + if (item != null) { + // Create animation listener + val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator?) { + openChapter(item.chapter, true) + } + } + + // Get coordinates and start animation + val coordinates = fab.getCoordinates() + if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) { + openChapter(item.chapter) + } + } else { + view.context.toast(R.string.no_next_chapter) + } + } + } + + override fun onDestroyView(view: View) { + adapter = null + actionMode = null + super.onDestroyView(view) + } + + override fun onActivityResumed(activity: Activity) { + if (view == null) return + + // Check if animation view is visible + if (reveal_view.visibility == View.VISIBLE) { + // Show the unReveal effect + val coordinates = fab.getCoordinates() + reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920) + } + super.onActivityResumed(activity) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.chapters, menu) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + // Initialize menu items. + 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) + + // Set correct checkbox values. + menuFilterRead.isChecked = presenter.onlyRead() + menuFilterUnread.isChecked = presenter.onlyUnread() + menuFilterDownloaded.isChecked = presenter.onlyDownloaded() + menuFilterBookmarked.isChecked = presenter.onlyBookmarked() + + if (presenter.onlyRead()) + //Disable unread filter option if read filter is enabled. + menuFilterUnread.isEnabled = false + if (presenter.onlyUnread()) + //Disable read filter option if unread filter is enabled. + menuFilterRead.isEnabled = false + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_display_mode -> showDisplayModeDialog() + R.id.manga_download -> showDownloadDialog() + R.id.action_sorting_mode -> showSortingDialog() + R.id.action_filter_unread -> { + item.isChecked = !item.isChecked + presenter.setUnreadFilter(item.isChecked) + activity?.invalidateOptionsMenu() + } + R.id.action_filter_read -> { + item.isChecked = !item.isChecked + presenter.setReadFilter(item.isChecked) + activity?.invalidateOptionsMenu() + } + R.id.action_filter_downloaded -> { + item.isChecked = !item.isChecked + presenter.setDownloadedFilter(item.isChecked) + } + R.id.action_filter_bookmarked -> { + item.isChecked = !item.isChecked + presenter.setBookmarkedFilter(item.isChecked) + } + R.id.action_filter_empty -> { + presenter.removeFilters() + activity?.invalidateOptionsMenu() + } + R.id.action_sort -> presenter.revertSortOrder() + else -> return super.onOptionsItemSelected(item) + } + return true + } + + fun onNextChapters(chapters: List) { + // If the list is empty, fetch chapters from source if the conditions are met + // We use presenter chapters instead because they are always unfiltered + if (presenter.chapters.isEmpty()) + initialFetchChapters() + + val adapter = adapter ?: return + adapter.updateDataSet(chapters) + + if (selectedItems.isNotEmpty()) { + adapter.clearSelection() // we need to start from a clean state, index may have changed + createActionModeIfNeeded() + selectedItems.forEach { item -> + val position = adapter.indexOf(item) + if (position != -1 && !adapter.isSelected(position)) { + adapter.toggleSelection(position) + } + } + actionMode?.invalidate() + } + + } + + private fun initialFetchChapters() { + // Only fetch if this view is from the catalog and it hasn't requested previously + if ((parentController as MangaController).fromCatalogue && !presenter.hasRequested) { + fetchChaptersFromSource() + } + } + + private fun fetchChaptersFromSource() { + swipe_refresh?.isRefreshing = true + presenter.fetchChaptersFromSource() + } + + fun onFetchChaptersDone() { + swipe_refresh?.isRefreshing = false + } + + fun onFetchChaptersError(error: Throwable) { + swipe_refresh?.isRefreshing = false + activity?.toast(error.message) + } + + fun onChapterStatusChange(download: Download) { + getHolder(download.chapter)?.notifyStatus(download.status) + } + + private fun getHolder(chapter: Chapter): ChapterHolder? { + return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder + } + + fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) { + val activity = activity ?: return + val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter) + if (hasAnimation) { + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + } + startActivity(intent) + } + + override fun onItemClick(view: View, position: Int): Boolean { + val adapter = adapter ?: return false + val item = adapter.getItem(position) ?: return false + if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { + toggleSelection(position) + return true + } else { + openChapter(item.chapter) + return false + } + } + + override fun onItemLongClick(position: Int) { + createActionModeIfNeeded() + toggleSelection(position) + } + + // SELECTIONS & ACTION MODE + + private fun toggleSelection(position: Int) { + val adapter = adapter ?: return + val item = adapter.getItem(position) ?: return + adapter.toggleSelection(position) + if (adapter.isSelected(position)) { + selectedItems.add(item) + } else { + selectedItems.remove(item) + } + actionMode?.invalidate() + } + + private fun getSelectedChapters(): List { + val adapter = adapter ?: return emptyList() + return adapter.selectedPositions.mapNotNull { adapter.getItem(it) } + } + + private fun createActionModeIfNeeded() { + if (actionMode == null) { + actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) + } + } + + private fun destroyActionModeIfNeeded() { + actionMode?.finish() + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.chapter_selection, menu) + adapter?.mode = SelectableAdapter.Mode.MULTI + return true + } + + @SuppressLint("StringFormatInvalid") + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val count = adapter?.selectedItemCount ?: 0 + if (count == 0) { + // Destroy action mode if there are no items selected. + destroyActionModeIfNeeded() + } else { + mode.title = resources?.getString(R.string.label_selected, count) + } + return false + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_select_all -> selectAll() + R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) + R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) + R.id.action_download -> downloadChapters(getSelectedChapters()) + R.id.action_delete -> showDeleteChaptersConfirmationDialog() + else -> return false + } + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + adapter?.mode = SelectableAdapter.Mode.SINGLE + adapter?.clearSelection() + selectedItems.clear() + actionMode = null + } + + override fun onMenuItemClick(position: Int, item: MenuItem) { + val chapter = adapter?.getItem(position) ?: return + val chapters = listOf(chapter) + + when (item.itemId) { + R.id.action_download -> downloadChapters(chapters) + R.id.action_bookmark -> bookmarkChapters(chapters, true) + R.id.action_remove_bookmark -> bookmarkChapters(chapters, false) + R.id.action_delete -> deleteChapters(chapters) + R.id.action_mark_as_read -> markAsRead(chapters) + R.id.action_mark_as_unread -> markAsUnread(chapters) + R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter) + } + } + + // SELECTION MODE ACTIONS + + private fun selectAll() { + val adapter = adapter ?: return + adapter.selectAll() + selectedItems.addAll(adapter.items) + actionMode?.invalidate() + } + + private fun markAsRead(chapters: List) { + presenter.markChaptersRead(chapters, true) + if (presenter.preferences.removeAfterMarkedAsRead()) { + deleteChapters(chapters) + } + } + + private fun markAsUnread(chapters: List) { + presenter.markChaptersRead(chapters, false) + } + + private fun downloadChapters(chapters: List) { + val view = view + destroyActionModeIfNeeded() + presenter.downloadChapters(chapters) + if (view != null && !presenter.manga.favorite) { + recycler?.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) { + setAction(R.string.action_add) { + presenter.addToLibrary() + } + } + } + } + + + private fun showDeleteChaptersConfirmationDialog() { + DeleteChaptersDialog(this).showDialog(router) + } + + override fun deleteChapters() { + deleteChapters(getSelectedChapters()) + } + + private fun markPreviousAsRead(chapter: ChapterItem) { + val adapter = adapter ?: return + val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items + val chapterPos = chapters.indexOf(chapter) + if (chapterPos != -1) { + markAsRead(chapters.take(chapterPos)) + } + } + + private fun bookmarkChapters(chapters: List, bookmarked: Boolean) { + destroyActionModeIfNeeded() + presenter.bookmarkChapters(chapters, bookmarked) + } + + fun deleteChapters(chapters: List) { + destroyActionModeIfNeeded() + if (chapters.isEmpty()) return + + DeletingChaptersDialog().showDialog(router) + presenter.deleteChapters(chapters) + } + + fun onChaptersDeleted() { + dismissDeletingDialog() + adapter?.notifyDataSetChanged() + } + + fun onChaptersDeletedError(error: Throwable) { + dismissDeletingDialog() + Timber.e(error) + } + + private fun dismissDeletingDialog() { + router.popControllerWithTag(DeletingChaptersDialog.TAG) + } + + // OVERFLOW MENU DIALOGS + + private fun showDisplayModeDialog() { + val preselected = if (presenter.manga.displayMode == Manga.DISPLAY_NAME) 0 else 1 + SetDisplayModeDialog(this, preselected).showDialog(router) + } + + override fun setDisplayMode(id: Int) { + presenter.setDisplayMode(id) + adapter?.notifyDataSetChanged() + } + + private fun showSortingDialog() { + val preselected = if (presenter.manga.sorting == Manga.SORTING_SOURCE) 0 else 1 + SetSortingDialog(this, preselected).showDialog(router) + } + + override fun setSorting(id: Int) { + presenter.setSorting(id) + } + + private fun showDownloadDialog() { + DownloadChaptersDialog(this).showDialog(router) + } + + private fun getUnreadChaptersSorted() = presenter.chapters + .filter { !it.read && it.status == Download.NOT_DOWNLOADED } + .distinctBy { it.name } + .sortedByDescending { it.source_order } + + override fun downloadCustomChapters(amount: Int) { + val chaptersToDownload = getUnreadChaptersSorted().take(amount) + if (chaptersToDownload.isNotEmpty()) { + downloadChapters(chaptersToDownload) + } + } + + private fun showCustomDownloadDialog() { + DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router) + } + + + override fun downloadChapters(choice: Int) { + // i = 0: Download 1 + // i = 1: Download 5 + // i = 2: Download 10 + // i = 3: Download x + // i = 4: Download unread + // i = 5: Download all + val chaptersToDownload = when (choice) { + 0 -> getUnreadChaptersSorted().take(1) + 1 -> getUnreadChaptersSorted().take(5) + 2 -> getUnreadChaptersSorted().take(10) + 3 -> { + showCustomDownloadDialog() + return + } + 4 -> presenter.chapters.filter { !it.read } + 5 -> presenter.chapters + else -> emptyList() + } + if (chaptersToDownload.isNotEmpty()) { + downloadChapters(chaptersToDownload) + } + } +} 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 ff000061e..b04369c34 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,418 +1,418 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.os.Bundle -import com.jakewharton.rxrelay.BehaviorRelay -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 -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -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 timber.log.Timber -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.Date - -/** - * Presenter of [ChaptersController]. - */ -class ChaptersPresenter( - val manga: Manga, - val source: Source, - private val chapterCountRelay: BehaviorRelay, - private val lastUpdateRelay: BehaviorRelay, - private val mangaFavoriteRelay: PublishRelay, - val preferences: PreferencesHelper = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get() -) : BasePresenter() { - - /** - * List of chapters of the manga. It's always unfiltered and unsorted. - */ - var chapters: List = emptyList() - private set - - /** - * Subject of list of chapters to allow updating the view without going to DB. - */ - val chaptersRelay: PublishRelay> - by lazy { PublishRelay.create>() } - - /** - * Whether the chapter list has been requested to the source. - */ - var hasRequested = false - private set - - /** - * Subscription to retrieve the new list of chapters from the source. - */ - private var fetchChaptersSubscription: Subscription? = null - - /** - * Subscription to observe download status changes. - */ - private var observeDownloadsSubscription: Subscription? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - // Prepare the relay. - chaptersRelay.flatMap { applyChapterFilters(it) } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(ChaptersController::onNextChapters, - { _, 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 relay. - add(db.getChapters(manga).asRxObservable() - .map { chapters -> - // Convert every chapter to a model. - chapters.map { it.toModel() } - } - .doOnNext { chapters -> - // Find downloaded chapters - setDownloadedChapters(chapters) - - // Store the last emission - this.chapters = chapters - - // Listen for download status changes - observeDownloads() - - // Emit the number of chapters to the info tab. - chapterCountRelay.call(chapters.maxBy { it.chapter_number }?.chapter_number - ?: 0f) - - // Emit the upload date of the most recent chapter - lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload - ?: 0)) - - } - .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(ChaptersController::onChapterStatusChange, - { _, error -> Timber.e(error) }) - } - - /** - * Converts a chapter from the database to an extended model, allowing to store new fields. - */ - private fun Chapter.toModel(): ChapterItem { - // Create the model object. - val model = ChapterItem(this, manga) - - // Find an active download for this chapter. - val download = downloadManager.queue.find { it.chapter.id == id } - - if (download != null) { - // If there's an active download, assign it. - 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) { - for (chapter in chapters) { - if (downloadManager.isChapterDownloaded(chapter, manga)) { - chapter.status = Download.DOWNLOADED - } - } - } - - /** - * Requests an updated list of chapters from the source. - */ - fun fetchChaptersFromSource() { - hasRequested = true - - if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return - fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } - .subscribeOn(Schedulers.io()) - .map { syncChaptersWithSource(db, it, manga, source) } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - view.onFetchChaptersDone() - }, ChaptersController::onFetchChaptersError) - } - - /** - * Updates the UI after applying the filters. - */ - private fun refreshChapters() { - chaptersRelay.call(chapters) - } - - /** - * Applies the view filters to the list of chapters obtained from the database. - * @param chapters the list of chapters from the database - * @return an observable of the list of chapters filtered and sorted. - */ - private fun applyChapterFilters(chapters: List): Observable> { - var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) - if (onlyUnread()) { - observable = observable.filter { !it.read } - } - else if (onlyRead()) { - observable = observable.filter { it.read } - } - if (onlyDownloaded()) { - observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } - } - if (onlyBookmarked()) { - observable = observable.filter { it.bookmark } - } - val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { - Manga.SORTING_SOURCE -> when (sortDescending()) { - 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) } - false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } - } - else -> throw NotImplementedError("Unimplemented sorting method") - } - return observable.toSortedList(sortFunction) - } - - /** - * Called when a download for the active manga changes status. - * @param download the download whose status changed. - */ - fun onDownloadStatusChange(download: Download) { - // Assign the download to the model object. - if (download.status == Download.QUEUE) { - chapters.find { it.id == download.chapter.id }?.let { - if (it.download == null) { - it.download = download - } - } - } - - // Force UI update if downloaded filter active and download finished. - if (onlyDownloaded() && download.status == Download.DOWNLOADED) - refreshChapters() - } - - /** - * Returns the next unread chapter or null if everything is read. - */ - fun getNextUnreadChapter(): ChapterItem? { - return chapters.sortedByDescending { it.source_order }.find { !it.read } - } - - /** - * Mark the selected chapter list as read/unread. - * @param selectedChapters the list of selected chapters. - * @param read whether to mark chapters as read or unread. - */ - fun markChaptersRead(selectedChapters: List, read: Boolean) { - Observable.from(selectedChapters) - .doOnNext { chapter -> - chapter.read = read - if (!read) { - chapter.last_page_read = 0 - } - } - .toList() - .flatMap { db.updateChaptersProgress(it).asRxObservable() } - .subscribeOn(Schedulers.io()) - .subscribe() - } - - /** - * Downloads the given list of chapters with the manager. - * @param chapters the list of chapters to download. - */ - fun downloadChapters(chapters: List) { - downloadManager.downloadChapters(manga, chapters) - } - - /** - * Bookmarks the given list of chapters. - * @param selectedChapters the list of chapters to bookmark. - */ - fun bookmarkChapters(selectedChapters: List, bookmarked: Boolean) { - Observable.from(selectedChapters) - .doOnNext { chapter -> - chapter.bookmark = bookmarked - } - .toList() - .flatMap { db.updateChaptersProgress(it).asRxObservable() } - .subscribeOn(Schedulers.io()) - .subscribe() - } - - /** - * Deletes the given list of chapter. - * @param chapters the list of chapters to delete. - */ - fun deleteChapters(chapters: List) { - Observable.just(chapters) - .doOnNext { deleteChaptersInternal(chapters) } - .doOnNext { if (onlyDownloaded()) refreshChapters() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - view.onChaptersDeleted() - }, ChaptersController::onChaptersDeletedError) - } - - /** - * Deletes a list of chapters from disk. This method is called in a background thread. - * @param chapters the chapters to delete. - */ - private fun deleteChaptersInternal(chapters: List) { - downloadManager.deleteChapters(chapters, manga, source) - chapters.forEach { - it.status = Download.NOT_DOWNLOADED - it.download = null - } - } - - /** - * Reverses the sorting and requests an UI update. - */ - fun revertSortOrder() { - manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC) - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Sets the read filter and requests an UI update. - * @param onlyUnread whether to display only unread chapters or all chapters. - */ - fun setUnreadFilter(onlyUnread: Boolean) { - manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Sets the read filter and requests an UI update. - * @param onlyRead whether to display only read chapters or all chapters. - */ - fun setReadFilter(onlyRead: Boolean) { - manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Sets the download filter and requests an UI update. - * @param onlyDownloaded whether to display only downloaded chapters or all chapters. - */ - fun setDownloadedFilter(onlyDownloaded: Boolean) { - manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Sets the bookmark filter and requests an UI update. - * @param onlyBookmarked whether to display only bookmarked chapters or all chapters. - */ - fun setBookmarkedFilter(onlyBookmarked: Boolean) { - manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Removes all filters and requests an UI update. - */ - fun removeFilters() { - manga.readFilter = Manga.SHOW_ALL - manga.downloadedFilter = Manga.SHOW_ALL - manga.bookmarkedFilter = Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Adds manga to library - */ - fun addToLibrary() { - mangaFavoriteRelay.call(true) - } - - /** - * Sets the active display mode. - * @param mode the mode to set. - */ - fun setDisplayMode(mode: Int) { - manga.displayMode = mode - db.updateFlags(manga).executeAsBlocking() - } - - /** - * Sets the sorting method and requests an UI update. - * @param sort the sorting mode. - */ - fun setSorting(sort: Int) { - manga.sorting = sort - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Whether the display only downloaded filter is enabled. - */ - fun onlyDownloaded(): Boolean { - return manga.downloadedFilter == Manga.SHOW_DOWNLOADED - } - - /** - * Whether the display only downloaded filter is enabled. - */ - fun onlyBookmarked(): Boolean { - return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED - } - - /** - * Whether the display only unread filter is enabled. - */ - fun onlyUnread(): Boolean { - return manga.readFilter == Manga.SHOW_UNREAD - } - - /** - * Whether the display only read filter is enabled. - */ - fun onlyRead(): Boolean { - return manga.readFilter == Manga.SHOW_READ - } - - /** - * Whether the sorting method is descending or ascending. - */ - fun sortDescending(): Boolean { - return manga.sortDescending() - } - -} +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.os.Bundle +import com.jakewharton.rxrelay.BehaviorRelay +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 +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +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 timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Date + +/** + * Presenter of [ChaptersController]. + */ +class ChaptersPresenter( + val manga: Manga, + val source: Source, + private val chapterCountRelay: BehaviorRelay, + private val lastUpdateRelay: BehaviorRelay, + private val mangaFavoriteRelay: PublishRelay, + val preferences: PreferencesHelper = Injekt.get(), + private val db: DatabaseHelper = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get() +) : BasePresenter() { + + /** + * List of chapters of the manga. It's always unfiltered and unsorted. + */ + var chapters: List = emptyList() + private set + + /** + * Subject of list of chapters to allow updating the view without going to DB. + */ + val chaptersRelay: PublishRelay> + by lazy { PublishRelay.create>() } + + /** + * Whether the chapter list has been requested to the source. + */ + var hasRequested = false + private set + + /** + * Subscription to retrieve the new list of chapters from the source. + */ + private var fetchChaptersSubscription: Subscription? = null + + /** + * Subscription to observe download status changes. + */ + private var observeDownloadsSubscription: Subscription? = null + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + // Prepare the relay. + chaptersRelay.flatMap { applyChapterFilters(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(ChaptersController::onNextChapters, + { _, 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 relay. + add(db.getChapters(manga).asRxObservable() + .map { chapters -> + // Convert every chapter to a model. + chapters.map { it.toModel() } + } + .doOnNext { chapters -> + // Find downloaded chapters + setDownloadedChapters(chapters) + + // Store the last emission + this.chapters = chapters + + // Listen for download status changes + observeDownloads() + + // Emit the number of chapters to the info tab. + chapterCountRelay.call(chapters.maxBy { it.chapter_number }?.chapter_number + ?: 0f) + + // Emit the upload date of the most recent chapter + lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload + ?: 0)) + + } + .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(ChaptersController::onChapterStatusChange, + { _, error -> Timber.e(error) }) + } + + /** + * Converts a chapter from the database to an extended model, allowing to store new fields. + */ + private fun Chapter.toModel(): ChapterItem { + // Create the model object. + val model = ChapterItem(this, manga) + + // Find an active download for this chapter. + val download = downloadManager.queue.find { it.chapter.id == id } + + if (download != null) { + // If there's an active download, assign it. + 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) { + for (chapter in chapters) { + if (downloadManager.isChapterDownloaded(chapter, manga)) { + chapter.status = Download.DOWNLOADED + } + } + } + + /** + * Requests an updated list of chapters from the source. + */ + fun fetchChaptersFromSource() { + hasRequested = true + + if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return + fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } + .subscribeOn(Schedulers.io()) + .map { syncChaptersWithSource(db, it, manga, source) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst({ view, _ -> + view.onFetchChaptersDone() + }, ChaptersController::onFetchChaptersError) + } + + /** + * Updates the UI after applying the filters. + */ + private fun refreshChapters() { + chaptersRelay.call(chapters) + } + + /** + * Applies the view filters to the list of chapters obtained from the database. + * @param chapters the list of chapters from the database + * @return an observable of the list of chapters filtered and sorted. + */ + private fun applyChapterFilters(chapters: List): Observable> { + var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) + if (onlyUnread()) { + observable = observable.filter { !it.read } + } + else if (onlyRead()) { + observable = observable.filter { it.read } + } + if (onlyDownloaded()) { + observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } + } + if (onlyBookmarked()) { + observable = observable.filter { it.bookmark } + } + val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { + Manga.SORTING_SOURCE -> when (sortDescending()) { + 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) } + false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } + } + else -> throw NotImplementedError("Unimplemented sorting method") + } + return observable.toSortedList(sortFunction) + } + + /** + * Called when a download for the active manga changes status. + * @param download the download whose status changed. + */ + fun onDownloadStatusChange(download: Download) { + // Assign the download to the model object. + if (download.status == Download.QUEUE) { + chapters.find { it.id == download.chapter.id }?.let { + if (it.download == null) { + it.download = download + } + } + } + + // Force UI update if downloaded filter active and download finished. + if (onlyDownloaded() && download.status == Download.DOWNLOADED) + refreshChapters() + } + + /** + * Returns the next unread chapter or null if everything is read. + */ + fun getNextUnreadChapter(): ChapterItem? { + return chapters.sortedByDescending { it.source_order }.find { !it.read } + } + + /** + * Mark the selected chapter list as read/unread. + * @param selectedChapters the list of selected chapters. + * @param read whether to mark chapters as read or unread. + */ + fun markChaptersRead(selectedChapters: List, read: Boolean) { + Observable.from(selectedChapters) + .doOnNext { chapter -> + chapter.read = read + if (!read) { + chapter.last_page_read = 0 + } + } + .toList() + .flatMap { db.updateChaptersProgress(it).asRxObservable() } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Downloads the given list of chapters with the manager. + * @param chapters the list of chapters to download. + */ + fun downloadChapters(chapters: List) { + downloadManager.downloadChapters(manga, chapters) + } + + /** + * Bookmarks the given list of chapters. + * @param selectedChapters the list of chapters to bookmark. + */ + fun bookmarkChapters(selectedChapters: List, bookmarked: Boolean) { + Observable.from(selectedChapters) + .doOnNext { chapter -> + chapter.bookmark = bookmarked + } + .toList() + .flatMap { db.updateChaptersProgress(it).asRxObservable() } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Deletes the given list of chapter. + * @param chapters the list of chapters to delete. + */ + fun deleteChapters(chapters: List) { + Observable.just(chapters) + .doOnNext { deleteChaptersInternal(chapters) } + .doOnNext { if (onlyDownloaded()) refreshChapters() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst({ view, _ -> + view.onChaptersDeleted() + }, ChaptersController::onChaptersDeletedError) + } + + /** + * Deletes a list of chapters from disk. This method is called in a background thread. + * @param chapters the chapters to delete. + */ + private fun deleteChaptersInternal(chapters: List) { + downloadManager.deleteChapters(chapters, manga, source) + chapters.forEach { + it.status = Download.NOT_DOWNLOADED + it.download = null + } + } + + /** + * Reverses the sorting and requests an UI update. + */ + fun revertSortOrder() { + manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC) + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the read filter and requests an UI update. + * @param onlyUnread whether to display only unread chapters or all chapters. + */ + fun setUnreadFilter(onlyUnread: Boolean) { + manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the read filter and requests an UI update. + * @param onlyRead whether to display only read chapters or all chapters. + */ + fun setReadFilter(onlyRead: Boolean) { + manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the download filter and requests an UI update. + * @param onlyDownloaded whether to display only downloaded chapters or all chapters. + */ + fun setDownloadedFilter(onlyDownloaded: Boolean) { + manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the bookmark filter and requests an UI update. + * @param onlyBookmarked whether to display only bookmarked chapters or all chapters. + */ + fun setBookmarkedFilter(onlyBookmarked: Boolean) { + manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Removes all filters and requests an UI update. + */ + fun removeFilters() { + manga.readFilter = Manga.SHOW_ALL + manga.downloadedFilter = Manga.SHOW_ALL + manga.bookmarkedFilter = Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Adds manga to library + */ + fun addToLibrary() { + mangaFavoriteRelay.call(true) + } + + /** + * Sets the active display mode. + * @param mode the mode to set. + */ + fun setDisplayMode(mode: Int) { + manga.displayMode = mode + db.updateFlags(manga).executeAsBlocking() + } + + /** + * Sets the sorting method and requests an UI update. + * @param sort the sorting mode. + */ + fun setSorting(sort: Int) { + manga.sorting = sort + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Whether the display only downloaded filter is enabled. + */ + fun onlyDownloaded(): Boolean { + return manga.downloadedFilter == Manga.SHOW_DOWNLOADED + } + + /** + * Whether the display only downloaded filter is enabled. + */ + fun onlyBookmarked(): Boolean { + return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED + } + + /** + * Whether the display only unread filter is enabled. + */ + fun onlyUnread(): Boolean { + return manga.readFilter == Manga.SHOW_UNREAD + } + + /** + * Whether the display only read filter is enabled. + */ + fun onlyRead(): Boolean { + return manga.readFilter == Manga.SHOW_READ + } + + /** + * Whether the sorting method is descending or ascending. + */ + fun sortDescending(): Boolean { + return manga.sortDescending() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt index a269fe085..1ac72e731 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt @@ -1,32 +1,32 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class DeleteChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : DeleteChaptersDialog.Listener { - - constructor(target: T) : this() { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .content(R.string.confirm_delete_chapters) - .positiveText(android.R.string.yes) - .negativeText(android.R.string.no) - .onPositive { _, _ -> - (targetController as? Listener)?.deleteChapters() - } - .show() - } - - interface Listener { - fun deleteChapters() - } - +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class DeleteChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : DeleteChaptersDialog.Listener { + + constructor(target: T) : this() { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog.Builder(activity!!) + .content(R.string.confirm_delete_chapters) + .positiveText(android.R.string.yes) + .negativeText(android.R.string.no) + .onPositive { _, _ -> + (targetController as? Listener)?.deleteChapters() + } + .show() + } + + interface Listener { + fun deleteChapters() + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeletingChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeletingChaptersDialog.kt index fcfd6b9ad..8fa6df586 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeletingChaptersDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeletingChaptersDialog.kt @@ -1,27 +1,27 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Router -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) { - - companion object { - const val TAG = "deleting_dialog" - } - - override fun onCreateDialog(savedState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .progress(true, 0) - .content(R.string.deleting) - .build() - } - - override fun showDialog(router: Router) { - showDialog(router, TAG) - } - +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Router +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) { + + companion object { + const val TAG = "deleting_dialog" + } + + override fun onCreateDialog(savedState: Bundle?): Dialog { + return MaterialDialog.Builder(activity!!) + .progress(true, 0) + .content(R.string.deleting) + .build() + } + + override fun showDialog(router: Router) { + showDialog(router, TAG) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadChaptersDialog.kt index c3016841c..b00356a47 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadChaptersDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadChaptersDialog.kt @@ -1,42 +1,42 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class DownloadChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : DownloadChaptersDialog.Listener { - - constructor(target: T) : this() { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - - val choices = intArrayOf( - R.string.download_1, - R.string.download_5, - R.string.download_10, - R.string.download_custom, - R.string.download_unread, - R.string.download_all - ).map { activity.getString(it) } - - return MaterialDialog.Builder(activity) - .negativeText(android.R.string.cancel) - .items(choices) - .itemsCallback { _, _, position, _ -> - (targetController as? Listener)?.downloadChapters(position) - } - .build() - } - - interface Listener { - fun downloadChapters(choice: Int) - } - +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class DownloadChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : DownloadChaptersDialog.Listener { + + constructor(target: T) : this() { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val activity = activity!! + + val choices = intArrayOf( + R.string.download_1, + R.string.download_5, + R.string.download_10, + R.string.download_custom, + R.string.download_unread, + R.string.download_all + ).map { activity.getString(it) } + + return MaterialDialog.Builder(activity) + .negativeText(android.R.string.cancel) + .items(choices) + .itemsCallback { _, _, position, _ -> + (targetController as? Listener)?.downloadChapters(position) + } + .build() + } + + interface Listener { + fun downloadChapters(choice: Int) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetDisplayModeDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetDisplayModeDialog.kt index 608742b74..56ce4affe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetDisplayModeDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetDisplayModeDialog.kt @@ -1,43 +1,43 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class SetDisplayModeDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : SetDisplayModeDialog.Listener { - - private val selectedIndex = args.getInt("selected", -1) - - constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { - putInt("selected", selectedIndex) - }) { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - val ids = intArrayOf(Manga.DISPLAY_NAME, Manga.DISPLAY_NUMBER) - val choices = intArrayOf(R.string.show_title, R.string.show_chapter_number) - .map { activity.getString(it) } - - return MaterialDialog.Builder(activity) - .title(R.string.action_display_mode) - .items(choices) - .itemsIds(ids) - .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> - (targetController as? Listener)?.setDisplayMode(itemView.id) - true - } - .build() - } - - interface Listener { - fun setDisplayMode(id: Int) - } - +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class SetDisplayModeDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : SetDisplayModeDialog.Listener { + + private val selectedIndex = args.getInt("selected", -1) + + constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { + putInt("selected", selectedIndex) + }) { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val activity = activity!! + val ids = intArrayOf(Manga.DISPLAY_NAME, Manga.DISPLAY_NUMBER) + val choices = intArrayOf(R.string.show_title, R.string.show_chapter_number) + .map { activity.getString(it) } + + return MaterialDialog.Builder(activity) + .title(R.string.action_display_mode) + .items(choices) + .itemsIds(ids) + .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> + (targetController as? Listener)?.setDisplayMode(itemView.id) + true + } + .build() + } + + interface Listener { + fun setDisplayMode(id: Int) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetSortingDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetSortingDialog.kt index c6baca5b9..861afaf1b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetSortingDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetSortingDialog.kt @@ -1,43 +1,43 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class SetSortingDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : SetSortingDialog.Listener { - - private val selectedIndex = args.getInt("selected", -1) - - constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { - putInt("selected", selectedIndex) - }) { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER) - val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number) - .map { activity.getString(it) } - - return MaterialDialog.Builder(activity) - .title(R.string.sorting_mode) - .items(choices) - .itemsIds(ids) - .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> - (targetController as? Listener)?.setSorting(itemView.id) - true - } - .build() - } - - interface Listener { - fun setSorting(id: Int) - } - +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class SetSortingDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : SetSortingDialog.Listener { + + private val selectedIndex = args.getInt("selected", -1) + + constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { + putInt("selected", selectedIndex) + }) { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val activity = activity!! + val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER) + val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number) + .map { activity.getString(it) } + + return MaterialDialog.Builder(activity) + .title(R.string.sorting_mode) + .items(choices) + .itemsIds(ids) + .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> + (targetController as? Listener)?.setSorting(itemView.id) + true + } + .build() + } + + interface Listener { + fun setSorting(id: Int) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt index 8f12ed164..7e60dc30d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt @@ -1,577 +1,577 @@ -package eu.kanade.tachiyomi.ui.manga.info - -import android.app.Dialog -import android.app.PendingIntent -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.drawable.Drawable -import android.net.Uri -import android.os.Build -import android.os.Bundle -import androidx.browser.customtabs.CustomTabsIntent -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import android.view.* -import android.widget.Toast -import com.afollestad.materialdialogs.MaterialDialog -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.resource.bitmap.RoundedCorners -import com.bumptech.glide.request.target.SimpleTarget -import com.bumptech.glide.request.transition.Transition -import com.jakewharton.rxbinding.support.v4.widget.refreshes -import com.jakewharton.rxbinding.view.clicks -import com.jakewharton.rxbinding.view.longClicks -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.data.notification.NotificationReceiver -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -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.controller.DialogController -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController -import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog -import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.util.getResourceColor -import eu.kanade.tachiyomi.util.openInBrowser -import eu.kanade.tachiyomi.util.snack -import eu.kanade.tachiyomi.util.toast -import eu.kanade.tachiyomi.util.truncateCenter -import jp.wasabeef.glide.transformations.CropSquareTransformation -import jp.wasabeef.glide.transformations.MaskTransformation -import kotlinx.android.synthetic.main.manga_info_controller.* -import uy.kohesive.injekt.injectLazy -import java.text.DateFormat -import java.text.DecimalFormat -import java.util.Date - -/** - * Fragment that shows manga information. - * Uses R.layout.manga_info_controller. - * UI related actions should be called from here. - */ -class MangaInfoController : NucleusController(), - ChangeMangaCategoriesDialog.Listener { - - /** - * Preferences helper. - */ - private val preferences: PreferencesHelper by injectLazy() - - init { - setHasOptionsMenu(true) - setOptionsMenuHidden(true) - } - - override fun createPresenter(): MangaInfoPresenter { - val ctrl = parentController as MangaController - return MangaInfoPresenter(ctrl.manga!!, ctrl.source!!, - ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay) - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.manga_info_controller, container, false) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - // Set onclickListener to toggle favorite when FAB clicked. - fab_favorite.clicks().subscribeUntilDestroy { onFabClick() } - - // Set onLongClickListener to manage categories when FAB is clicked. - fab_favorite.longClicks().subscribeUntilDestroy{ onFabLongClick() } - - // Set SwipeRefresh to refresh manga data. - swipe_refresh.refreshes().subscribeUntilDestroy { fetchMangaFromSource() } - - manga_full_title.longClicks().subscribeUntilDestroy { - copyToClipboard(view.context.getString(R.string.title), manga_full_title.text.toString()) - } - - manga_full_title.clicks().subscribeUntilDestroy { - performGlobalSearch(manga_full_title.text.toString()) - } - - manga_artist.longClicks().subscribeUntilDestroy { - copyToClipboard(manga_artist_label.text.toString(), manga_artist.text.toString()) - } - - manga_artist.clicks().subscribeUntilDestroy { - performGlobalSearch(manga_artist.text.toString()) - } - - manga_author.longClicks().subscribeUntilDestroy { - copyToClipboard(manga_author.text.toString(), manga_author.text.toString()) - } - - manga_author.clicks().subscribeUntilDestroy { - performGlobalSearch(manga_author.text.toString()) - } - - manga_summary.longClicks().subscribeUntilDestroy { - copyToClipboard(view.context.getString(R.string.description), manga_summary.text.toString()) - } - - //manga_genres_tags.setOnTagClickListener { tag -> performGlobalSearch(tag) } - - manga_cover.longClicks().subscribeUntilDestroy { - copyToClipboard(view.context.getString(R.string.title), presenter.manga.title) - } - - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.manga_info, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_open_in_browser -> openInBrowser() - R.id.action_open_in_web_view -> openInWebView() - R.id.action_share -> shareManga() - R.id.action_add_to_home_screen -> addToHomeScreen() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - /** - * Check if manga is initialized. - * If true update view with manga information, - * if false fetch manga information - * - * @param manga manga object containing information about manga. - * @param source the source of the manga. - */ - fun onNextManga(manga: Manga, source: Source) { - if (manga.initialized) { - // Update view. - setMangaInfo(manga, source) - - } else { - // Initialize manga. - fetchMangaFromSource() - } - } - - /** - * Update the view with manga information. - * - * @param manga manga object containing information about manga. - * @param source the source of the manga. - */ - private fun setMangaInfo(manga: Manga, source: Source?) { - val view = view ?: return - - //update full title TextView. - manga_full_title.text = if (manga.title.isBlank()) { - view.context.getString(R.string.unknown) - } else { - manga.title - } - - // Update artist TextView. - manga_artist.text = if (manga.artist.isNullOrBlank()) { - view.context.getString(R.string.unknown) - } else { - manga.artist - } - - // Update author TextView. - manga_author.text = if (manga.author.isNullOrBlank()) { - view.context.getString(R.string.unknown) - } else { - manga.author - } - - // If manga source is known update source TextView. - manga_source.text = if (source == null) { - view.context.getString(R.string.unknown) - } else { - source.toString() - } - - // Update genres list - if (manga.genre.isNullOrBlank().not()) { - manga_genres_tags.setTags(manga.genre?.split(", ")) - } - - // Update description TextView. - manga_summary.text = if (manga.description.isNullOrBlank()) { - view.context.getString(R.string.unknown) - } else { - manga.description - } - - // Update status TextView. - manga_status.setText(when (manga.status) { - SManga.ONGOING -> R.string.ongoing - SManga.COMPLETED -> R.string.completed - SManga.LICENSED -> R.string.licensed - else -> R.string.unknown - }) - - // Set the favorite drawable to the correct one. - setFavoriteDrawable(manga.favorite) - - // Set cover if it wasn't already. - if (manga_cover.drawable == null && !manga.thumbnail_url.isNullOrEmpty()) { - GlideApp.with(view.context) - .load(manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .into(manga_cover) - - if (backdrop != null) { - GlideApp.with(view.context) - .load(manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .into(backdrop) - } - } - } - - override fun onDestroyView(view: View) { - manga_genres_tags.setOnTagClickListener(null) - super.onDestroyView(view) - } - - /** - * Update chapter count TextView. - * - * @param count number of chapters. - */ - fun setChapterCount(count: Float) { - if (count > 0f) { - manga_chapters?.text = DecimalFormat("#.#").format(count) - } else { - manga_chapters?.text = resources?.getString(R.string.unknown) - } - } - - fun setLastUpdateDate(date: Date) { - if (date.time != 0L) { - manga_last_update?.text = DateFormat.getDateInstance(DateFormat.SHORT).format(date) - } else { - manga_last_update?.text = resources?.getString(R.string.unknown) - } - } - - /** - * Toggles the favorite status and asks for confirmation to delete downloaded chapters. - */ - private fun toggleFavorite() { - val view = view - - val isNowFavorite = presenter.toggleFavorite() - if (view != null && !isNowFavorite && presenter.hasDownloads()) { - view.snack(view.context.getString(R.string.delete_downloads_for_manga)) { - setAction(R.string.action_delete) { - presenter.deleteDownloads() - } - } - } - } - - /** - * Open the manga in browser. - */ - private fun openInBrowser() { - val context = view?.context ?: return - val source = presenter.source as? HttpSource ?: return - - context.openInBrowser(source.mangaDetailsRequest(presenter.manga).url.toString()) - } - - private fun openInWebView() { - val source = presenter.source as? HttpSource ?: return - - val url = try { - source.mangaDetailsRequest(presenter.manga).url.toString() - } catch (e: Exception) { - return - } - - parentController?.router?.pushController(MangaWebViewController(source.id, url) - .withFadeTransaction()) - } - - /** - * Called to run Intent with [Intent.ACTION_SEND], which show share dialog. - */ - private fun shareManga() { - val context = view?.context ?: return - - val source = presenter.source as? HttpSource ?: return - try { - val url = source.mangaDetailsRequest(presenter.manga).url.toString() - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - } - startActivity(Intent.createChooser(intent, context.getString(R.string.action_share))) - } catch (e: Exception) { - context.toast(e.message) - } - } - - /** - * Update FAB with correct drawable. - * - * @param isFavorite determines if manga is favorite or not. - */ - private fun setFavoriteDrawable(isFavorite: Boolean) { - // Set the Favorite drawable to the correct one. - // Border drawable if false, filled drawable if true. - fab_favorite?.setImageResource(if (isFavorite) - R.drawable.ic_bookmark_white_24dp - else - R.drawable.ic_add_to_library_24dp) - } - - /** - * Start fetching manga information from source. - */ - private fun fetchMangaFromSource() { - setRefreshing(true) - // Call presenter and start fetching manga information - presenter.fetchMangaFromSource() - } - - - /** - * Update swipe refresh to stop showing refresh in progress spinner. - */ - fun onFetchMangaDone() { - setRefreshing(false) - } - - /** - * Update swipe refresh to start showing refresh in progress spinner. - */ - fun onFetchMangaError(error: Throwable) { - setRefreshing(false) - activity?.toast(error.message) - } - - /** - * Set swipe refresh status. - * - * @param value whether it should be refreshing or not. - */ - private fun setRefreshing(value: Boolean) { - swipe_refresh?.isRefreshing = value - } - - /** - * Called when the fab is clicked. - */ - private fun onFabClick() { - val manga = presenter.manga - toggleFavorite() - if (manga.favorite) { - val categories = presenter.getCategories() - val defaultCategoryId = preferences.defaultCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId } - when { - defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory) - defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category - presenter.moveMangaToCategory(manga, null) - else -> { - val ids = presenter.getMangaCategoryIds(manga) - val preselected = ids.mapNotNull { id -> - categories.indexOfFirst { it.id == id }.takeIf { it != -1 } - }.toTypedArray() - - ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) - .showDialog(router) - } - } - activity?.toast(activity?.getString(R.string.manga_added_library)) - } else { - activity?.toast(activity?.getString(R.string.manga_removed_library)) - } - } - - /** - * Called when the fab is long clicked. - */ - private fun onFabLongClick() { - val manga = presenter.manga - if (!manga.favorite) { - toggleFavorite() - activity?.toast(activity?.getString(R.string.manga_added_library)) - } - val categories = presenter.getCategories() - if (categories.isEmpty()) { - // no categories exist, display a message about adding categories - activity?.toast(activity?.getString(R.string.action_add_category)) - } else { - val ids = presenter.getMangaCategoryIds(manga) - val preselected = ids.mapNotNull { id -> - categories.indexOfFirst { it.id == id }.takeIf { it != -1 } - }.toTypedArray() - - ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) - .showDialog(router) - } - } - - override fun updateCategoriesForMangas(mangas: List, categories: List) { - val manga = mangas.firstOrNull() ?: return - presenter.moveMangaToCategories(manga, categories) - } - - /** - * Add a shortcut of the manga to the home screen - */ - private fun addToHomeScreen() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // TODO are transformations really unsupported or is it just the Pixel Launcher? - createShortcutForShape() - } else { - ChooseShapeDialog(this).showDialog(router) - } - } - - /** - * Dialog to choose a shape for the icon. - */ - private class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) { - - constructor(target: MangaInfoController) : this() { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val modes = intArrayOf(R.string.circular_icon, - R.string.rounded_icon, - R.string.square_icon, - R.string.star_icon) - - return MaterialDialog.Builder(activity!!) - .title(R.string.icon_shape) - .negativeText(android.R.string.cancel) - .items(modes.map { activity?.getString(it) }) - .itemsCallback { _, _, i, _ -> - (targetController as? MangaInfoController)?.createShortcutForShape(i) - } - .build() - } - } - - /** - * Retrieves the bitmap of the shortcut with the requested shape and calls [createShortcut] when - * the resource is available. - * - * @param i The shape index to apply. Defaults to circle crop transformation. - */ - private fun createShortcutForShape(i: Int = 0) { - if (activity == null) return - GlideApp.with(activity!!) - .asBitmap() - .load(presenter.manga) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .apply { - when (i) { - 0 -> circleCrop() - 1 -> transform(RoundedCorners(5)) - 2 -> transform(CropSquareTransformation()) - 3 -> centerCrop().transform(MaskTransformation(R.drawable.mask_star)) - } - } - .into(object : SimpleTarget(96, 96) { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - createShortcut(resource) - } - - override fun onLoadFailed(errorDrawable: Drawable?) { - activity?.toast(R.string.icon_creation_fail) - } - }) - } - - /** - * Copies a string to clipboard - * - * @param label Label to show to the user describing the content - * @param content the actual text to copy to the board - */ - private fun copyToClipboard(label: String, content: String) { - if (content.isBlank()) return - - val activity = activity ?: return - val view = view ?: return - - val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.primaryClip = ClipData.newPlainText(label, content) - - activity.toast(view.context.getString(R.string.copied_to_clipboard, content.truncateCenter(20)), - Toast.LENGTH_SHORT) - } - - /** - * Perform a global search using the provided query. - * - * @param query the search query to pass to the search controller - */ - fun performGlobalSearch(query: String) { - val router = parentController?.router ?: return - router.pushController(CatalogueSearchController(query).withFadeTransaction()) - } - - /** - * Create shortcut using ShortcutManager. - * - * @param icon The image of the shortcut. - */ - private fun createShortcut(icon: Bitmap) { - val activity = activity ?: return - val mangaControllerArgs = parentController?.args ?: return - - // Create the shortcut intent. - val shortcutIntent = activity.intent - .setAction(MainActivity.SHORTCUT_MANGA) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - .putExtra(MangaController.MANGA_EXTRA, - mangaControllerArgs.getLong(MangaController.MANGA_EXTRA)) - - // Check if shortcut placement is supported - if (ShortcutManagerCompat.isRequestPinShortcutSupported(activity)) { - val shortcutId = "manga-shortcut-${presenter.manga.title}-${presenter.source.name}" - - // Create shortcut info - val shortcutInfo = ShortcutInfoCompat.Builder(activity, shortcutId) - .setShortLabel(presenter.manga.title) - .setIcon(IconCompat.createWithBitmap(icon)) - .setIntent(shortcutIntent) - .build() - - val successCallback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create the CallbackIntent. - val intent = ShortcutManagerCompat.createShortcutResultIntent(activity, shortcutInfo) - - // Configure the intent so that the broadcast receiver gets the callback successfully. - PendingIntent.getBroadcast(activity, 0, intent, 0) - } else { - NotificationReceiver.shortcutCreatedBroadcast(activity) - } - - // Request shortcut. - ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfo, - successCallback.intentSender) - } - } - -} +package eu.kanade.tachiyomi.ui.manga.info + +import android.app.Dialog +import android.app.PendingIntent +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import android.view.* +import android.widget.Toast +import com.afollestad.materialdialogs.MaterialDialog +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.target.SimpleTarget +import com.bumptech.glide.request.transition.Transition +import com.jakewharton.rxbinding.support.v4.widget.refreshes +import com.jakewharton.rxbinding.view.clicks +import com.jakewharton.rxbinding.view.longClicks +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +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.controller.DialogController +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController +import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.openInBrowser +import eu.kanade.tachiyomi.util.snack +import eu.kanade.tachiyomi.util.toast +import eu.kanade.tachiyomi.util.truncateCenter +import jp.wasabeef.glide.transformations.CropSquareTransformation +import jp.wasabeef.glide.transformations.MaskTransformation +import kotlinx.android.synthetic.main.manga_info_controller.* +import uy.kohesive.injekt.injectLazy +import java.text.DateFormat +import java.text.DecimalFormat +import java.util.Date + +/** + * Fragment that shows manga information. + * Uses R.layout.manga_info_controller. + * UI related actions should be called from here. + */ +class MangaInfoController : NucleusController(), + ChangeMangaCategoriesDialog.Listener { + + /** + * Preferences helper. + */ + private val preferences: PreferencesHelper by injectLazy() + + init { + setHasOptionsMenu(true) + setOptionsMenuHidden(true) + } + + override fun createPresenter(): MangaInfoPresenter { + val ctrl = parentController as MangaController + return MangaInfoPresenter(ctrl.manga!!, ctrl.source!!, + ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay) + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.manga_info_controller, container, false) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + // Set onclickListener to toggle favorite when FAB clicked. + fab_favorite.clicks().subscribeUntilDestroy { onFabClick() } + + // Set onLongClickListener to manage categories when FAB is clicked. + fab_favorite.longClicks().subscribeUntilDestroy{ onFabLongClick() } + + // Set SwipeRefresh to refresh manga data. + swipe_refresh.refreshes().subscribeUntilDestroy { fetchMangaFromSource() } + + manga_full_title.longClicks().subscribeUntilDestroy { + copyToClipboard(view.context.getString(R.string.title), manga_full_title.text.toString()) + } + + manga_full_title.clicks().subscribeUntilDestroy { + performGlobalSearch(manga_full_title.text.toString()) + } + + manga_artist.longClicks().subscribeUntilDestroy { + copyToClipboard(manga_artist_label.text.toString(), manga_artist.text.toString()) + } + + manga_artist.clicks().subscribeUntilDestroy { + performGlobalSearch(manga_artist.text.toString()) + } + + manga_author.longClicks().subscribeUntilDestroy { + copyToClipboard(manga_author.text.toString(), manga_author.text.toString()) + } + + manga_author.clicks().subscribeUntilDestroy { + performGlobalSearch(manga_author.text.toString()) + } + + manga_summary.longClicks().subscribeUntilDestroy { + copyToClipboard(view.context.getString(R.string.description), manga_summary.text.toString()) + } + + //manga_genres_tags.setOnTagClickListener { tag -> performGlobalSearch(tag) } + + manga_cover.longClicks().subscribeUntilDestroy { + copyToClipboard(view.context.getString(R.string.title), presenter.manga.title) + } + + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.manga_info, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_open_in_browser -> openInBrowser() + R.id.action_open_in_web_view -> openInWebView() + R.id.action_share -> shareManga() + R.id.action_add_to_home_screen -> addToHomeScreen() + else -> return super.onOptionsItemSelected(item) + } + return true + } + + /** + * Check if manga is initialized. + * If true update view with manga information, + * if false fetch manga information + * + * @param manga manga object containing information about manga. + * @param source the source of the manga. + */ + fun onNextManga(manga: Manga, source: Source) { + if (manga.initialized) { + // Update view. + setMangaInfo(manga, source) + + } else { + // Initialize manga. + fetchMangaFromSource() + } + } + + /** + * Update the view with manga information. + * + * @param manga manga object containing information about manga. + * @param source the source of the manga. + */ + private fun setMangaInfo(manga: Manga, source: Source?) { + val view = view ?: return + + //update full title TextView. + manga_full_title.text = if (manga.title.isBlank()) { + view.context.getString(R.string.unknown) + } else { + manga.title + } + + // Update artist TextView. + manga_artist.text = if (manga.artist.isNullOrBlank()) { + view.context.getString(R.string.unknown) + } else { + manga.artist + } + + // Update author TextView. + manga_author.text = if (manga.author.isNullOrBlank()) { + view.context.getString(R.string.unknown) + } else { + manga.author + } + + // If manga source is known update source TextView. + manga_source.text = if (source == null) { + view.context.getString(R.string.unknown) + } else { + source.toString() + } + + // Update genres list + if (manga.genre.isNullOrBlank().not()) { + manga_genres_tags.setTags(manga.genre?.split(", ")) + } + + // Update description TextView. + manga_summary.text = if (manga.description.isNullOrBlank()) { + view.context.getString(R.string.unknown) + } else { + manga.description + } + + // Update status TextView. + manga_status.setText(when (manga.status) { + SManga.ONGOING -> R.string.ongoing + SManga.COMPLETED -> R.string.completed + SManga.LICENSED -> R.string.licensed + else -> R.string.unknown + }) + + // Set the favorite drawable to the correct one. + setFavoriteDrawable(manga.favorite) + + // Set cover if it wasn't already. + if (manga_cover.drawable == null && !manga.thumbnail_url.isNullOrEmpty()) { + GlideApp.with(view.context) + .load(manga) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(manga_cover) + + if (backdrop != null) { + GlideApp.with(view.context) + .load(manga) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(backdrop) + } + } + } + + override fun onDestroyView(view: View) { + manga_genres_tags.setOnTagClickListener(null) + super.onDestroyView(view) + } + + /** + * Update chapter count TextView. + * + * @param count number of chapters. + */ + fun setChapterCount(count: Float) { + if (count > 0f) { + manga_chapters?.text = DecimalFormat("#.#").format(count) + } else { + manga_chapters?.text = resources?.getString(R.string.unknown) + } + } + + fun setLastUpdateDate(date: Date) { + if (date.time != 0L) { + manga_last_update?.text = DateFormat.getDateInstance(DateFormat.SHORT).format(date) + } else { + manga_last_update?.text = resources?.getString(R.string.unknown) + } + } + + /** + * Toggles the favorite status and asks for confirmation to delete downloaded chapters. + */ + private fun toggleFavorite() { + val view = view + + val isNowFavorite = presenter.toggleFavorite() + if (view != null && !isNowFavorite && presenter.hasDownloads()) { + view.snack(view.context.getString(R.string.delete_downloads_for_manga)) { + setAction(R.string.action_delete) { + presenter.deleteDownloads() + } + } + } + } + + /** + * Open the manga in browser. + */ + private fun openInBrowser() { + val context = view?.context ?: return + val source = presenter.source as? HttpSource ?: return + + context.openInBrowser(source.mangaDetailsRequest(presenter.manga).url.toString()) + } + + private fun openInWebView() { + val source = presenter.source as? HttpSource ?: return + + val url = try { + source.mangaDetailsRequest(presenter.manga).url.toString() + } catch (e: Exception) { + return + } + + parentController?.router?.pushController(MangaWebViewController(source.id, url) + .withFadeTransaction()) + } + + /** + * Called to run Intent with [Intent.ACTION_SEND], which show share dialog. + */ + private fun shareManga() { + val context = view?.context ?: return + + val source = presenter.source as? HttpSource ?: return + try { + val url = source.mangaDetailsRequest(presenter.manga).url.toString() + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + startActivity(Intent.createChooser(intent, context.getString(R.string.action_share))) + } catch (e: Exception) { + context.toast(e.message) + } + } + + /** + * Update FAB with correct drawable. + * + * @param isFavorite determines if manga is favorite or not. + */ + private fun setFavoriteDrawable(isFavorite: Boolean) { + // Set the Favorite drawable to the correct one. + // Border drawable if false, filled drawable if true. + fab_favorite?.setImageResource(if (isFavorite) + R.drawable.ic_bookmark_white_24dp + else + R.drawable.ic_add_to_library_24dp) + } + + /** + * Start fetching manga information from source. + */ + private fun fetchMangaFromSource() { + setRefreshing(true) + // Call presenter and start fetching manga information + presenter.fetchMangaFromSource() + } + + + /** + * Update swipe refresh to stop showing refresh in progress spinner. + */ + fun onFetchMangaDone() { + setRefreshing(false) + } + + /** + * Update swipe refresh to start showing refresh in progress spinner. + */ + fun onFetchMangaError(error: Throwable) { + setRefreshing(false) + activity?.toast(error.message) + } + + /** + * Set swipe refresh status. + * + * @param value whether it should be refreshing or not. + */ + private fun setRefreshing(value: Boolean) { + swipe_refresh?.isRefreshing = value + } + + /** + * Called when the fab is clicked. + */ + private fun onFabClick() { + val manga = presenter.manga + toggleFavorite() + if (manga.favorite) { + val categories = presenter.getCategories() + val defaultCategoryId = preferences.defaultCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId } + when { + defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory) + defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category + presenter.moveMangaToCategory(manga, null) + else -> { + val ids = presenter.getMangaCategoryIds(manga) + val preselected = ids.mapNotNull { id -> + categories.indexOfFirst { it.id == id }.takeIf { it != -1 } + }.toTypedArray() + + ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) + .showDialog(router) + } + } + activity?.toast(activity?.getString(R.string.manga_added_library)) + } else { + activity?.toast(activity?.getString(R.string.manga_removed_library)) + } + } + + /** + * Called when the fab is long clicked. + */ + private fun onFabLongClick() { + val manga = presenter.manga + if (!manga.favorite) { + toggleFavorite() + activity?.toast(activity?.getString(R.string.manga_added_library)) + } + val categories = presenter.getCategories() + if (categories.isEmpty()) { + // no categories exist, display a message about adding categories + activity?.toast(activity?.getString(R.string.action_add_category)) + } else { + val ids = presenter.getMangaCategoryIds(manga) + val preselected = ids.mapNotNull { id -> + categories.indexOfFirst { it.id == id }.takeIf { it != -1 } + }.toTypedArray() + + ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) + .showDialog(router) + } + } + + override fun updateCategoriesForMangas(mangas: List, categories: List) { + val manga = mangas.firstOrNull() ?: return + presenter.moveMangaToCategories(manga, categories) + } + + /** + * Add a shortcut of the manga to the home screen + */ + private fun addToHomeScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // TODO are transformations really unsupported or is it just the Pixel Launcher? + createShortcutForShape() + } else { + ChooseShapeDialog(this).showDialog(router) + } + } + + /** + * Dialog to choose a shape for the icon. + */ + private class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) { + + constructor(target: MangaInfoController) : this() { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val modes = intArrayOf(R.string.circular_icon, + R.string.rounded_icon, + R.string.square_icon, + R.string.star_icon) + + return MaterialDialog.Builder(activity!!) + .title(R.string.icon_shape) + .negativeText(android.R.string.cancel) + .items(modes.map { activity?.getString(it) }) + .itemsCallback { _, _, i, _ -> + (targetController as? MangaInfoController)?.createShortcutForShape(i) + } + .build() + } + } + + /** + * Retrieves the bitmap of the shortcut with the requested shape and calls [createShortcut] when + * the resource is available. + * + * @param i The shape index to apply. Defaults to circle crop transformation. + */ + private fun createShortcutForShape(i: Int = 0) { + if (activity == null) return + GlideApp.with(activity!!) + .asBitmap() + .load(presenter.manga) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .apply { + when (i) { + 0 -> circleCrop() + 1 -> transform(RoundedCorners(5)) + 2 -> transform(CropSquareTransformation()) + 3 -> centerCrop().transform(MaskTransformation(R.drawable.mask_star)) + } + } + .into(object : SimpleTarget(96, 96) { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + createShortcut(resource) + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + activity?.toast(R.string.icon_creation_fail) + } + }) + } + + /** + * Copies a string to clipboard + * + * @param label Label to show to the user describing the content + * @param content the actual text to copy to the board + */ + private fun copyToClipboard(label: String, content: String) { + if (content.isBlank()) return + + val activity = activity ?: return + val view = view ?: return + + val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.primaryClip = ClipData.newPlainText(label, content) + + activity.toast(view.context.getString(R.string.copied_to_clipboard, content.truncateCenter(20)), + Toast.LENGTH_SHORT) + } + + /** + * Perform a global search using the provided query. + * + * @param query the search query to pass to the search controller + */ + fun performGlobalSearch(query: String) { + val router = parentController?.router ?: return + router.pushController(CatalogueSearchController(query).withFadeTransaction()) + } + + /** + * Create shortcut using ShortcutManager. + * + * @param icon The image of the shortcut. + */ + private fun createShortcut(icon: Bitmap) { + val activity = activity ?: return + val mangaControllerArgs = parentController?.args ?: return + + // Create the shortcut intent. + val shortcutIntent = activity.intent + .setAction(MainActivity.SHORTCUT_MANGA) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra(MangaController.MANGA_EXTRA, + mangaControllerArgs.getLong(MangaController.MANGA_EXTRA)) + + // Check if shortcut placement is supported + if (ShortcutManagerCompat.isRequestPinShortcutSupported(activity)) { + val shortcutId = "manga-shortcut-${presenter.manga.title}-${presenter.source.name}" + + // Create shortcut info + val shortcutInfo = ShortcutInfoCompat.Builder(activity, shortcutId) + .setShortLabel(presenter.manga.title) + .setIcon(IconCompat.createWithBitmap(icon)) + .setIntent(shortcutIntent) + .build() + + val successCallback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Create the CallbackIntent. + val intent = ShortcutManagerCompat.createShortcutResultIntent(activity, shortcutInfo) + + // Configure the intent so that the broadcast receiver gets the callback successfully. + PendingIntent.getBroadcast(activity, 0, intent, 0) + } else { + NotificationReceiver.shortcutCreatedBroadcast(activity) + } + + // Request shortcut. + ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfo, + successCallback.intentSender) + } + } + +} 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 8b1b3731d..6bcad22e5 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 @@ -1,173 +1,173 @@ -package eu.kanade.tachiyomi.ui.manga.info - -import android.os.Bundle -import com.jakewharton.rxrelay.BehaviorRelay -import com.jakewharton.rxrelay.PublishRelay -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaCategory -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -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.Injekt -import uy.kohesive.injekt.api.get -import java.util.* - -/** - * Presenter of MangaInfoFragment. - * Contains information and data for fragment. - * Observable updates should be called from here. - */ -class MangaInfoPresenter( - val manga: Manga, - val source: Source, - private val chapterCountRelay: BehaviorRelay, - private val lastUpdateRelay: BehaviorRelay, - private val mangaFavoriteRelay: PublishRelay, - private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get(), - private val coverCache: CoverCache = Injekt.get() -) : BasePresenter() { - - /** - * Subscription to send the manga to the view. - */ - private var viewMangaSubscription: Subscription? = null - - /** - * Subscription to update the manga from the source. - */ - private var fetchMangaSubscription: Subscription? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - sendMangaToView() - - // Update chapter count - chapterCountRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(MangaInfoController::setChapterCount) - - // Update favorite status - mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribe { setFavorite(it) } - .apply { add(this) } - - //update last update date - lastUpdateRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(MangaInfoController::setLastUpdateDate) - } - - /** - * Sends the active manga to the view. - */ - fun sendMangaToView() { - viewMangaSubscription?.let { remove(it) } - viewMangaSubscription = Observable.just(manga) - .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) }) - } - - /** - * Fetch manga information from source. - */ - fun fetchMangaFromSource() { - if (!fetchMangaSubscription.isNullOrUnsubscribed()) return - fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) } - .map { networkManga -> - manga.copyFrom(networkManga) - manga.initialized = true - db.insertManga(manga).executeAsBlocking() - manga - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { sendMangaToView() } - .subscribeFirst({ view, _ -> - view.onFetchMangaDone() - }, MangaInfoController::onFetchMangaError) - } - - /** - * Update favorite status of manga, (removes / adds) manga (to / from) library. - * - * @return the new status of the manga. - */ - fun toggleFavorite(): Boolean { - manga.favorite = !manga.favorite - if (!manga.favorite) { - coverCache.deleteFromCache(manga.thumbnail_url) - } - db.insertManga(manga).executeAsBlocking() - sendMangaToView() - return manga.favorite - } - - private fun setFavorite(favorite: Boolean) { - if (manga.favorite == favorite) { - return - } - toggleFavorite() - } - - /** - * Returns true if the manga has any downloads. - */ - fun hasDownloads(): Boolean { - return downloadManager.getDownloadCount(manga) > 0 - } - - /** - * Deletes all the downloads for the manga. - */ - fun deleteDownloads() { - downloadManager.deleteManga(manga, source) - } - - /** - * Get user categories. - * - * @return List of categories, not including the default category - */ - fun getCategories(): List { - return db.getCategories().executeAsBlocking() - } - - /** - * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. - * - * @param manga the manga to get categories from. - * @return Array of category ids the manga is in, if none returns default id - */ - fun getMangaCategoryIds(manga: Manga): Array { - val categories = db.getCategoriesForManga(manga).executeAsBlocking() - return categories.mapNotNull { it.id }.toTypedArray() - } - - /** - * Move the given manga to categories. - * - * @param manga the manga to move. - * @param categories the selected categories. - */ - fun moveMangaToCategories(manga: Manga, categories: List) { - val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) } - db.setMangaCategories(mc, listOf(manga)) - } - - /** - * Move the given manga to the category. - * - * @param manga the manga to move. - * @param category the selected category, or null for default category. - */ - fun moveMangaToCategory(manga: Manga, category: Category?) { - moveMangaToCategories(manga, listOfNotNull(category)) - } - -} +package eu.kanade.tachiyomi.ui.manga.info + +import android.os.Bundle +import com.jakewharton.rxrelay.BehaviorRelay +import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +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.Injekt +import uy.kohesive.injekt.api.get +import java.util.* + +/** + * Presenter of MangaInfoFragment. + * Contains information and data for fragment. + * Observable updates should be called from here. + */ +class MangaInfoPresenter( + val manga: Manga, + val source: Source, + private val chapterCountRelay: BehaviorRelay, + private val lastUpdateRelay: BehaviorRelay, + private val mangaFavoriteRelay: PublishRelay, + private val db: DatabaseHelper = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val coverCache: CoverCache = Injekt.get() +) : BasePresenter() { + + /** + * Subscription to send the manga to the view. + */ + private var viewMangaSubscription: Subscription? = null + + /** + * Subscription to update the manga from the source. + */ + private var fetchMangaSubscription: Subscription? = null + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + sendMangaToView() + + // Update chapter count + chapterCountRelay.observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(MangaInfoController::setChapterCount) + + // Update favorite status + mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread()) + .subscribe { setFavorite(it) } + .apply { add(this) } + + //update last update date + lastUpdateRelay.observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(MangaInfoController::setLastUpdateDate) + } + + /** + * Sends the active manga to the view. + */ + fun sendMangaToView() { + viewMangaSubscription?.let { remove(it) } + viewMangaSubscription = Observable.just(manga) + .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) }) + } + + /** + * Fetch manga information from source. + */ + fun fetchMangaFromSource() { + if (!fetchMangaSubscription.isNullOrUnsubscribed()) return + fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) } + .map { networkManga -> + manga.copyFrom(networkManga) + manga.initialized = true + db.insertManga(manga).executeAsBlocking() + manga + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { sendMangaToView() } + .subscribeFirst({ view, _ -> + view.onFetchMangaDone() + }, MangaInfoController::onFetchMangaError) + } + + /** + * Update favorite status of manga, (removes / adds) manga (to / from) library. + * + * @return the new status of the manga. + */ + fun toggleFavorite(): Boolean { + manga.favorite = !manga.favorite + if (!manga.favorite) { + coverCache.deleteFromCache(manga.thumbnail_url) + } + db.insertManga(manga).executeAsBlocking() + sendMangaToView() + return manga.favorite + } + + private fun setFavorite(favorite: Boolean) { + if (manga.favorite == favorite) { + return + } + toggleFavorite() + } + + /** + * Returns true if the manga has any downloads. + */ + fun hasDownloads(): Boolean { + return downloadManager.getDownloadCount(manga) > 0 + } + + /** + * Deletes all the downloads for the manga. + */ + fun deleteDownloads() { + downloadManager.deleteManga(manga, source) + } + + /** + * Get user categories. + * + * @return List of categories, not including the default category + */ + fun getCategories(): List { + return db.getCategories().executeAsBlocking() + } + + /** + * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. + * + * @param manga the manga to get categories from. + * @return Array of category ids the manga is in, if none returns default id + */ + fun getMangaCategoryIds(manga: Manga): Array { + val categories = db.getCategoriesForManga(manga).executeAsBlocking() + return categories.mapNotNull { it.id }.toTypedArray() + } + + /** + * Move the given manga to categories. + * + * @param manga the manga to move. + * @param categories the selected categories. + */ + fun moveMangaToCategories(manga: Manga, categories: List) { + val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) } + db.setMangaCategories(mc, listOf(manga)) + } + + /** + * Move the given manga to the category. + * + * @param manga the manga to move. + * @param category the selected category, or null for default category. + */ + fun moveMangaToCategory(manga: Manga, category: Category?) { + moveMangaToCategories(manga, listOfNotNull(category)) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt index 249d96562..59279a2ef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt @@ -1,74 +1,74 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.widget.NumberPicker -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackChaptersDialog : DialogController - where T : Controller, T : SetTrackChaptersDialog.Listener { - - private val item: TrackItem - - constructor(target: T, item: TrackItem) : super(Bundle().apply { - putSerializable(KEY_ITEM_TRACK, item.track) - }) { - targetController = target - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track - val service = Injekt.get().getService(track.sync_id)!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val item = item - - val dialog = MaterialDialog.Builder(activity!!) - .title(R.string.chapters) - .customView(R.layout.track_chapters_dialog, false) - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .onPositive { dialog, _ -> - val view = dialog.customView - if (view != null) { - // Remove focus to update selected number - val np: NumberPicker = view.findViewById(R.id.chapters_picker) - np.clearFocus() - - (targetController as? Listener)?.setChaptersRead(item, np.value) - } - } - .build() - - val view = dialog.customView - if (view != null) { - val np: NumberPicker = view.findViewById(R.id.chapters_picker) - // Set initial value - np.value = item.track?.last_chapter_read ?: 0 - // Don't allow to go from 0 to 9999 - np.wrapSelectorWheel = false - } - - return dialog - } - - interface Listener { - fun setChaptersRead(item: TrackItem, chaptersRead: Int) - } - - private companion object { - const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track" - } - +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import android.widget.NumberPicker +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackChaptersDialog : DialogController + where T : Controller, T : SetTrackChaptersDialog.Listener { + + private val item: TrackItem + + constructor(target: T, item: TrackItem) : super(Bundle().apply { + putSerializable(KEY_ITEM_TRACK, item.track) + }) { + targetController = target + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + + val dialog = MaterialDialog.Builder(activity!!) + .title(R.string.chapters) + .customView(R.layout.track_chapters_dialog, false) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .onPositive { dialog, _ -> + val view = dialog.customView + if (view != null) { + // Remove focus to update selected number + val np: NumberPicker = view.findViewById(R.id.chapters_picker) + np.clearFocus() + + (targetController as? Listener)?.setChaptersRead(item, np.value) + } + } + .build() + + val view = dialog.customView + if (view != null) { + val np: NumberPicker = view.findViewById(R.id.chapters_picker) + // Set initial value + np.value = item.track?.last_chapter_read ?: 0 + // Don't allow to go from 0 to 9999 + np.wrapSelectorWheel = false + } + + return dialog + } + + interface Listener { + fun setChaptersRead(item: TrackItem, chaptersRead: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track" + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt index 44734f64b..382a29a11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt @@ -1,80 +1,80 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.widget.NumberPicker -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackScoreDialog : DialogController - where T : Controller, T : SetTrackScoreDialog.Listener { - - private val item: TrackItem - - constructor(target: T, item: TrackItem) : super(Bundle().apply { - putSerializable(KEY_ITEM_TRACK, item.track) - }) { - targetController = target - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track - val service = Injekt.get().getService(track.sync_id)!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val item = item - - val dialog = MaterialDialog.Builder(activity!!) - .title(R.string.score) - .customView(R.layout.track_score_dialog, false) - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .onPositive { dialog, _ -> - val view = dialog.customView - if (view != null) { - // Remove focus to update selected number - val np: NumberPicker = view.findViewById(R.id.score_picker) - np.clearFocus() - - (targetController as? Listener)?.setScore(item, np.value) - } - } - .show() - - val view = dialog.customView - if (view != null) { - val np: NumberPicker = view.findViewById(R.id.score_picker) - val scores = item.service.getScoreList().toTypedArray() - np.maxValue = scores.size - 1 - np.displayedValues = scores - - // Set initial value - val displayedScore = item.service.displayScore(item.track!!) - if (displayedScore != "-") { - val index = scores.indexOf(displayedScore) - np.value = if (index != -1) index else 0 - } - } - - return dialog - } - - interface Listener { - fun setScore(item: TrackItem, score: Int) - } - - private companion object { - const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track" - } - +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import android.widget.NumberPicker +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackScoreDialog : DialogController + where T : Controller, T : SetTrackScoreDialog.Listener { + + private val item: TrackItem + + constructor(target: T, item: TrackItem) : super(Bundle().apply { + putSerializable(KEY_ITEM_TRACK, item.track) + }) { + targetController = target + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + + val dialog = MaterialDialog.Builder(activity!!) + .title(R.string.score) + .customView(R.layout.track_score_dialog, false) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .onPositive { dialog, _ -> + val view = dialog.customView + if (view != null) { + // Remove focus to update selected number + val np: NumberPicker = view.findViewById(R.id.score_picker) + np.clearFocus() + + (targetController as? Listener)?.setScore(item, np.value) + } + } + .show() + + val view = dialog.customView + if (view != null) { + val np: NumberPicker = view.findViewById(R.id.score_picker) + val scores = item.service.getScoreList().toTypedArray() + np.maxValue = scores.size - 1 + np.displayedValues = scores + + // Set initial value + val displayedScore = item.service.displayScore(item.track!!) + if (displayedScore != "-") { + val index = scores.indexOf(displayedScore) + np.value = if (index != -1) index else 0 + } + } + + return dialog + } + + interface Listener { + fun setScore(item: TrackItem, score: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track" + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt index 6ad057951..ad2774c62 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt @@ -1,58 +1,58 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackStatusDialog : DialogController - where T : Controller, T : SetTrackStatusDialog.Listener { - - private val item: TrackItem - - constructor(target: T, item: TrackItem) : super(Bundle().apply { - putSerializable(KEY_ITEM_TRACK, item.track) - }) { - targetController = target - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track - val service = Injekt.get().getService(track.sync_id)!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val item = item - val statusList = item.service.getStatusList().orEmpty() - val statusString = statusList.mapNotNull { item.service.getStatus(it) } - val selectedIndex = statusList.indexOf(item.track?.status) - - return MaterialDialog.Builder(activity!!) - .title(R.string.status) - .negativeText(android.R.string.cancel) - .items(statusString) - .itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ -> - (targetController as? Listener)?.setStatus(item, i) - true - }) - .build() - } - - interface Listener { - fun setStatus(item: TrackItem, selection: Int) - } - - private companion object { - const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track" - } - +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackStatusDialog : DialogController + where T : Controller, T : SetTrackStatusDialog.Listener { + + private val item: TrackItem + + constructor(target: T, item: TrackItem) : super(Bundle().apply { + putSerializable(KEY_ITEM_TRACK, item.track) + }) { + targetController = target + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + val statusList = item.service.getStatusList().orEmpty() + val statusString = statusList.mapNotNull { item.service.getStatus(it) } + val selectedIndex = statusList.indexOf(item.track?.status) + + return MaterialDialog.Builder(activity!!) + .title(R.string.status) + .negativeText(android.R.string.cancel) + .items(statusString) + .itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ -> + (targetController as? Listener)?.setStatus(item, i) + true + }) + .build() + } + + interface Listener { + fun setStatus(item: TrackItem, selection: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track" + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt index d1b05cfc8..5a57a1b72 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt @@ -1,45 +1,45 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import androidx.recyclerview.widget.RecyclerView -import android.view.ViewGroup -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.inflate - -class TrackAdapter(controller: TrackController) : RecyclerView.Adapter() { - - var items = emptyList() - set(value) { - if (field !== value) { - field = value - notifyDataSetChanged() - } - } - - val rowClickListener: OnClickListener = controller - - fun getItem(index: Int): TrackItem? { - return items.getOrNull(index) - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder { - val view = parent.inflate(R.layout.track_item) - return TrackHolder(view, this) - } - - override fun onBindViewHolder(holder: TrackHolder, position: Int) { - holder.bind(items[position]) - } - - interface OnClickListener { - fun onLogoClick(position: Int) - fun onTitleClick(position: Int) - fun onStatusClick(position: Int) - fun onChaptersClick(position: Int) - fun onScoreClick(position: Int) - } - -} +package eu.kanade.tachiyomi.ui.manga.track + +import androidx.recyclerview.widget.RecyclerView +import android.view.ViewGroup +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.inflate + +class TrackAdapter(controller: TrackController) : RecyclerView.Adapter() { + + var items = emptyList() + set(value) { + if (field !== value) { + field = value + notifyDataSetChanged() + } + } + + val rowClickListener: OnClickListener = controller + + fun getItem(index: Int): TrackItem? { + return items.getOrNull(index) + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder { + val view = parent.inflate(R.layout.track_item) + return TrackHolder(view, this) + } + + override fun onBindViewHolder(holder: TrackHolder, position: Int) { + holder.bind(items[position]) + } + + interface OnClickListener { + fun onLogoClick(position: Int) + fun onTitleClick(position: Int) + fun onStatusClick(position: Int) + fun onChaptersClick(position: Int) + fun onScoreClick(position: Int) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt index 45fe58e8c..d42b8928f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt @@ -1,142 +1,142 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.content.Intent -import android.net.Uri -import androidx.recyclerview.widget.LinearLayoutManager -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.jakewharton.rxbinding.support.v4.widget.refreshes -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.track_controller.* -import timber.log.Timber - -class TrackController : NucleusController(), - TrackAdapter.OnClickListener, - SetTrackStatusDialog.Listener, - SetTrackChaptersDialog.Listener, - SetTrackScoreDialog.Listener { - - private var adapter: TrackAdapter? = null - - init { - // There's no menu, but this avoids a bug when coming from the catalogue, where the menu - // disappears if the searchview is expanded - setHasOptionsMenu(true) - } - - override fun createPresenter(): TrackPresenter { - return TrackPresenter((parentController as MangaController).manga!!) - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.track_controller, container, false) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - adapter = TrackAdapter(this) - with(view) { - track_recycler.layoutManager = LinearLayoutManager(context) - track_recycler.adapter = adapter - swipe_refresh.isEnabled = false - swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() } - } - } - - override fun onDestroyView(view: View) { - adapter = null - super.onDestroyView(view) - } - - fun onNextTrackings(trackings: List) { - val atLeastOneLink = trackings.any { it.track != null } - adapter?.items = trackings - swipe_refresh?.isEnabled = atLeastOneLink - (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink) - } - - fun onSearchResults(results: List) { - getSearchDialog()?.onSearchResults(results) - } - - @Suppress("UNUSED_PARAMETER") - fun onSearchResultsError(error: Throwable) { - Timber.e(error) - getSearchDialog()?.onSearchResultsError() - } - - private fun getSearchDialog(): TrackSearchDialog? { - return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog - } - - fun onRefreshDone() { - swipe_refresh?.isRefreshing = false - } - - fun onRefreshError(error: Throwable) { - swipe_refresh?.isRefreshing = false - activity?.toast(error.message) - } - - override fun onLogoClick(position: Int) { - val track = adapter?.getItem(position)?.track ?: return - - if (track.tracking_url.isNullOrBlank()) { - activity?.toast(R.string.url_not_set) - } else { - activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url))) - } - } - - override fun onTitleClick(position: Int) { - val item = adapter?.getItem(position) ?: return - TrackSearchDialog(this, item.service).showDialog(router, TAG_SEARCH_CONTROLLER) - } - - override fun onStatusClick(position: Int) { - val item = adapter?.getItem(position) ?: return - if (item.track == null) return - - SetTrackStatusDialog(this, item).showDialog(router) - } - - override fun onChaptersClick(position: Int) { - val item = adapter?.getItem(position) ?: return - if (item.track == null) return - - SetTrackChaptersDialog(this, item).showDialog(router) - } - - override fun onScoreClick(position: Int) { - val item = adapter?.getItem(position) ?: return - if (item.track == null) return - - SetTrackScoreDialog(this, item).showDialog(router) - } - - override fun setStatus(item: TrackItem, selection: Int) { - presenter.setStatus(item, selection) - swipe_refresh?.isRefreshing = true - } - - override fun setScore(item: TrackItem, score: Int) { - presenter.setScore(item, score) - swipe_refresh?.isRefreshing = true - } - - override fun setChaptersRead(item: TrackItem, chaptersRead: Int) { - presenter.setLastChapterRead(item, chaptersRead) - swipe_refresh?.isRefreshing = true - } - - private companion object { - const val TAG_SEARCH_CONTROLLER = "track_search_controller" - } - -} +package eu.kanade.tachiyomi.ui.manga.track + +import android.content.Intent +import android.net.Uri +import androidx.recyclerview.widget.LinearLayoutManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.jakewharton.rxbinding.support.v4.widget.refreshes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.util.toast +import kotlinx.android.synthetic.main.track_controller.* +import timber.log.Timber + +class TrackController : NucleusController(), + TrackAdapter.OnClickListener, + SetTrackStatusDialog.Listener, + SetTrackChaptersDialog.Listener, + SetTrackScoreDialog.Listener { + + private var adapter: TrackAdapter? = null + + init { + // There's no menu, but this avoids a bug when coming from the catalogue, where the menu + // disappears if the searchview is expanded + setHasOptionsMenu(true) + } + + override fun createPresenter(): TrackPresenter { + return TrackPresenter((parentController as MangaController).manga!!) + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.track_controller, container, false) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = TrackAdapter(this) + with(view) { + track_recycler.layoutManager = LinearLayoutManager(context) + track_recycler.adapter = adapter + swipe_refresh.isEnabled = false + swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() } + } + } + + override fun onDestroyView(view: View) { + adapter = null + super.onDestroyView(view) + } + + fun onNextTrackings(trackings: List) { + val atLeastOneLink = trackings.any { it.track != null } + adapter?.items = trackings + swipe_refresh?.isEnabled = atLeastOneLink + (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink) + } + + fun onSearchResults(results: List) { + getSearchDialog()?.onSearchResults(results) + } + + @Suppress("UNUSED_PARAMETER") + fun onSearchResultsError(error: Throwable) { + Timber.e(error) + getSearchDialog()?.onSearchResultsError() + } + + private fun getSearchDialog(): TrackSearchDialog? { + return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog + } + + fun onRefreshDone() { + swipe_refresh?.isRefreshing = false + } + + fun onRefreshError(error: Throwable) { + swipe_refresh?.isRefreshing = false + activity?.toast(error.message) + } + + override fun onLogoClick(position: Int) { + val track = adapter?.getItem(position)?.track ?: return + + if (track.tracking_url.isNullOrBlank()) { + activity?.toast(R.string.url_not_set) + } else { + activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url))) + } + } + + override fun onTitleClick(position: Int) { + val item = adapter?.getItem(position) ?: return + TrackSearchDialog(this, item.service).showDialog(router, TAG_SEARCH_CONTROLLER) + } + + override fun onStatusClick(position: Int) { + val item = adapter?.getItem(position) ?: return + if (item.track == null) return + + SetTrackStatusDialog(this, item).showDialog(router) + } + + override fun onChaptersClick(position: Int) { + val item = adapter?.getItem(position) ?: return + if (item.track == null) return + + SetTrackChaptersDialog(this, item).showDialog(router) + } + + override fun onScoreClick(position: Int) { + val item = adapter?.getItem(position) ?: return + if (item.track == null) return + + SetTrackScoreDialog(this, item).showDialog(router) + } + + override fun setStatus(item: TrackItem, selection: Int) { + presenter.setStatus(item, selection) + swipe_refresh?.isRefreshing = true + } + + override fun setScore(item: TrackItem, score: Int) { + presenter.setScore(item, score) + swipe_refresh?.isRefreshing = true + } + + override fun setChaptersRead(item: TrackItem, chaptersRead: Int) { + presenter.setLastChapterRead(item, chaptersRead) + swipe_refresh?.isRefreshing = true + } + + private companion object { + const val TAG_SEARCH_CONTROLLER = "track_search_controller" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt index 2f018f19d..4a62c430b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt @@ -1,42 +1,42 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.annotation.SuppressLint -import android.view.View -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder -import kotlinx.android.synthetic.main.track_item.* - -class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { - - init { - val listener = adapter.rowClickListener - logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) } - title_container.setOnClickListener { listener.onTitleClick(adapterPosition) } - status_container.setOnClickListener { listener.onStatusClick(adapterPosition) } - chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) } - score_container.setOnClickListener { listener.onScoreClick(adapterPosition) } - } - - @SuppressLint("SetTextI18n") - @Suppress("DEPRECATION") - fun bind(item: TrackItem) { - val track = item.track - track_logo.setImageResource(item.service.getLogo()) - logo_container.setBackgroundColor(item.service.getLogoColor()) - if (track != null) { - track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary) - track_title.setAllCaps(false) - track_title.text = track.title - track_chapters.text = "${track.last_chapter_read}/" + - if (track.total_chapters > 0) track.total_chapters else "-" - track_status.text = item.service.getStatus(track.status) - track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track) - } else { - track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Medium_Button) - track_title.setText(R.string.action_edit) - track_chapters.text = "" - track_score.text = "" - track_status.text = "" - } - } -} +package eu.kanade.tachiyomi.ui.manga.track + +import android.annotation.SuppressLint +import android.view.View +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder +import kotlinx.android.synthetic.main.track_item.* + +class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { + + init { + val listener = adapter.rowClickListener + logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) } + title_container.setOnClickListener { listener.onTitleClick(adapterPosition) } + status_container.setOnClickListener { listener.onStatusClick(adapterPosition) } + chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) } + score_container.setOnClickListener { listener.onScoreClick(adapterPosition) } + } + + @SuppressLint("SetTextI18n") + @Suppress("DEPRECATION") + fun bind(item: TrackItem) { + val track = item.track + track_logo.setImageResource(item.service.getLogo()) + logo_container.setBackgroundColor(item.service.getLogoColor()) + if (track != null) { + track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary) + track_title.setAllCaps(false) + track_title.text = track.title + track_chapters.text = "${track.last_chapter_read}/" + + if (track.total_chapters > 0) track.total_chapters else "-" + track_status.text = item.service.getStatus(track.status) + track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track) + } else { + track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Medium_Button) + track_title.setText(R.string.action_edit) + track_chapters.text = "" + track_score.text = "" + track_status.text = "" + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt index 6e7c3ebec..a751434d9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt @@ -1,6 +1,6 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackService - -data class TrackItem(val track: Track?, val service: TrackService) +package eu.kanade.tachiyomi.ui.manga.track + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService + +data class TrackItem(val track: Track?, val service: TrackService) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt index ac8592ed9..e33f92c3c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt @@ -1,130 +1,130 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.os.Bundle -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.toast -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - - -class TrackPresenter( - val manga: Manga, - preferences: PreferencesHelper = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - private val trackManager: TrackManager = Injekt.get() -) : BasePresenter() { - - private val context = preferences.context - - private var trackList: List = emptyList() - - private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } - - private var trackSubscription: Subscription? = null - - private var searchSubscription: Subscription? = null - - private var refreshSubscription: Subscription? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - fetchTrackings() - } - - fun fetchTrackings() { - trackSubscription?.let { remove(it) } - trackSubscription = db.getTracks(manga) - .asRxObservable() - .map { tracks -> - loggedServices.map { service -> - TrackItem(tracks.find { it.sync_id == service.id }, service) - } - } - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { trackList = it } - .subscribeLatestCache(TrackController::onNextTrackings) - } - - fun refresh() { - refreshSubscription?.let { remove(it) } - refreshSubscription = Observable.from(trackList) - .filter { it.track != null } - .concatMap { item -> - item.service.refresh(item.track!!) - .flatMap { db.insertTrack(it).asRxObservable() } - .map { item } - .onErrorReturn { item } - } - .toList() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> view.onRefreshDone() }, - TrackController::onRefreshError) - } - - fun search(query: String, service: TrackService) { - searchSubscription?.let { remove(it) } - searchSubscription = service.search(query) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(TrackController::onSearchResults, - TrackController::onSearchResultsError) - } - - fun registerTracking(item: Track?, service: TrackService) { - if (item != null) { - item.manga_id = manga.id!! - add(service.bind(item) - .flatMap { db.insertTrack(item).asRxObservable() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ }, - { error -> context.toast(error.message) })) - } else { - db.deleteTrackForManga(manga, service).executeAsBlocking() - } - } - - private fun updateRemote(track: Track, service: TrackService) { - service.update(track) - .flatMap { db.insertTrack(track).asRxObservable() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> view.onRefreshDone() }, - { view, error -> - view.onRefreshError(error) - - // Restart on error to set old values - fetchTrackings() - }) - } - - fun setStatus(item: TrackItem, index: Int) { - val track = item.track!! - track.status = item.service.getStatusList()[index] - updateRemote(track, item.service) - } - - fun setScore(item: TrackItem, index: Int) { - val track = item.track!! - track.score = item.service.indexToScore(index) - updateRemote(track, item.service) - } - - fun setLastChapterRead(item: TrackItem, chapterNumber: Int) { - val track = item.track!! - track.last_chapter_read = chapterNumber - updateRemote(track, item.service) - } - +package eu.kanade.tachiyomi.ui.manga.track + +import android.os.Bundle +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.toast +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + + +class TrackPresenter( + val manga: Manga, + preferences: PreferencesHelper = Injekt.get(), + private val db: DatabaseHelper = Injekt.get(), + private val trackManager: TrackManager = Injekt.get() +) : BasePresenter() { + + private val context = preferences.context + + private var trackList: List = emptyList() + + private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } + + private var trackSubscription: Subscription? = null + + private var searchSubscription: Subscription? = null + + private var refreshSubscription: Subscription? = null + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + fetchTrackings() + } + + fun fetchTrackings() { + trackSubscription?.let { remove(it) } + trackSubscription = db.getTracks(manga) + .asRxObservable() + .map { tracks -> + loggedServices.map { service -> + TrackItem(tracks.find { it.sync_id == service.id }, service) + } + } + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { trackList = it } + .subscribeLatestCache(TrackController::onNextTrackings) + } + + fun refresh() { + refreshSubscription?.let { remove(it) } + refreshSubscription = Observable.from(trackList) + .filter { it.track != null } + .concatMap { item -> + item.service.refresh(item.track!!) + .flatMap { db.insertTrack(it).asRxObservable() } + .map { item } + .onErrorReturn { item } + } + .toList() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst({ view, _ -> view.onRefreshDone() }, + TrackController::onRefreshError) + } + + fun search(query: String, service: TrackService) { + searchSubscription?.let { remove(it) } + searchSubscription = service.search(query) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(TrackController::onSearchResults, + TrackController::onSearchResultsError) + } + + fun registerTracking(item: Track?, service: TrackService) { + if (item != null) { + item.manga_id = manga.id!! + add(service.bind(item) + .flatMap { db.insertTrack(item).asRxObservable() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ }, + { error -> context.toast(error.message) })) + } else { + db.deleteTrackForManga(manga, service).executeAsBlocking() + } + } + + private fun updateRemote(track: Track, service: TrackService) { + service.update(track) + .flatMap { db.insertTrack(track).asRxObservable() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst({ view, _ -> view.onRefreshDone() }, + { view, error -> + view.onRefreshError(error) + + // Restart on error to set old values + fetchTrackings() + }) + } + + fun setStatus(item: TrackItem, index: Int) { + val track = item.track!! + track.status = item.service.getStatusList()[index] + updateRemote(track, item.service) + } + + fun setScore(item: TrackItem, index: Int) { + val track = item.track!! + track.score = item.service.indexToScore(index) + updateRemote(track, item.service) + } + + fun setLastChapterRead(item: TrackItem, chapterNumber: Int) { + val track = item.track!! + track.last_chapter_read = chapterNumber + updateRemote(track, item.service) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt index c9b3f3265..930651270 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt @@ -1,79 +1,79 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.inflate -import kotlinx.android.synthetic.main.track_search_item.view.* -import java.util.* - -class TrackSearchAdapter(context: Context) - : ArrayAdapter(context, R.layout.track_search_item, ArrayList()) { - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - var v = view - // Get the data item for this position - val track = getItem(position) - // Check if an existing view is being reused, otherwise inflate the view - val holder: TrackSearchHolder // view lookup cache stored in tag - if (v == null) { - v = parent.inflate(R.layout.track_search_item) - holder = TrackSearchHolder(v) - v.tag = holder - } else { - holder = v.tag as TrackSearchHolder - } - holder.onSetValues(track) - return v - } - - fun setItems(syncs: List) { - setNotifyOnChange(false) - clear() - addAll(syncs) - notifyDataSetChanged() - } - - class TrackSearchHolder(private val view: View) { - - fun onSetValues(track: TrackSearch) { - view.track_search_title.text = track.title - view.track_search_summary.text = track.summary - GlideApp.with(view.context).clear(view.track_search_cover) - if (!track.cover_url.isNullOrEmpty()) { - GlideApp.with(view.context) - .load(track.cover_url) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .into(view.track_search_cover) - } - - if (track.publishing_status.isNullOrBlank()) { - view.track_search_status.gone() - view.track_search_status_result.gone() - } else { - view.track_search_status_result.text = track.publishing_status.capitalize() - } - - if (track.publishing_type.isNullOrBlank()) { - view.track_search_type.gone() - view.track_search_type_result.gone() - } else { - view.track_search_type_result.text = track.publishing_type.capitalize() - } - - if (track.start_date.isNullOrBlank()) { - view.track_search_start.gone() - view.track_search_start_result.gone() - } else { - view.track_search_start_result.text = track.start_date - } - } - } +package eu.kanade.tachiyomi.ui.manga.track + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.util.gone +import eu.kanade.tachiyomi.util.inflate +import kotlinx.android.synthetic.main.track_search_item.view.* +import java.util.* + +class TrackSearchAdapter(context: Context) + : ArrayAdapter(context, R.layout.track_search_item, ArrayList()) { + + override fun getView(position: Int, view: View?, parent: ViewGroup): View { + var v = view + // Get the data item for this position + val track = getItem(position) + // Check if an existing view is being reused, otherwise inflate the view + val holder: TrackSearchHolder // view lookup cache stored in tag + if (v == null) { + v = parent.inflate(R.layout.track_search_item) + holder = TrackSearchHolder(v) + v.tag = holder + } else { + holder = v.tag as TrackSearchHolder + } + holder.onSetValues(track) + return v + } + + fun setItems(syncs: List) { + setNotifyOnChange(false) + clear() + addAll(syncs) + notifyDataSetChanged() + } + + class TrackSearchHolder(private val view: View) { + + fun onSetValues(track: TrackSearch) { + view.track_search_title.text = track.title + view.track_search_summary.text = track.summary + GlideApp.with(view.context).clear(view.track_search_cover) + if (!track.cover_url.isNullOrEmpty()) { + GlideApp.with(view.context) + .load(track.cover_url) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(view.track_search_cover) + } + + if (track.publishing_status.isNullOrBlank()) { + view.track_search_status.gone() + view.track_search_status_result.gone() + } else { + view.track_search_status_result.text = track.publishing_status.capitalize() + } + + if (track.publishing_type.isNullOrBlank()) { + view.track_search_type.gone() + view.track_search_type_result.gone() + } else { + view.track_search_type_result.text = track.publishing_type.capitalize() + } + + if (track.start_date.isNullOrBlank()) { + view.track_search_start.gone() + view.track_search_start_result.gone() + } else { + view.track_search_start_result.text = track.start_date + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt index 9856ce2e5..215ef00b9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt @@ -1,144 +1,144 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.view.View -import com.afollestad.materialdialogs.MaterialDialog -import com.jakewharton.rxbinding.widget.itemClicks -import com.jakewharton.rxbinding.widget.textChanges -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.plusAssign -import kotlinx.android.synthetic.main.track_search_dialog.view.* -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.subscriptions.CompositeSubscription -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit - -class TrackSearchDialog : DialogController { - - private var dialogView: View? = null - - private var adapter: TrackSearchAdapter? = null - - private var selectedItem: Track? = null - - private val service: TrackService - - private var subscriptions = CompositeSubscription() - - private var searchTextSubscription: Subscription? = null - - private val trackController - get() = targetController as TrackController - - constructor(target: TrackController, service: TrackService) : super(Bundle().apply { - putInt(KEY_SERVICE, service.id) - }) { - targetController = target - this.service = service - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - service = Injekt.get().getService(bundle.getInt(KEY_SERVICE))!! - } - - override fun onCreateDialog(savedState: Bundle?): Dialog { - val dialog = MaterialDialog.Builder(activity!!) - .customView(R.layout.track_search_dialog, false) - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .onPositive { _, _ -> onPositiveButtonClick() } - .build() - - if (subscriptions.isUnsubscribed) { - subscriptions = CompositeSubscription() - } - - dialogView = dialog.view - onViewCreated(dialog.view, savedState) - - return dialog - } - - fun onViewCreated(view: View, savedState: Bundle?) { - // Create adapter - val adapter = TrackSearchAdapter(view.context) - this.adapter = adapter - view.track_search_list.adapter = adapter - - // Set listeners - selectedItem = null - - subscriptions += view.track_search_list.itemClicks().subscribe { position -> - selectedItem = adapter.getItem(position) - } - - // Do an initial search based on the manga's title - if (savedState == null) { - val title = trackController.presenter.manga.title - view.track_search.append(title) - search(title) - } - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - subscriptions.unsubscribe() - dialogView = null - adapter = null - } - - override fun onAttach(view: View) { - super.onAttach(view) - searchTextSubscription = dialogView!!.track_search.textChanges() - .skip(1) - .debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) - .map { it.toString() } - .filter(String::isNotBlank) - .subscribe { search(it) } - } - - override fun onDetach(view: View) { - super.onDetach(view) - searchTextSubscription?.unsubscribe() - } - - private fun search(query: String) { - val view = dialogView ?: return - view.progress.visibility = View.VISIBLE - view.track_search_list.visibility = View.INVISIBLE - trackController.presenter.search(query, service) - } - - fun onSearchResults(results: List) { - selectedItem = null - val view = dialogView ?: return - view.progress.visibility = View.INVISIBLE - view.track_search_list.visibility = View.VISIBLE - adapter?.setItems(results) - } - - fun onSearchResultsError() { - val view = dialogView ?: return - view.progress.visibility = View.VISIBLE - view.track_search_list.visibility = View.INVISIBLE - adapter?.setItems(emptyList()) - } - - private fun onPositiveButtonClick() { - trackController.presenter.registerTracking(selectedItem, service) - } - - private companion object { - const val KEY_SERVICE = "service_id" - } - -} +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import android.view.View +import com.afollestad.materialdialogs.MaterialDialog +import com.jakewharton.rxbinding.widget.itemClicks +import com.jakewharton.rxbinding.widget.textChanges +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.util.plusAssign +import kotlinx.android.synthetic.main.track_search_dialog.view.* +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.subscriptions.CompositeSubscription +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.TimeUnit + +class TrackSearchDialog : DialogController { + + private var dialogView: View? = null + + private var adapter: TrackSearchAdapter? = null + + private var selectedItem: Track? = null + + private val service: TrackService + + private var subscriptions = CompositeSubscription() + + private var searchTextSubscription: Subscription? = null + + private val trackController + get() = targetController as TrackController + + constructor(target: TrackController, service: TrackService) : super(Bundle().apply { + putInt(KEY_SERVICE, service.id) + }) { + targetController = target + this.service = service + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + service = Injekt.get().getService(bundle.getInt(KEY_SERVICE))!! + } + + override fun onCreateDialog(savedState: Bundle?): Dialog { + val dialog = MaterialDialog.Builder(activity!!) + .customView(R.layout.track_search_dialog, false) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .onPositive { _, _ -> onPositiveButtonClick() } + .build() + + if (subscriptions.isUnsubscribed) { + subscriptions = CompositeSubscription() + } + + dialogView = dialog.view + onViewCreated(dialog.view, savedState) + + return dialog + } + + fun onViewCreated(view: View, savedState: Bundle?) { + // Create adapter + val adapter = TrackSearchAdapter(view.context) + this.adapter = adapter + view.track_search_list.adapter = adapter + + // Set listeners + selectedItem = null + + subscriptions += view.track_search_list.itemClicks().subscribe { position -> + selectedItem = adapter.getItem(position) + } + + // Do an initial search based on the manga's title + if (savedState == null) { + val title = trackController.presenter.manga.title + view.track_search.append(title) + search(title) + } + } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + subscriptions.unsubscribe() + dialogView = null + adapter = null + } + + override fun onAttach(view: View) { + super.onAttach(view) + searchTextSubscription = dialogView!!.track_search.textChanges() + .skip(1) + .debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) + .map { it.toString() } + .filter(String::isNotBlank) + .subscribe { search(it) } + } + + override fun onDetach(view: View) { + super.onDetach(view) + searchTextSubscription?.unsubscribe() + } + + private fun search(query: String) { + val view = dialogView ?: return + view.progress.visibility = View.VISIBLE + view.track_search_list.visibility = View.INVISIBLE + trackController.presenter.search(query, service) + } + + fun onSearchResults(results: List) { + selectedItem = null + val view = dialogView ?: return + view.progress.visibility = View.INVISIBLE + view.track_search_list.visibility = View.VISIBLE + adapter?.setItems(results) + } + + fun onSearchResultsError() { + val view = dialogView ?: return + view.progress.visibility = View.VISIBLE + view.track_search_list.visibility = View.INVISIBLE + adapter?.setItems(emptyList()) + } + + private fun onPositiveButtonClick() { + trackController.presenter.registerTracking(selectedItem, service) + } + + private companion object { + const val KEY_SERVICE = "service_id" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt index 51f6c365b..26b166af5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt @@ -1,333 +1,333 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ActionMode -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import android.view.* -import com.jakewharton.rxbinding.support.v4.widget.refreshes -import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.SelectableAdapter -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.controller.NoToolbarElevationController -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.recent_chapters_controller.* -import timber.log.Timber - -/** - * Fragment that shows recent chapters. - * Uses [R.layout.recent_chapters_controller]. - * UI related actions should be called from here. - */ -class RecentChaptersController : NucleusController(), - NoToolbarElevationController, - ActionMode.Callback, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - FlexibleAdapter.OnUpdateListener, - ConfirmDeleteChaptersDialog.Listener, - RecentChaptersAdapter.OnCoverClickListener { - - /** - * Action mode for multiple selection. - */ - private var actionMode: ActionMode? = null - - /** - * Adapter containing the recent chapters. - */ - var adapter: RecentChaptersAdapter? = null - private set - - override fun getTitle(): String? { - return resources?.getString(R.string.label_recent_updates) - } - - override fun createPresenter(): RecentChaptersPresenter { - return RecentChaptersPresenter() - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.recent_chapters_controller, container, false) - } - - /** - * Called when view is created - * @param view created view - */ - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - // Init RecyclerView and adapter - val layoutManager = LinearLayoutManager(view.context) - recycler.layoutManager = layoutManager - recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) - recycler.setHasFixedSize(true) - adapter = RecentChaptersAdapter(this@RecentChaptersController) - recycler.adapter = adapter - - recycler.scrollStateChanges().subscribeUntilDestroy { - // Disable swipe refresh when view is not at the top - val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition() - swipe_refresh.isEnabled = firstPos <= 0 - } - - swipe_refresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt()) - swipe_refresh.refreshes().subscribeUntilDestroy { - if (!LibraryUpdateService.isRunning(view.context)) { - LibraryUpdateService.start(view.context) - view.context.toast(R.string.action_update_library) - } - // It can be a very long operation, so we disable swipe refresh and show a toast. - swipe_refresh.isRefreshing = false - } - } - - override fun onDestroyView(view: View) { - adapter = null - actionMode = null - super.onDestroyView(view) - } - - /** - * Returns selected chapters - * @return list of selected chapters - */ - fun getSelectedChapters(): List { - val adapter = adapter ?: return emptyList() - return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem } - } - - /** - * Called when item in list is clicked - * @param position position of clicked item - */ - override fun onItemClick(view: View, position: Int): Boolean { - val adapter = adapter ?: return false - - // Get item from position - val item = adapter.getItem(position) as? RecentChapterItem ?: return false - if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { - toggleSelection(position) - return true - } else { - openChapter(item) - return false - } - } - - /** - * Called when item in list is long clicked - * @param position position of clicked item - */ - override fun onItemLongClick(position: Int) { - if (actionMode == null) - actionMode = (activity as AppCompatActivity).startSupportActionMode(this) - - toggleSelection(position) - } - - /** - * Called to toggle selection - * @param position position of selected item - */ - private fun toggleSelection(position: Int) { - val adapter = adapter ?: return - adapter.toggleSelection(position) - actionMode?.invalidate() - } - - /** - * Open chapter in reader - * @param chapter selected chapter - */ - private fun openChapter(item: RecentChapterItem) { - val activity = activity ?: return - val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter) - startActivity(intent) - } - - /** - * Download selected items - * @param chapters list of selected [RecentChapter]s - */ - fun downloadChapters(chapters: List) { - destroyActionModeIfNeeded() - presenter.downloadChapters(chapters) - } - - /** - * Populate adapter with chapters - * @param chapters list of [Any] - */ - fun onNextRecentChapters(chapters: List>) { - destroyActionModeIfNeeded() - adapter?.updateDataSet(chapters) - } - - override fun onUpdateEmptyView(size: Int) { - if (size > 0) { - empty_view?.hide() - } else { - empty_view?.show(R.drawable.ic_update_black_128dp, R.string.information_no_recent) - } - } - - /** - * Update download status of chapter - * @param download [Download] object containing download progress. - */ - fun onChapterStatusChange(download: Download) { - getHolder(download)?.notifyStatus(download.status) - } - - /** - * Returns holder belonging to chapter - * @param download [Download] object containing download progress. - */ - 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) { - presenter.markChapterRead(chapters, true) - if (presenter.preferences.removeAfterMarkedAsRead()) { - deleteChapters(chapters) - } - } - - override fun deleteChapters(chaptersToDelete: List) { - destroyActionModeIfNeeded() - DeletingChaptersDialog().showDialog(router) - presenter.deleteChapters(chaptersToDelete) - } - - /** - * Destory [ActionMode] if it's shown - */ - fun destroyActionModeIfNeeded() { - actionMode?.finish() - } - - /** - * Mark chapter as unread - * @param chapters list of selected [RecentChapter] - */ - fun markAsUnread(chapters: List) { - presenter.markChapterRead(chapters, false) - } - - /** - * Start downloading chapter - * @param chapter selected chapter with manga - */ - fun downloadChapter(chapter: RecentChapterItem) { - presenter.downloadChapters(listOf(chapter)) - } - - /** - * Start deleting chapter - * @param chapter selected chapter with manga - */ - fun deleteChapter(chapter: RecentChapterItem) { - DeletingChaptersDialog().showDialog(router) - presenter.deleteChapters(listOf(chapter)) - } - - override fun onCoverClick(position: Int) { - val chapterClicked = adapter?.getItem(position) as? RecentChapterItem ?: return - openManga(chapterClicked) - - } - - fun openManga(chapter: RecentChapterItem) { - router.pushController(MangaController(chapter.manga).withFadeTransaction()) - } - - /** - * Called when chapters are deleted - */ - fun onChaptersDeleted() { - dismissDeletingDialog() - adapter?.notifyDataSetChanged() - } - - /** - * Called when error while deleting - * @param error error message - */ - fun onChaptersDeletedError(error: Throwable) { - dismissDeletingDialog() - Timber.e(error) - } - - /** - * Called to dismiss deleting dialog - */ - fun dismissDeletingDialog() { - router.popControllerWithTag(DeletingChaptersDialog.TAG) - } - - /** - * Called when ActionMode created. - * @param mode the ActionMode object - * @param menu menu object of ActionMode - */ - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu) - adapter?.mode = SelectableAdapter.Mode.MULTI - return true - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val count = adapter?.selectedItemCount ?: 0 - if (count == 0) { - // Destroy action mode if there are no items selected. - destroyActionModeIfNeeded() - } else { - mode.title = resources?.getString(R.string.label_selected, count) - } - return false - } - - /** - * Called when ActionMode item clicked - * @param mode the ActionMode object - * @param item item from ActionMode. - */ - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) - R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) - R.id.action_download -> downloadChapters(getSelectedChapters()) - R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters()) - .showDialog(router) - else -> return false - } - return true - } - - /** - * Called when ActionMode destroyed - * @param mode the ActionMode object - */ - override fun onDestroyActionMode(mode: ActionMode?) { - adapter?.mode = SelectableAdapter.Mode.IDLE - adapter?.clearSelection() - actionMode = null - } - -} +package eu.kanade.tachiyomi.ui.recent_updates + +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import android.view.* +import com.jakewharton.rxbinding.support.v4.widget.refreshes +import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.SelectableAdapter +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.controller.NoToolbarElevationController +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.util.toast +import kotlinx.android.synthetic.main.recent_chapters_controller.* +import timber.log.Timber + +/** + * Fragment that shows recent chapters. + * Uses [R.layout.recent_chapters_controller]. + * UI related actions should be called from here. + */ +class RecentChaptersController : NucleusController(), + NoToolbarElevationController, + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + FlexibleAdapter.OnUpdateListener, + ConfirmDeleteChaptersDialog.Listener, + RecentChaptersAdapter.OnCoverClickListener { + + /** + * Action mode for multiple selection. + */ + private var actionMode: ActionMode? = null + + /** + * Adapter containing the recent chapters. + */ + var adapter: RecentChaptersAdapter? = null + private set + + override fun getTitle(): String? { + return resources?.getString(R.string.label_recent_updates) + } + + override fun createPresenter(): RecentChaptersPresenter { + return RecentChaptersPresenter() + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.recent_chapters_controller, container, false) + } + + /** + * Called when view is created + * @param view created view + */ + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + // Init RecyclerView and adapter + val layoutManager = LinearLayoutManager(view.context) + recycler.layoutManager = layoutManager + recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + recycler.setHasFixedSize(true) + adapter = RecentChaptersAdapter(this@RecentChaptersController) + recycler.adapter = adapter + + recycler.scrollStateChanges().subscribeUntilDestroy { + // Disable swipe refresh when view is not at the top + val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition() + swipe_refresh.isEnabled = firstPos <= 0 + } + + swipe_refresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt()) + swipe_refresh.refreshes().subscribeUntilDestroy { + if (!LibraryUpdateService.isRunning(view.context)) { + LibraryUpdateService.start(view.context) + view.context.toast(R.string.action_update_library) + } + // It can be a very long operation, so we disable swipe refresh and show a toast. + swipe_refresh.isRefreshing = false + } + } + + override fun onDestroyView(view: View) { + adapter = null + actionMode = null + super.onDestroyView(view) + } + + /** + * Returns selected chapters + * @return list of selected chapters + */ + fun getSelectedChapters(): List { + val adapter = adapter ?: return emptyList() + return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem } + } + + /** + * Called when item in list is clicked + * @param position position of clicked item + */ + override fun onItemClick(view: View, position: Int): Boolean { + val adapter = adapter ?: return false + + // Get item from position + val item = adapter.getItem(position) as? RecentChapterItem ?: return false + if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { + toggleSelection(position) + return true + } else { + openChapter(item) + return false + } + } + + /** + * Called when item in list is long clicked + * @param position position of clicked item + */ + override fun onItemLongClick(position: Int) { + if (actionMode == null) + actionMode = (activity as AppCompatActivity).startSupportActionMode(this) + + toggleSelection(position) + } + + /** + * Called to toggle selection + * @param position position of selected item + */ + private fun toggleSelection(position: Int) { + val adapter = adapter ?: return + adapter.toggleSelection(position) + actionMode?.invalidate() + } + + /** + * Open chapter in reader + * @param chapter selected chapter + */ + private fun openChapter(item: RecentChapterItem) { + val activity = activity ?: return + val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter) + startActivity(intent) + } + + /** + * Download selected items + * @param chapters list of selected [RecentChapter]s + */ + fun downloadChapters(chapters: List) { + destroyActionModeIfNeeded() + presenter.downloadChapters(chapters) + } + + /** + * Populate adapter with chapters + * @param chapters list of [Any] + */ + fun onNextRecentChapters(chapters: List>) { + destroyActionModeIfNeeded() + adapter?.updateDataSet(chapters) + } + + override fun onUpdateEmptyView(size: Int) { + if (size > 0) { + empty_view?.hide() + } else { + empty_view?.show(R.drawable.ic_update_black_128dp, R.string.information_no_recent) + } + } + + /** + * Update download status of chapter + * @param download [Download] object containing download progress. + */ + fun onChapterStatusChange(download: Download) { + getHolder(download)?.notifyStatus(download.status) + } + + /** + * Returns holder belonging to chapter + * @param download [Download] object containing download progress. + */ + 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) { + presenter.markChapterRead(chapters, true) + if (presenter.preferences.removeAfterMarkedAsRead()) { + deleteChapters(chapters) + } + } + + override fun deleteChapters(chaptersToDelete: List) { + destroyActionModeIfNeeded() + DeletingChaptersDialog().showDialog(router) + presenter.deleteChapters(chaptersToDelete) + } + + /** + * Destory [ActionMode] if it's shown + */ + fun destroyActionModeIfNeeded() { + actionMode?.finish() + } + + /** + * Mark chapter as unread + * @param chapters list of selected [RecentChapter] + */ + fun markAsUnread(chapters: List) { + presenter.markChapterRead(chapters, false) + } + + /** + * Start downloading chapter + * @param chapter selected chapter with manga + */ + fun downloadChapter(chapter: RecentChapterItem) { + presenter.downloadChapters(listOf(chapter)) + } + + /** + * Start deleting chapter + * @param chapter selected chapter with manga + */ + fun deleteChapter(chapter: RecentChapterItem) { + DeletingChaptersDialog().showDialog(router) + presenter.deleteChapters(listOf(chapter)) + } + + override fun onCoverClick(position: Int) { + val chapterClicked = adapter?.getItem(position) as? RecentChapterItem ?: return + openManga(chapterClicked) + + } + + fun openManga(chapter: RecentChapterItem) { + router.pushController(MangaController(chapter.manga).withFadeTransaction()) + } + + /** + * Called when chapters are deleted + */ + fun onChaptersDeleted() { + dismissDeletingDialog() + adapter?.notifyDataSetChanged() + } + + /** + * Called when error while deleting + * @param error error message + */ + fun onChaptersDeletedError(error: Throwable) { + dismissDeletingDialog() + Timber.e(error) + } + + /** + * Called to dismiss deleting dialog + */ + fun dismissDeletingDialog() { + router.popControllerWithTag(DeletingChaptersDialog.TAG) + } + + /** + * Called when ActionMode created. + * @param mode the ActionMode object + * @param menu menu object of ActionMode + */ + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu) + adapter?.mode = SelectableAdapter.Mode.MULTI + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val count = adapter?.selectedItemCount ?: 0 + if (count == 0) { + // Destroy action mode if there are no items selected. + destroyActionModeIfNeeded() + } else { + mode.title = resources?.getString(R.string.label_selected, count) + } + return false + } + + /** + * Called when ActionMode item clicked + * @param mode the ActionMode object + * @param item item from ActionMode. + */ + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) + R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) + R.id.action_download -> downloadChapters(getSelectedChapters()) + R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters()) + .showDialog(router) + else -> return false + } + return true + } + + /** + * Called when ActionMode destroyed + * @param mode the ActionMode object + */ + override fun onDestroyActionMode(mode: ActionMode?) { + adapter?.mode = SelectableAdapter.Mode.IDLE + adapter?.clearSelection() + actionMode = null + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt index 45f97eebb..6725ebb8f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt @@ -1,87 +1,87 @@ -package eu.kanade.tachiyomi.ui.setting - -import android.content.Context -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.preference.PreferenceController -import androidx.preference.PreferenceScreen -import android.util.TypedValue -import android.view.ContextThemeWrapper -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.bluelinelabs.conductor.ControllerChangeHandler -import com.bluelinelabs.conductor.ControllerChangeType -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.ui.base.controller.BaseController -import rx.Observable -import rx.Subscription -import rx.subscriptions.CompositeSubscription -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -abstract class SettingsController : PreferenceController() { - - val preferences: PreferencesHelper = Injekt.get() - - var untilDestroySubscriptions = CompositeSubscription() - private set - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View { - if (untilDestroySubscriptions.isUnsubscribed) { - untilDestroySubscriptions = CompositeSubscription() - } - return super.onCreateView(inflater, container, savedInstanceState) - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - untilDestroySubscriptions.unsubscribe() - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - val screen = preferenceManager.createPreferenceScreen(getThemedContext()) - preferenceScreen = screen - setupPreferenceScreen(screen) - } - - abstract fun setupPreferenceScreen(screen: PreferenceScreen): Any? - - private fun getThemedContext(): Context { - val tv = TypedValue() - activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true) - return ContextThemeWrapper(activity, tv.resourceId) - } - - open fun getTitle(): String? { - return preferenceScreen?.title?.toString() - } - - fun setTitle() { - var parentController = parentController - while (parentController != null) { - if (parentController is BaseController && parentController.getTitle() != null) { - return - } - parentController = parentController.parentController - } - - (activity as? AppCompatActivity)?.supportActionBar?.title = getTitle() - } - - override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { - if (type.isEnter) { - setTitle() - } - super.onChangeStarted(handler, type) - } - - fun Observable.subscribeUntilDestroy(): Subscription { - return subscribe().also { untilDestroySubscriptions.add(it) } - } - - fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { - return subscribe(onNext).also { untilDestroySubscriptions.add(it) } - } -} +package eu.kanade.tachiyomi.ui.setting + +import android.content.Context +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.PreferenceController +import androidx.preference.PreferenceScreen +import android.util.TypedValue +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.base.controller.BaseController +import rx.Observable +import rx.Subscription +import rx.subscriptions.CompositeSubscription +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +abstract class SettingsController : PreferenceController() { + + val preferences: PreferencesHelper = Injekt.get() + + var untilDestroySubscriptions = CompositeSubscription() + private set + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View { + if (untilDestroySubscriptions.isUnsubscribed) { + untilDestroySubscriptions = CompositeSubscription() + } + return super.onCreateView(inflater, container, savedInstanceState) + } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + untilDestroySubscriptions.unsubscribe() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val screen = preferenceManager.createPreferenceScreen(getThemedContext()) + preferenceScreen = screen + setupPreferenceScreen(screen) + } + + abstract fun setupPreferenceScreen(screen: PreferenceScreen): Any? + + private fun getThemedContext(): Context { + val tv = TypedValue() + activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true) + return ContextThemeWrapper(activity, tv.resourceId) + } + + open fun getTitle(): String? { + return preferenceScreen?.title?.toString() + } + + fun setTitle() { + var parentController = parentController + while (parentController != null) { + if (parentController is BaseController && parentController.getTitle() != null) { + return + } + parentController = parentController.parentController + } + + (activity as? AppCompatActivity)?.supportActionBar?.title = getTitle() + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + if (type.isEnter) { + setTitle() + } + super.onChangeStarted(handler, type) + } + + fun Observable.subscribeUntilDestroy(): Subscription { + return subscribe().also { untilDestroySubscriptions.add(it) } + } + + fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { + return subscribe(onNext).also { untilDestroySubscriptions.add(it) } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index 65e98e8a3..f465fe22b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -1,61 +1,61 @@ -package eu.kanade.tachiyomi.ui.setting - -import androidx.preference.PreferenceScreen -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.util.getResourceColor - -class SettingsMainController : SettingsController() { - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { - titleRes = R.string.label_settings - - val tintColor = context.getResourceColor(R.attr.colorAccent) - - preference { - iconRes = R.drawable.ic_tune_black_24dp - iconTint = tintColor - titleRes = R.string.pref_category_general - onClick { navigateTo(SettingsGeneralController()) } - } - preference { - iconRes = R.drawable.ic_chrome_reader_mode_black_24dp - iconTint = tintColor - titleRes = R.string.pref_category_reader - onClick { navigateTo(SettingsReaderController()) } - } - preference { - iconRes = R.drawable.ic_file_download_black_24dp - iconTint = tintColor - titleRes = R.string.pref_category_downloads - onClick { navigateTo(SettingsDownloadController()) } - } - preference { - iconRes = R.drawable.ic_sync_black_24dp - iconTint = tintColor - titleRes = R.string.pref_category_tracking - onClick { navigateTo(SettingsTrackingController()) } - } - preference { - iconRes = R.drawable.ic_backup_black_24dp - iconTint = tintColor - titleRes = R.string.backup - onClick { navigateTo(SettingsBackupController()) } - } - preference { - iconRes = R.drawable.ic_code_black_24dp - iconTint = tintColor - titleRes = R.string.pref_category_advanced - onClick { navigateTo(SettingsAdvancedController()) } - } - preference { - iconRes = R.drawable.ic_help_black_24dp - iconTint = tintColor - titleRes = R.string.pref_category_about - onClick { navigateTo(SettingsAboutController()) } - } - } - - private fun navigateTo(controller: SettingsController) { - router.pushController(controller.withFadeTransaction()) - } -} +package eu.kanade.tachiyomi.ui.setting + +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.util.getResourceColor + +class SettingsMainController : SettingsController() { + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + titleRes = R.string.label_settings + + val tintColor = context.getResourceColor(R.attr.colorAccent) + + preference { + iconRes = R.drawable.ic_tune_black_24dp + iconTint = tintColor + titleRes = R.string.pref_category_general + onClick { navigateTo(SettingsGeneralController()) } + } + preference { + iconRes = R.drawable.ic_chrome_reader_mode_black_24dp + iconTint = tintColor + titleRes = R.string.pref_category_reader + onClick { navigateTo(SettingsReaderController()) } + } + preference { + iconRes = R.drawable.ic_file_download_black_24dp + iconTint = tintColor + titleRes = R.string.pref_category_downloads + onClick { navigateTo(SettingsDownloadController()) } + } + preference { + iconRes = R.drawable.ic_sync_black_24dp + iconTint = tintColor + titleRes = R.string.pref_category_tracking + onClick { navigateTo(SettingsTrackingController()) } + } + preference { + iconRes = R.drawable.ic_backup_black_24dp + iconTint = tintColor + titleRes = R.string.backup + onClick { navigateTo(SettingsBackupController()) } + } + preference { + iconRes = R.drawable.ic_code_black_24dp + iconTint = tintColor + titleRes = R.string.pref_category_advanced + onClick { navigateTo(SettingsAdvancedController()) } + } + preference { + iconRes = R.drawable.ic_help_black_24dp + iconTint = tintColor + titleRes = R.string.pref_category_about + onClick { navigateTo(SettingsAboutController()) } + } + } + + private fun navigateTo(controller: SettingsController) { + router.pushController(controller.withFadeTransaction()) + } +} 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 db8c73ee2..de0e05790 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt @@ -1,239 +1,239 @@ -package eu.kanade.tachiyomi.widget - -import android.content.Context -import android.graphics.drawable.Drawable -import androidx.annotation.CallSuper -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import android.util.AttributeSet -import android.view.View -import android.view.ViewGroup -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor - -/** - * An alternative implementation of [android.support.design.widget.NavigationView], without menu - * inflation and allowing customizable items (multiple selections, custom views, etc). - */ -open class ExtendedNavigationView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0) - : SimpleNavigationView(context, attrs, defStyleAttr) { - - /** - * Every item of the nav view. Generic items must belong to this list, custom items could be - * implemented by an abstract class. If more customization is needed in the future, this can be - * changed to an interface instead of sealed class. - */ - sealed class Item { - /** - * A view separator. - */ - class Separator(val paddingTop: Int = 0, val paddingBottom: Int = 0) : Item() - - /** - * A header with a title. - */ - class Header(val resTitle: Int) : Item() - - /** - * A checkbox. - */ - open class Checkbox(val resTitle: Int, var checked: Boolean = false) : Item() - - /** - * A checkbox belonging to a group. The group must handle selections and restrictions. - */ - class CheckboxGroup(resTitle: Int, override val group: Group, checked: Boolean = false) - : Checkbox(resTitle, checked), GroupedItem - - /** - * A radio belonging to a group (a sole radio makes no sense). The group must handle - * selections and restrictions. - */ - class Radio(val resTitle: Int, override val group: Group, var checked: Boolean = false) - : Item(), GroupedItem - - /** - * An item with which needs more than two states (selected/deselected). - */ - abstract class MultiState(val resTitle: Int, var state: Int = 0) : Item() { - - /** - * Returns the drawable associated to every possible each state. - */ - abstract fun getStateDrawable(context: Context): Drawable? - - /** - * Creates a vector tinted with the accent color. - * - * @param context any context. - * @param resId the vector resource to load and tint - */ - fun tintVector(context: Context, resId: Int): Drawable { - return VectorDrawableCompat.create(context.resources, resId, context.theme)!!.apply { - setTint(context.getResourceColor(R.attr.colorAccent)) - } - } - } - - /** - * An item with which needs more than two states (selected/deselected) belonging to a group. - * The group must handle selections and restrictions. - */ - abstract class MultiStateGroup(resTitle: Int, override val group: Group, state: Int = 0) - : MultiState(resTitle, state), GroupedItem - - /** - * A multistate item for sorting lists (unselected, ascending, descending). - */ - class MultiSort(resId: Int, group: Group) : MultiStateGroup(resId, group) { - - companion object { - const val SORT_NONE = 0 - const val SORT_ASC = 1 - const val SORT_DESC = 2 - } - - override fun getStateDrawable(context: Context): Drawable? { - return when (state) { - SORT_ASC -> tintVector(context, R.drawable.ic_arrow_up_white_32dp) - SORT_DESC -> tintVector(context, R.drawable.ic_arrow_down_white_32dp) - SORT_NONE -> ContextCompat.getDrawable(context, R.drawable.empty_drawable_32dp) - else -> null - } - } - - } - } - - /** - * Interface for an item belonging to a group. - */ - interface GroupedItem { - val group: Group - } - - /** - * A group containing a list of items. - */ - interface Group { - - /** - * An optional header for the group, typically a [Item.Header]. - */ - val header: Item? - - /** - * An optional footer for the group, typically a [Item.Separator]. - */ - val footer: Item? - - /** - * The items of the group, excluding header and footer. - */ - val items: List - - /** - * Creates all the elements of this group. Implementations can override this method for more - * customization. - */ - fun createItems() = (mutableListOf() + header + items + footer).filterNotNull() - - /** - * Called after creating the list of items. Implementations should load the current values - * into the models. - */ - fun initModels() - - /** - * Called when an item of this group is clicked. The group is responsible for all the - * selections of its items. - */ - fun onItemClicked(item: Item) - - } - - /** - * Base adapter for the navigation view. It knows how to create and render every subclass of - * [Item]. - */ - abstract inner class Adapter(private val items: List) : RecyclerView.Adapter() { - - private val onClick = View.OnClickListener { - val pos = recycler.getChildAdapterPosition(it) - val item = items[pos] - onItemClicked(item) - } - - fun notifyItemChanged(item: Item) { - val pos = items.indexOf(item) - if (pos != -1) notifyItemChanged(pos) - } - - override fun getItemCount(): Int { - return items.size - } - - @CallSuper - override fun getItemViewType(position: Int): Int { - val item = items[position] - return when (item) { - is Item.Header -> VIEW_TYPE_HEADER - is Item.Separator -> VIEW_TYPE_SEPARATOR - is Item.Radio -> VIEW_TYPE_RADIO - is Item.Checkbox -> VIEW_TYPE_CHECKBOX - is Item.MultiState -> VIEW_TYPE_MULTISTATE - } - } - - @CallSuper - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { - return when (viewType) { - VIEW_TYPE_HEADER -> HeaderHolder(parent) - VIEW_TYPE_SEPARATOR -> SeparatorHolder(parent) - VIEW_TYPE_RADIO -> RadioHolder(parent, onClick) - VIEW_TYPE_CHECKBOX -> CheckboxHolder(parent, onClick) - VIEW_TYPE_MULTISTATE -> MultiStateHolder(parent, onClick) - else -> throw Exception("Unknown view type") - } - } - - @CallSuper - override fun onBindViewHolder(holder: Holder, position: Int) { - when (holder) { - is HeaderHolder -> { - val item = items[position] as Item.Header - holder.title.setText(item.resTitle) - } - is SeparatorHolder -> { - val view = holder.itemView - val item = items[position] as Item.Separator - view.setPadding(0, item.paddingTop, 0, item.paddingBottom) - } - is RadioHolder -> { - val item = items[position] as Item.Radio - holder.radio.setText(item.resTitle) - holder.radio.isChecked = item.checked - } - is CheckboxHolder -> { - val item = items[position] as Item.CheckboxGroup - holder.check.setText(item.resTitle) - holder.check.isChecked = item.checked - } - is MultiStateHolder -> { - val item = items[position] as Item.MultiStateGroup - val drawable = item.getStateDrawable(context) - holder.text.setText(item.resTitle) - holder.text.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) - } - } - } - - abstract fun onItemClicked(item: Item) - - } - -} +package eu.kanade.tachiyomi.widget + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.annotation.CallSuper +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.getResourceColor + +/** + * An alternative implementation of [android.support.design.widget.NavigationView], without menu + * inflation and allowing customizable items (multiple selections, custom views, etc). + */ +open class ExtendedNavigationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0) + : SimpleNavigationView(context, attrs, defStyleAttr) { + + /** + * Every item of the nav view. Generic items must belong to this list, custom items could be + * implemented by an abstract class. If more customization is needed in the future, this can be + * changed to an interface instead of sealed class. + */ + sealed class Item { + /** + * A view separator. + */ + class Separator(val paddingTop: Int = 0, val paddingBottom: Int = 0) : Item() + + /** + * A header with a title. + */ + class Header(val resTitle: Int) : Item() + + /** + * A checkbox. + */ + open class Checkbox(val resTitle: Int, var checked: Boolean = false) : Item() + + /** + * A checkbox belonging to a group. The group must handle selections and restrictions. + */ + class CheckboxGroup(resTitle: Int, override val group: Group, checked: Boolean = false) + : Checkbox(resTitle, checked), GroupedItem + + /** + * A radio belonging to a group (a sole radio makes no sense). The group must handle + * selections and restrictions. + */ + class Radio(val resTitle: Int, override val group: Group, var checked: Boolean = false) + : Item(), GroupedItem + + /** + * An item with which needs more than two states (selected/deselected). + */ + abstract class MultiState(val resTitle: Int, var state: Int = 0) : Item() { + + /** + * Returns the drawable associated to every possible each state. + */ + abstract fun getStateDrawable(context: Context): Drawable? + + /** + * Creates a vector tinted with the accent color. + * + * @param context any context. + * @param resId the vector resource to load and tint + */ + fun tintVector(context: Context, resId: Int): Drawable { + return VectorDrawableCompat.create(context.resources, resId, context.theme)!!.apply { + setTint(context.getResourceColor(R.attr.colorAccent)) + } + } + } + + /** + * An item with which needs more than two states (selected/deselected) belonging to a group. + * The group must handle selections and restrictions. + */ + abstract class MultiStateGroup(resTitle: Int, override val group: Group, state: Int = 0) + : MultiState(resTitle, state), GroupedItem + + /** + * A multistate item for sorting lists (unselected, ascending, descending). + */ + class MultiSort(resId: Int, group: Group) : MultiStateGroup(resId, group) { + + companion object { + const val SORT_NONE = 0 + const val SORT_ASC = 1 + const val SORT_DESC = 2 + } + + override fun getStateDrawable(context: Context): Drawable? { + return when (state) { + SORT_ASC -> tintVector(context, R.drawable.ic_arrow_up_white_32dp) + SORT_DESC -> tintVector(context, R.drawable.ic_arrow_down_white_32dp) + SORT_NONE -> ContextCompat.getDrawable(context, R.drawable.empty_drawable_32dp) + else -> null + } + } + + } + } + + /** + * Interface for an item belonging to a group. + */ + interface GroupedItem { + val group: Group + } + + /** + * A group containing a list of items. + */ + interface Group { + + /** + * An optional header for the group, typically a [Item.Header]. + */ + val header: Item? + + /** + * An optional footer for the group, typically a [Item.Separator]. + */ + val footer: Item? + + /** + * The items of the group, excluding header and footer. + */ + val items: List + + /** + * Creates all the elements of this group. Implementations can override this method for more + * customization. + */ + fun createItems() = (mutableListOf() + header + items + footer).filterNotNull() + + /** + * Called after creating the list of items. Implementations should load the current values + * into the models. + */ + fun initModels() + + /** + * Called when an item of this group is clicked. The group is responsible for all the + * selections of its items. + */ + fun onItemClicked(item: Item) + + } + + /** + * Base adapter for the navigation view. It knows how to create and render every subclass of + * [Item]. + */ + abstract inner class Adapter(private val items: List) : RecyclerView.Adapter() { + + private val onClick = View.OnClickListener { + val pos = recycler.getChildAdapterPosition(it) + val item = items[pos] + onItemClicked(item) + } + + fun notifyItemChanged(item: Item) { + val pos = items.indexOf(item) + if (pos != -1) notifyItemChanged(pos) + } + + override fun getItemCount(): Int { + return items.size + } + + @CallSuper + override fun getItemViewType(position: Int): Int { + val item = items[position] + return when (item) { + is Item.Header -> VIEW_TYPE_HEADER + is Item.Separator -> VIEW_TYPE_SEPARATOR + is Item.Radio -> VIEW_TYPE_RADIO + is Item.Checkbox -> VIEW_TYPE_CHECKBOX + is Item.MultiState -> VIEW_TYPE_MULTISTATE + } + } + + @CallSuper + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return when (viewType) { + VIEW_TYPE_HEADER -> HeaderHolder(parent) + VIEW_TYPE_SEPARATOR -> SeparatorHolder(parent) + VIEW_TYPE_RADIO -> RadioHolder(parent, onClick) + VIEW_TYPE_CHECKBOX -> CheckboxHolder(parent, onClick) + VIEW_TYPE_MULTISTATE -> MultiStateHolder(parent, onClick) + else -> throw Exception("Unknown view type") + } + } + + @CallSuper + override fun onBindViewHolder(holder: Holder, position: Int) { + when (holder) { + is HeaderHolder -> { + val item = items[position] as Item.Header + holder.title.setText(item.resTitle) + } + is SeparatorHolder -> { + val view = holder.itemView + val item = items[position] as Item.Separator + view.setPadding(0, item.paddingTop, 0, item.paddingBottom) + } + is RadioHolder -> { + val item = items[position] as Item.Radio + holder.radio.setText(item.resTitle) + holder.radio.isChecked = item.checked + } + is CheckboxHolder -> { + val item = items[position] as Item.CheckboxGroup + holder.check.setText(item.resTitle) + holder.check.isChecked = item.checked + } + is MultiStateHolder -> { + val item = items[position] as Item.MultiStateGroup + val drawable = item.getStateDrawable(context) + holder.text.setText(item.resTitle) + holder.text.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + } + } + } + + abstract fun onItemClicked(item: Item) + + } + +} diff --git a/app/src/main/res/drawable/empty_drawable_32dp.xml b/app/src/main/res/drawable/empty_drawable_32dp.xml index de7699cab..09b50315d 100644 --- a/app/src/main/res/drawable/empty_drawable_32dp.xml +++ b/app/src/main/res/drawable/empty_drawable_32dp.xml @@ -1,8 +1,8 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_done_white_18dp.xml b/app/src/main/res/drawable/ic_done_white_18dp.xml index 3bd793040..3e9103eb0 100644 --- a/app/src/main/res/drawable/ic_done_white_18dp.xml +++ b/app/src/main/res/drawable/ic_done_white_18dp.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml index 6032098bd..e9f85c873 100644 --- a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml +++ b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/layout/navigation_view_checkbox.xml b/app/src/main/res/layout/navigation_view_checkbox.xml index 18a345f58..ecc547fc5 100644 --- a/app/src/main/res/layout/navigation_view_checkbox.xml +++ b/app/src/main/res/layout/navigation_view_checkbox.xml @@ -1,23 +1,23 @@ - - - - - - + + + + + + diff --git a/app/src/main/res/layout/navigation_view_group.xml b/app/src/main/res/layout/navigation_view_group.xml index 10b43e851..d3399c50f 100644 --- a/app/src/main/res/layout/navigation_view_group.xml +++ b/app/src/main/res/layout/navigation_view_group.xml @@ -1,30 +1,30 @@ - - - - - - - + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/pref_item_source.xml b/app/src/main/res/layout/pref_item_source.xml index 27ff9b02e..88680c72e 100644 --- a/app/src/main/res/layout/pref_item_source.xml +++ b/app/src/main/res/layout/pref_item_source.xml @@ -1,62 +1,62 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/track_item.xml b/app/src/main/res/layout/track_item.xml index 27cc0d5ea..3dff2a779 100644 --- a/app/src/main/res/layout/track_item.xml +++ b/app/src/main/res/layout/track_item.xml @@ -1,191 +1,191 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +