@@ -0,0 +1,24 @@
+* text=auto
+* text eol=lf
+
+# Windows forced line-endings
+/.idea/* text eol=crlf
+# Gradle wrapper
+*.jar binary
+# Images
+*.webp binary
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.gz binary
+*.zip binary
+*.7z binary
+*.ttf binary
+*.eot binary
+*.woff binary
+*.pyc binary
+*.swp binary
@@ -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"
+ }
}
@@ -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<Track>) = db.put().objects(tracks).prepare()
- fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
- .byQuery(DeleteQuery.builder()
- .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
- .whereArgs(manga.id, sync.id)
+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<Track>) = db.put().objects(tracks).prepare()
+ fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
+ .byQuery(DeleteQuery.builder()
+ .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
+ .whereArgs(manga.id, sync.id)
@@ -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"
- 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"
+ 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"
+}
@@ -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 }
@@ -1,70 +1,70 @@
-import android.support.annotation.CallSuper
-import android.support.annotation.DrawableRes
-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<Int>
- abstract fun getStatus(status: Int): String
- abstract fun getScoreList(): List<String>
- open fun indexToScore(index: Int): Float {
- return index.toFloat()
- abstract fun displayScore(track: Track): String
- abstract fun add(track: Track): Observable<Track>
- abstract fun update(track: Track): Observable<Track>
- abstract fun bind(track: Track): Observable<Track>
- abstract fun search(query: String): Observable<List<TrackSearch>>
- abstract fun refresh(track: Track): Observable<Track>
- 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)
+import android.support.annotation.CallSuper
+import android.support.annotation.DrawableRes
+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<Int>
+ abstract fun getStatus(status: Int): String
+ abstract fun getScoreList(): List<String>
+ open fun indexToScore(index: Int): Float {
+ return index.toFloat()
+ abstract fun displayScore(track: Track): String
+ abstract fun add(track: Track): Observable<Track>
+ abstract fun update(track: Track): Observable<Track>
+ abstract fun bind(track: Track): Observable<Track>
+ abstract fun search(query: String): Observable<List<TrackSearch>>
+ abstract fun refresh(track: Track): Observable<Track>
+ 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)
@@ -1,214 +1,214 @@
-package eu.kanade.tachiyomi.data.track.anilist
-import android.graphics.Color
-import com.google.gson.Gson
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.preference.getOrDefault
-class Anilist(private val context: Context, id: Int) : TrackService(id) {
- 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<Int> {
- 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<String> {
- 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 {
- POINT_10 -> index * 10f
- POINT_100 -> index.toFloat()
- POINT_5 -> when {
- index == 0 -> 0f
- else -> index * 20f - 10f
- POINT_3 -> when {
- else -> index * 25f + 10f
- POINT_10_DECIMAL -> index.toFloat()
- override fun displayScore(track: Track): String {
- val score = track.score
- score == 0f -> "0 ★"
- else -> "${((score + 10) / 20).toInt()} ★"
- score == 0f -> "0"
- score <= 35 -> "😦"
- score <= 60 -> "😐"
- else -> "😊"
- else -> track.toAnilistScore()
- override fun add(track: Track): Observable<Track> {
- return api.addLibManga(track)
- override fun update(track: Track): Observable<Track> {
- 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<Track> {
- 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<List<TrackSearch>> {
- return api.search(query)
- override fun refresh(track: Track): Observable<Track> {
- return api.getLibManga(track, getUsername().toInt())
- .map { 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{
- }.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.graphics.Color
+import com.google.gson.Gson
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.preference.getOrDefault
+class Anilist(private val context: Context, id: Int) : TrackService(id) {
+ 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<Int> {
+ 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<String> {
+ 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 {
+ POINT_10 -> index * 10f
+ POINT_100 -> index.toFloat()
+ POINT_5 -> when {
+ index == 0 -> 0f
+ else -> index * 20f - 10f
+ POINT_3 -> when {
+ else -> index * 25f + 10f
+ POINT_10_DECIMAL -> index.toFloat()
+ override fun displayScore(track: Track): String {
+ val score = track.score
+ score == 0f -> "0 ★"
+ else -> "${((score + 10) / 20).toInt()} ★"
+ score == 0f -> "0"
+ score <= 35 -> "😦"
+ score <= 60 -> "😐"
+ else -> "😊"
+ else -> track.toAnilistScore()
+ override fun add(track: Track): Observable<Track> {
+ return api.addLibManga(track)
+ override fun update(track: Track): Observable<Track> {
+ 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<Track> {
+ 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<List<TrackSearch>> {
+ return api.search(query)
+ override fun refresh(track: Track): Observable<Track> {
+ return api.getLibManga(track, getUsername().toInt())
+ .map { 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{
+ }.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
@@ -1,286 +1,286 @@
-import android.net.Uri
-import com.github.salomonbrys.kotson.*
-import com.google.gson.JsonObject
-import com.google.gson.JsonParser
-import eu.kanade.tachiyomi.network.asObservableSuccess
-import okhttp3.MediaType
-import okhttp3.Request
-import okhttp3.RequestBody
-import java.util.Calendar
-class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
- private val parser = JsonParser()
- private val jsonMime = MediaType.parse("application/json; charset=utf-8")
- private val authClient = client.newBuilder().addInterceptor(interceptor).build()
- fun addLibManga(track: Track): Observable<Track> {
- 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
- fun updateLibManga(track: Track): Observable<Track> {
- |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
- |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
- |id
- |status
- |progress
- "listId" to track.library_id,
- "status" to track.toAnilistStatus(),
- "score" to track.score.toInt()
- .map {
- fun search(search: String): Observable<List<TrackSearch>> {
- |query Search(${'$'}query: String) {
- |Page (perPage: 50) {
- |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
- |title {
- |romaji
- |coverImage {
- |large
- |type
- |chapters
- |description
- |startDate {
- |year
- |month
- |day
- "query" to search
- 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<Track?> {
- |query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
- |Page {
- |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
- |scoreRaw: score(format: POINT_100)
- |media {
- "id" to userid,
- "manga_id" to track.media_id
- val media = page["mediaList"].array
- val entries = media.map { jsonToALUserManga(it.obj) }
- entries.firstOrNull()?.toTrack()
- fun getLibManga(track: Track, userid: Int): Observable<Track> {
- 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<Pair<Int, String>> {
- |query User {
- |Viewer {
- |mediaListOptions {
- |scoreFormat
- "query" to query
- 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))
- 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")
+import android.net.Uri
+import com.github.salomonbrys.kotson.*
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import okhttp3.MediaType
+import okhttp3.Request
+import okhttp3.RequestBody
+import java.util.Calendar
+class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
+ private val parser = JsonParser()
+ private val jsonMime = MediaType.parse("application/json; charset=utf-8")
+ private val authClient = client.newBuilder().addInterceptor(interceptor).build()
+ fun addLibManga(track: Track): Observable<Track> {
+ 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
+ fun updateLibManga(track: Track): Observable<Track> {
+ |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
+ |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
+ |id
+ |status
+ |progress
+ "listId" to track.library_id,
+ "status" to track.toAnilistStatus(),
+ "score" to track.score.toInt()
+ .map {
+ fun search(search: String): Observable<List<TrackSearch>> {
+ |query Search(${'$'}query: String) {
+ |Page (perPage: 50) {
+ |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
+ |title {
+ |romaji
+ |coverImage {
+ |large
+ |type
+ |chapters
+ |description
+ |startDate {
+ |year
+ |month
+ |day
+ "query" to search
+ 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<Track?> {
+ |query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
+ |Page {
+ |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
+ |scoreRaw: score(format: POINT_100)
+ |media {
+ "id" to userid,
+ "manga_id" to track.media_id
+ val media = page["mediaList"].array
+ val entries = media.map { jsonToALUserManga(it.obj) }
+ entries.firstOrNull()?.toTrack()
+ fun getLibManga(track: Track, userid: Int): Observable<Track> {
+ 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<Pair<Int, String>> {
+ |query User {
+ |Viewer {
+ |mediaListOptions {
+ |scoreFormat
+ "query" to query
+ 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))
+ 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")
@@ -1,58 +1,58 @@
-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}")
- 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)
+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}")
+ 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)
@@ -1,10 +1,10 @@
-data class OAuth(
- val access_token: String,
- val token_type: String,
- val expires: Long,
- val expires_in: Long) {
- fun isExpired() = System.currentTimeMillis() > expires
+data class OAuth(
+ val access_token: String,
+ val token_type: String,
+ val expires: Long,
+ val expires_in: Long) {
+ fun isExpired() = System.currentTimeMillis() > expires
@@ -1,144 +1,144 @@
-package eu.kanade.tachiyomi.data.track.bangumi
-class Bangumi(private val context: Context, id: Int) : TrackService(id) {
- return IntRange(0, 10).map(Int::toString)
- return track.score.toInt().toString()
- return api.statusLibManga(track)
- .flatMap {
- api.findLibManga(track).flatMap { remoteTrack ->
- if (remoteTrack != null && it != null) {
- track.status = remoteTrack.status
- track.last_chapter_read = remoteTrack.last_chapter_read
- track.copyPersonalFrom(it!!)
- api.findLibManga(track)
- const val READING = 3
- const val ON_HOLD = 4
- const val DROPPED = 5
- const val PLANNING = 1
- override val name = "Bangumi"
- 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)
- return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING)
- 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 {
- fun saveToken(oauth: OAuth?) {
- val json = gson.toJson(oauth)
- preferences.trackToken(this).set(json)
- fun restoreToken(): OAuth? {
- interceptor.newAuth(null)
+package eu.kanade.tachiyomi.data.track.bangumi
+class Bangumi(private val context: Context, id: Int) : TrackService(id) {
+ return IntRange(0, 10).map(Int::toString)
+ return track.score.toInt().toString()
+ return api.statusLibManga(track)
+ .flatMap {
+ api.findLibManga(track).flatMap { remoteTrack ->
+ if (remoteTrack != null && it != null) {
+ track.status = remoteTrack.status
+ track.last_chapter_read = remoteTrack.last_chapter_read
+ track.copyPersonalFrom(it!!)
+ api.findLibManga(track)
+ const val READING = 3
+ const val ON_HOLD = 4
+ const val DROPPED = 5
+ const val PLANNING = 1
+ override val name = "Bangumi"
+ 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)
+ return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING)
+ 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 {
+ fun saveToken(oauth: OAuth?) {
+ val json = gson.toJson(oauth)
+ preferences.trackToken(this).set(json)
+ fun restoreToken(): OAuth? {
+ interceptor.newAuth(null)
@@ -1,16 +1,16 @@
- 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)
+ 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.kitsu
-import java.text.DecimalFormat
-class Kitsu(private val context: Context, id: Int) : TrackService(id) {
- const val PLAN_TO_READ = 5
- const val DEFAULT_SCORE = 0f
- override val name = "Kitsu"
- 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)
- return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
- PLAN_TO_READ -> getString(R.string.plan_to_read)
- val df = DecimalFormat("0.#")
- return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
- return if (index > 0) (index + 1) / 2f else 0f
- return df.format(track.score)
- return api.addLibManga(track, getUserId())
- return api.findLibManga(track, getUserId())
- track.media_id = remoteTrack.media_id
- track.score = DEFAULT_SCORE
- return api.getLibManga(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()
- private fun getUserId(): String {
- return getPassword()
+package eu.kanade.tachiyomi.data.track.kitsu
+import java.text.DecimalFormat
+class Kitsu(private val context: Context, id: Int) : TrackService(id) {
+ const val PLAN_TO_READ = 5
+ const val DEFAULT_SCORE = 0f
+ override val name = "Kitsu"
+ 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)
+ return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
+ PLAN_TO_READ -> getString(R.string.plan_to_read)
+ val df = DecimalFormat("0.#")
+ return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
+ return if (index > 0) (index + 1) / 2f else 0f
+ return df.format(track.score)
+ return api.addLibManga(track, getUserId())
+ return api.findLibManga(track, getUserId())
+ track.media_id = remoteTrack.media_id
+ track.score = DEFAULT_SCORE
+ return api.getLibManga(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()
+ private fun getUserId(): String {
+ return getPassword()
@@ -1,11 +1,11 @@
- val refresh_token: String?) {
+ val refresh_token: String?) {
@@ -1,164 +1,164 @@
-package eu.kanade.tachiyomi.data.track.myanimelist
-import okhttp3.HttpUrl
-import java.lang.Exception
-class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
- const val PLAN_TO_READ = 6
- 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)
- return api.findLibManga(track)
- return Observable.fromCallable { api.login(username, password) }
- .doOnNext { csrf -> saveCSRF(csrf) }
- .doOnNext { saveCredentials(username, password) }
- fun refreshLogin() {
- val username = getUsername()
- val password = getPassword()
- val csrf = api.login(username, password)
- saveCSRF(csrf)
- saveCredentials(username, password)
- 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()
- preferences.trackToken(this).delete()
- networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!)
- 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 = HttpUrl.parse(BASE_URL)!!
- 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 okhttp3.HttpUrl
+import java.lang.Exception
+class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
+ const val PLAN_TO_READ = 6
+ 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)
+ return api.findLibManga(track)
+ return Observable.fromCallable { api.login(username, password) }
+ .doOnNext { csrf -> saveCSRF(csrf) }
+ .doOnNext { saveCredentials(username, password) }
+ fun refreshLogin() {
+ val username = getUsername()
+ val password = getPassword()
+ val csrf = api.login(username, password)
+ saveCSRF(csrf)
+ saveCredentials(username, password)
+ 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()
+ preferences.trackToken(this).delete()
+ networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!)
+ 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 = HttpUrl.parse(BASE_URL)!!
+ for (ck in networkService.cookieManager.get(url)) {
+ if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
+ ckCount++
+ return ckCount == 2
@@ -1,13 +1,13 @@
-package eu.kanade.tachiyomi.data.track.shikimori
- // Access token lives 1 day
+package eu.kanade.tachiyomi.data.track.shikimori
+ // Access token lives 1 day
@@ -1,139 +1,139 @@
-import android.util.Log
-class Shikimori(private val context: Context, id: Int) : TrackService(id) {
- return api.addLibManga(track, getUsername())
- return api.updateLibManga(track, getUsername())
- return api.findLibManga(track, getUsername())
- override val name = "Shikimori"
- 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)
- val user = api.getCurrentUser()
- saveCredentials(user.toString(), oauth.access_token)
+import android.util.Log
+class Shikimori(private val context: Context, id: Int) : TrackService(id) {
+ return api.addLibManga(track, getUsername())
+ return api.updateLibManga(track, getUsername())
+ return api.findLibManga(track, getUsername())
+ override val name = "Shikimori"
+ 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)
+ val user = api.getCurrentUser()
+ saveCredentials(user.toString(), oauth.access_token)
@@ -1,154 +1,154 @@
-package eu.kanade.tachiyomi.network
-import android.annotation.SuppressLint
-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 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)
- @Synchronized
- initWebView
- val response = chain.proceed(chain.request())
- // Check if Cloudflare anti-bot is on
- if (response.code() == 503 && response.header("Server") in serverCheck) {
- response.close()
- val solutionRequest = resolveWithWebView(chain.request())
- return chain.proceed(solutionRequest)
- // 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.
- override fun onReceivedErrorCompat(
- errorCode: Int,
- description: String?,
- failingUrl: String,
- isMainFrame: Boolean
- if (isMainFrame) {
- if (errorCode == 503) {
- // Found the cloudflare challenge page.
- challengeFound = true
- // Unlock thread, the challenge wasn't found.
- 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)
- 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")
+package eu.kanade.tachiyomi.network
+import android.annotation.SuppressLint
+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 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)
+ @Synchronized
+ initWebView
+ val response = chain.proceed(chain.request())
+ // Check if Cloudflare anti-bot is on
+ if (response.code() == 503 && response.header("Server") in serverCheck) {
+ response.close()
+ val solutionRequest = resolveWithWebView(chain.request())
+ return chain.proceed(solutionRequest)
+ // 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.
+ override fun onReceivedErrorCompat(
+ errorCode: Int,
+ description: String?,
+ failingUrl: String,
+ isMainFrame: Boolean
+ if (isMainFrame) {
+ if (errorCode == 503) {
+ // Found the cloudflare challenge page.
+ challengeFound = true
+ // Unlock thread, the challenge wasn't found.
+ 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)
+ 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")
@@ -1,117 +1,117 @@
-import okhttp3.*
-import java.io.File
-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()
- val cloudflareClient = client.newBuilder()
- .addInterceptor(CloudflareInterceptor(context))
- 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
- val context = SSLContext.getInstance("TLS")
- context.init(null, null, null)
- internalSSLSocketFactory = context.socketFactory
- override fun getDefaultCipherSuites(): Array<String> {
- return internalSSLSocketFactory.defaultCipherSuites
- override fun getSupportedCipherSuites(): Array<String> {
- return internalSSLSocketFactory.supportedCipherSuites
- @Throws(IOException::class)
- override fun createSocket(): Socket? {
- return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
- 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))
- override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
- return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
- override fun createSocket(host: InetAddress, port: Int): Socket? {
- 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
- val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
- connectionSpecs(specs)
+import okhttp3.*
+import java.io.File
+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()
+ val cloudflareClient = client.newBuilder()
+ .addInterceptor(CloudflareInterceptor(context))
+ 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
+ val context = SSLContext.getInstance("TLS")
+ context.init(null, null, null)
+ internalSSLSocketFactory = context.socketFactory
+ override fun getDefaultCipherSuites(): Array<String> {
+ return internalSSLSocketFactory.defaultCipherSuites
+ override fun getSupportedCipherSuites(): Array<String> {
+ return internalSSLSocketFactory.supportedCipherSuites
+ @Throws(IOException::class)
+ override fun createSocket(): Socket? {
+ return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
+ 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))
+ override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
+ return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
+ override fun createSocket(host: InetAddress, port: Int): Socket? {
+ 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
+ val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
+ connectionSpecs(specs)
-import okhttp3.Call
-import rx.Producer
-import rx.Subscription
-import java.util.concurrent.atomic.AtomicBoolean
-fun Call.asObservable(): Observable<Response> {
- 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
- val response = call.execute()
- if (!subscriber.isUnsubscribed) {
- subscriber.onNext(response)
- subscriber.onCompleted()
- } catch (error: Exception) {
- 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<Response> {
- return asObservable().doOnNext { response ->
- if (!response.isSuccessful) {
- 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))
- return progressClient.newCall(request)
+import okhttp3.Call
+import rx.Producer
+import rx.Subscription
+import java.util.concurrent.atomic.AtomicBoolean
+fun Call.asObservable(): Observable<Response> {
+ 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
+ val response = call.execute()
+ if (!subscriber.isUnsubscribed) {
+ subscriber.onNext(response)
+ subscriber.onCompleted()
+ } catch (error: Exception) {
+ 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<Response> {
+ return asObservable().doOnNext { response ->
+ if (!response.isSuccessful) {
+ 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))
+ return progressClient.newCall(request)
@@ -1,5 +1,5 @@
-interface ProgressListener {
- fun update(bytesRead: Long, contentLength: Long, done: Boolean)
+interface ProgressListener {
+ fun update(bytesRead: Long, contentLength: Long, done: Boolean)
@@ -1,40 +1,40 @@
-import okhttp3.ResponseBody
-import okio.*
-class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
- private val bufferedSource: BufferedSource by lazy {
- Okio.buffer(source(responseBody.source()))
- override fun contentType(): MediaType {
- return responseBody.contentType()!!
- override fun contentLength(): Long {
- return responseBody.contentLength()
- override fun source(): BufferedSource {
- return bufferedSource
- private fun source(source: Source): Source {
- return object : ForwardingSource(source) {
- internal var totalBytesRead = 0L
- 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
+import okhttp3.ResponseBody
+import okio.*
+class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
+ private val bufferedSource: BufferedSource by lazy {
+ Okio.buffer(source(responseBody.source()))
+ override fun contentType(): MediaType {
+ return responseBody.contentType()!!
+ override fun contentLength(): Long {
+ return responseBody.contentLength()
+ override fun source(): BufferedSource {
+ return bufferedSource
+ private fun source(source: Source): Source {
+ return object : ForwardingSource(source) {
+ internal var totalBytesRead = 0L
+ 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
@@ -1,32 +1,32 @@
-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)
-fun POST(url: String,
- body: RequestBody = DEFAULT_BODY,
+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)
+fun POST(url: String,
+ body: RequestBody = DEFAULT_BODY,
@@ -1,46 +1,46 @@
-package eu.kanade.tachiyomi.source
-import eu.kanade.tachiyomi.source.model.FilterList
-import eu.kanade.tachiyomi.source.model.MangasPage
-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<MangasPage>
- * @param query the search query.
- * @param filters the list of filters to apply.
- fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
- * Returns an observable containing a page with a list of latest manga updates.
- fun fetchLatestUpdates(page: Int): Observable<MangasPage>
- * 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
+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<MangasPage>
+ * @param query the search query.
+ * @param filters the list of filters to apply.
+ fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
+ * Returns an observable containing a page with a list of latest manga updates.
+ fun fetchLatestUpdates(page: Int): Observable<MangasPage>
+ * Returns the list of filters for the source.
+ fun getFilterList(): FilterList
@@ -1,44 +1,44 @@
-import eu.kanade.tachiyomi.source.model.Page
-import eu.kanade.tachiyomi.source.model.SChapter
-import eu.kanade.tachiyomi.source.model.SManga
- * 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<SManga>
- * Returns an observable with all the available chapters for a manga.
- fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
- * Returns an observable with the list of pages a chapter has.
- * @param chapter the chapter.
- fun fetchPageList(chapter: SChapter): Observable<List<Page>>
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+ * 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<SManga>
+ * Returns an observable with all the available chapters for a manga.
+ fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
+ * Returns an observable with the list of pages a chapter has.
+ * @param chapter the chapter.
+ fun fetchPageList(chapter: SChapter): Observable<List<Page>>
@@ -1,74 +1,74 @@
-import eu.kanade.tachiyomi.source.online.HttpSource
-open class SourceManager(private val context: Context) {
- private val sourcesMap = mutableMapOf<Long, Source>()
- private val stubSourcesMap = mutableMapOf<Long, StubSource>()
- 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<HttpSource>()
- fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
- 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<Source> = listOf(
- LocalSource(context)
- private inner class StubSource(override val id: Long) : Source {
- get() = id.toString()
- override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
- return Observable.error(getSourceNotInstalledException())
- override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
- override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
- override fun toString(): String {
- return name
- private fun getSourceNotInstalledException(): Exception {
- return Exception(context.getString(R.string.source_not_installed, id.toString()))
+import eu.kanade.tachiyomi.source.online.HttpSource
+open class SourceManager(private val context: Context) {
+ private val sourcesMap = mutableMapOf<Long, Source>()
+ private val stubSourcesMap = mutableMapOf<Long, StubSource>()
+ 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<HttpSource>()
+ fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
+ 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<Source> = listOf(
+ LocalSource(context)
+ private inner class StubSource(override val id: Long) : Source {
+ get() = id.toString()
+ override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
+ return Observable.error(getSourceNotInstalledException())
+ override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
+ override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
+ 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.model
-sealed class Filter<T>(val name: String, var state: T) {
- open class Header(name: String) : Filter<Any>(name, 0)
- open class Separator(name: String = "") : Filter<Any>(name, 0)
- abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
- abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
- abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
- abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
- fun isIgnored() = state == STATE_IGNORE
- fun isIncluded() = state == STATE_INCLUDE
- fun isExcluded() = state == STATE_EXCLUDE
- const val STATE_IGNORE = 0
- const val STATE_INCLUDE = 1
- const val STATE_EXCLUDE = 2
- abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state)
- abstract class Sort(name: String, val values: Array<String>, state: Selection? = null)
- : Filter<Sort.Selection?>(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<T>(val name: String, var state: T) {
+ open class Header(name: String) : Filter<Any>(name, 0)
+ open class Separator(name: String = "") : Filter<Any>(name, 0)
+ abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
+ abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
+ abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
+ abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
+ fun isIgnored() = state == STATE_IGNORE
+ fun isIncluded() = state == STATE_INCLUDE
+ fun isExcluded() = state == STATE_EXCLUDE
+ const val STATE_IGNORE = 0
+ const val STATE_INCLUDE = 1
+ const val STATE_EXCLUDE = 2
+ abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state)
+ abstract class Sort(name: String, val values: Array<String>, state: Selection? = null)
+ : Filter<Sort.Selection?>(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
@@ -1,7 +1,7 @@
-data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
- constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
+data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
+ constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
@@ -1,3 +1,3 @@
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)
@@ -1,48 +1,48 @@
-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
- field = value
- statusSubject?.onNext(value)
- @Transient @Volatile var progress: Int = 0
- @Transient private var statusSubject: Subject<Int, Int>? = null
- override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
- progress = if (contentLength > 0) {
- (100 * bytesRead / contentLength).toInt()
- -1
- fun setStatusSubject(subject: Subject<Int, Int>?) {
- this.statusSubject = subject
- const val QUEUE = 0
- const val LOAD_PAGE = 1
- const val DOWNLOAD_IMAGE = 2
- const val READY = 3
- const val ERROR = 4
+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
+ field = value
+ statusSubject?.onNext(value)
+ @Transient @Volatile var progress: Int = 0
+ @Transient private var statusSubject: Subject<Int, Int>? = null
+ override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
+ progress = if (contentLength > 0) {
+ (100 * bytesRead / contentLength).toInt()
+ -1
+ fun setStatusSubject(subject: Subject<Int, Int>?) {
+ this.statusSubject = subject
+ const val QUEUE = 0
+ const val LOAD_PAGE = 1
+ const val DOWNLOAD_IMAGE = 2
+ const val READY = 3
+ const val ERROR = 4
@@ -1,31 +1,31 @@
-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
- fun create(): SChapter {
- return SChapterImpl()
+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
+ fun create(): SChapter {
+ return SChapterImpl()
@@ -1,15 +1,15 @@
-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
+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
-interface SManga : Serializable {
- 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
- const val UNKNOWN = 0
- const val ONGOING = 1
- const val LICENSED = 3
- fun create(): SManga {
- return SMangaImpl()
+interface SManga : Serializable {
+ 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
+ const val UNKNOWN = 0
+ const val ONGOING = 1
+ const val LICENSED = 3
+ fun create(): SManga {
+ return SMangaImpl()
-class SMangaImpl : SManga {
- 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
+class SMangaImpl : SManga {
+ 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
@@ -1,367 +1,367 @@
-package eu.kanade.tachiyomi.source.online
-import eu.kanade.tachiyomi.network.GET
-import eu.kanade.tachiyomi.network.newCallWithProgress
-import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.source.model.*
-import okhttp3.Headers
-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<Application>().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.
- 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.
- override fun fetchPopularManga(page: Int): Observable<MangasPage> {
- return client.newCall(popularMangaRequest(page))
- .map { response ->
- popularMangaParse(response)
- * Returns the request for the popular manga given the page.
- 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
- override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
- return client.newCall(searchMangaRequest(page, query, filters))
- searchMangaParse(response)
- * Returns the request for the search manga given the page.
- abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
- abstract protected fun searchMangaParse(response: Response): MangasPage
- override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
- return client.newCall(latestUpdatesRequest(page))
- latestUpdatesParse(response)
- * Returns the request for latest manga given the page.
- abstract protected fun latestUpdatesRequest(page: Int): Request
- abstract protected fun latestUpdatesParse(response: Response): MangasPage
- * Returns an observable with the updated details for a manga. Normally it's not needed to
- * @param manga the manga to be updated.
- return client.newCall(mangaDetailsRequest(manga))
- 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.
- 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.
- 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.
- if (manga.status != SManga.LICENSED) {
- return client.newCall(chapterListRequest(manga))
- chapterListParse(response)
- 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.
- open protected fun chapterListRequest(manga: SManga): Request {
- * Parses the response from the site and returns a list of chapters.
- abstract protected fun chapterListParse(response: Response): List<SChapter>
- * Returns an observable with the page list for a chapter.
- * @param chapter the chapter whose page list has to be fetched.
- return client.newCall(pageListRequest(chapter))
- pageListParse(response)
- * Returns the request for getting the page list. Override only if it's needed to override the
- 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.
- abstract protected fun pageListParse(response: Response): List<Page>
- * 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<String> {
- return client.newCall(imageUrlRequest(page))
- .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.
- 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<Response> {
- return client.newCallWithProgress(imageRequest(page), page)
- * Returns the request for getting the source image. Override only if it's needed to override
- 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
- * @param url the full url to the manga.
- fun SManga.setUrlWithoutDomain(url: String) {
- * Returns the url of the given string without the scheme and domain.
- * @param orig the full url.
- private fun getUrlWithoutDomain(orig: String): String {
- 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) {
- override fun getFilterList() = FilterList()
+package eu.kanade.tachiyomi.source.online
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.newCallWithProgress
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.model.*
+import okhttp3.Headers
+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<Application>().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.
+ 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.
+ override fun fetchPopularManga(page: Int): Observable<MangasPage> {
+ return client.newCall(popularMangaRequest(page))
+ .map { response ->
+ popularMangaParse(response)
+ * Returns the request for the popular manga given the page.
+ 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
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
+ return client.newCall(searchMangaRequest(page, query, filters))
+ searchMangaParse(response)
+ * Returns the request for the search manga given the page.
+ abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
+ abstract protected fun searchMangaParse(response: Response): MangasPage
+ override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
+ return client.newCall(latestUpdatesRequest(page))
+ latestUpdatesParse(response)
+ * Returns the request for latest manga given the page.
+ abstract protected fun latestUpdatesRequest(page: Int): Request
+ abstract protected fun latestUpdatesParse(response: Response): MangasPage
+ * Returns an observable with the updated details for a manga. Normally it's not needed to
+ * @param manga the manga to be updated.
+ return client.newCall(mangaDetailsRequest(manga))
+ 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.
+ 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.
+ 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.
+ if (manga.status != SManga.LICENSED) {
+ return client.newCall(chapterListRequest(manga))
+ chapterListParse(response)
+ 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.
+ open protected fun chapterListRequest(manga: SManga): Request {
+ * Parses the response from the site and returns a list of chapters.
+ abstract protected fun chapterListParse(response: Response): List<SChapter>
+ * Returns an observable with the page list for a chapter.
+ * @param chapter the chapter whose page list has to be fetched.
+ return client.newCall(pageListRequest(chapter))
+ pageListParse(response)
+ * Returns the request for getting the page list. Override only if it's needed to override the
+ 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.
+ abstract protected fun pageListParse(response: Response): List<Page>
+ * 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<String> {
+ return client.newCall(imageUrlRequest(page))
+ .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.
+ 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<Response> {
+ return client.newCallWithProgress(imageRequest(page), page)
+ * Returns the request for getting the source image. Override only if it's needed to override
+ 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
+ * @param url the full url to the manga.
+ fun SManga.setUrlWithoutDomain(url: String) {
+ * Returns the url of the given string without the scheme and domain.
+ * @param orig the full url.
+ private fun getUrlWithoutDomain(orig: String): String {
+ 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) {
+ override fun getFilterList() = FilterList()
@@ -1,25 +1,25 @@
-fun HttpSource.getImageUrl(page: Page): Observable<Page> {
- 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<Page>): Observable<Page> {
- return Observable.from(pages)
- .filter { !it.imageUrl.isNullOrEmpty() }
- .mergeWith(fetchRemainingImageUrlsFromPageList(pages))
-fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
- .filter { it.imageUrl.isNullOrEmpty() }
- .concatMap { getImageUrl(it) }
+fun HttpSource.getImageUrl(page: Page): Observable<Page> {
+ 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<Page>): Observable<Page> {
+ return Observable.from(pages)
+ .filter { !it.imageUrl.isNullOrEmpty() }
+ .mergeWith(fetchRemainingImageUrlsFromPageList(pages))
+fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
+ .filter { it.imageUrl.isNullOrEmpty() }
+ .concatMap { getImageUrl(it) }
-import eu.kanade.tachiyomi.source.Source
-interface LoginSource : Source {
- fun isLogged(): Boolean
- fun login(username: String, password: String): Observable<Boolean>
- fun isAuthenticationSuccessful(response: Response): Boolean
+import eu.kanade.tachiyomi.source.Source
+interface LoginSource : Source {
+ fun isLogged(): Boolean
+ fun login(username: String, password: String): Observable<Boolean>
+ fun isAuthenticationSuccessful(response: Response): Boolean
@@ -1,200 +1,200 @@
-import eu.kanade.tachiyomi.util.asJsoup
-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() {
- 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 <a> tag linking to the next page, or null if
- * there's no next page.
- abstract protected fun popularMangaNextPageSelector(): String?
- override fun searchMangaParse(response: Response): MangasPage {
- val mangas = document.select(searchMangaSelector()).map { element ->
- searchMangaFromElement(element)
- val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
- abstract protected fun searchMangaSelector(): String
- * @param element an element obtained from [searchMangaSelector].
- abstract protected fun searchMangaFromElement(element: Element): SManga
- abstract protected fun searchMangaNextPageSelector(): String?
- override fun latestUpdatesParse(response: Response): MangasPage {
- val mangas = document.select(latestUpdatesSelector()).map { element ->
- latestUpdatesFromElement(element)
- val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
- abstract protected fun latestUpdatesSelector(): String
- * @param element an element obtained from [latestUpdatesSelector].
- abstract protected fun latestUpdatesFromElement(element: Element): SManga
- abstract protected fun latestUpdatesNextPageSelector(): String?
- 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
- override fun chapterListParse(response: Response): List<SChapter> {
- 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.
- override fun pageListParse(response: Response): List<Page> {
- return pageListParse(response.asJsoup())
- * Returns a page list from the given document.
- abstract protected fun pageListParse(document: Document): List<Page>
- * Parse the response from the site and returns the absolute url to the source image.
- override fun imageUrlParse(response: Response): String {
- return imageUrlParse(response.asJsoup())
- * Returns the absolute url to the source image from the document.
- abstract protected fun imageUrlParse(document: Document): String
+import eu.kanade.tachiyomi.util.asJsoup
+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() {
+ 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 <a> tag linking to the next page, or null if
+ * there's no next page.
+ abstract protected fun popularMangaNextPageSelector(): String?
+ override fun searchMangaParse(response: Response): MangasPage {
+ val mangas = document.select(searchMangaSelector()).map { element ->
+ searchMangaFromElement(element)
+ val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
+ abstract protected fun searchMangaSelector(): String
+ * @param element an element obtained from [searchMangaSelector].
+ abstract protected fun searchMangaFromElement(element: Element): SManga
+ abstract protected fun searchMangaNextPageSelector(): String?
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val mangas = document.select(latestUpdatesSelector()).map { element ->
+ latestUpdatesFromElement(element)
+ val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
+ abstract protected fun latestUpdatesSelector(): String
+ * @param element an element obtained from [latestUpdatesSelector].
+ abstract protected fun latestUpdatesFromElement(element: Element): SManga
+ abstract protected fun latestUpdatesNextPageSelector(): String?
+ 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
+ override fun chapterListParse(response: Response): List<SChapter> {
+ 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.
+ override fun pageListParse(response: Response): List<Page> {
+ return pageListParse(response.asJsoup())
+ * Returns a page list from the given document.
+ abstract protected fun pageListParse(document: Document): List<Page>
+ * Parse the response from the site and returns the absolute url to the source image.
+ override fun imageUrlParse(response: Response): String {
+ return imageUrlParse(response.asJsoup())
+ * Returns the absolute url to the source image from the document.
+ abstract protected fun imageUrlParse(document: Document): String
@@ -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<P : Presenter<*>>(val bundle: Bundle? = null) : RxController(bundle),
- PresenterFactory<P> {
- private val delegate = NucleusConductorDelegate(this)
- val presenter: P
- get() = delegate.presenter
- 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<P : Presenter<*>>(val bundle: Bundle? = null) : RxController(bundle),
+ PresenterFactory<P> {
+ private val delegate = NucleusConductorDelegate(this)
+ val presenter: P
+ get() = delegate.presenter
+ addLifecycleListener(NucleusConductorLifecycleListener(delegate))
@@ -1,61 +1,61 @@
-package eu.kanade.tachiyomi.ui.base.presenter;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import nucleus.factory.PresenterFactory;
-import nucleus.presenter.Presenter;
-public class NucleusConductorDelegate<P extends Presenter> {
- @Nullable private P presenter;
- @Nullable private Bundle bundle;
- private PresenterFactory<P> factory;
- public NucleusConductorDelegate(PresenterFactory<P> 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();
- //noinspection unchecked
- presenter.takeView(view);
- void onDropView() {
- presenter.dropView();
- void onDestroy() {
- presenter.destroy();
+package eu.kanade.tachiyomi.ui.base.presenter;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import nucleus.factory.PresenterFactory;
+import nucleus.presenter.Presenter;
+public class NucleusConductorDelegate<P extends Presenter> {
+ @Nullable private P presenter;
+ @Nullable private Bundle bundle;
+ private PresenterFactory<P> factory;
+ public NucleusConductorDelegate(PresenterFactory<P> 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();
+ //noinspection unchecked
+ presenter.takeView(view);
+ void onDropView() {
+ presenter.dropView();
+ void onDestroy() {
+ presenter.destroy();
-import android.support.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);
- public void preDestroyView(@NonNull Controller controller, @NonNull View view) {
- delegate.onDropView();
- public void preDestroy(@NonNull Controller controller) {
- delegate.onDestroy();
- public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) {
- outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState());
- public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) {
- delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY));
+import android.support.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);
+ public void preDestroyView(@NonNull Controller controller, @NonNull View view) {
+ delegate.onDropView();
+ public void preDestroy(@NonNull Controller controller) {
+ delegate.onDestroy();
+ public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) {
+ outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState());
+ public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) {
+ delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY));
@@ -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<TriStateItem.Holder, GroupItem> {
- private var head: GroupItem? = null
- override fun getHeader(): GroupItem? = head
- override fun setHeader(header: GroupItem?) {
- head = header
- if (javaClass != other?.javaClass) return false
- return filter == (other as TriStateSectionItem).filter
- return filter.hashCode()
-class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable<TextItem.Holder, GroupItem> {
- return filter == (other as TextSectionItem).filter
-class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable<CheckboxItem.Holder, GroupItem> {
- return filter == (other as CheckboxSectionItem).filter
-class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable<SelectItem.Holder, GroupItem> {
- return filter == (other as SelectSectionItem).filter
+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<TriStateItem.Holder, GroupItem> {
+ private var head: GroupItem? = null
+ override fun getHeader(): GroupItem? = head
+ override fun setHeader(header: GroupItem?) {
+ head = header
+ if (javaClass != other?.javaClass) return false
+ return filter == (other as TriStateSectionItem).filter
+ return filter.hashCode()
+class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable<TextItem.Holder, GroupItem> {
+ return filter == (other as TextSectionItem).filter
+class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable<CheckboxItem.Holder, GroupItem> {
+ return filter == (other as CheckboxSectionItem).filter
+class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable<SelectItem.Holder, GroupItem> {
+ return filter == (other as SelectSectionItem).filter
@@ -1,52 +1,52 @@
-import android.view.View
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
-import eu.kanade.tachiyomi.util.setVectorCompat
-class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem<SortGroup.Holder, ISectionable<*, *>>() {
- 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<Any?>?) {
- 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)
- return filter == (other as SortGroup).filter
- class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter)
+import android.view.View
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
+import eu.kanade.tachiyomi.util.setVectorCompat
+class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem<SortGroup.Holder, ISectionable<*, *>>() {
+ 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<Any?>?) {
+ 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)
+ return filter == (other as SortGroup).filter
+ class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter)
@@ -1,28 +1,28 @@
-package eu.kanade.tachiyomi.ui.catalogue.global_search
- * Adapter that holds the manga items from search results.
- * @param controller instance of [CatalogueSearchController].
-class CatalogueSearchCardAdapter(controller: CatalogueSearchController) :
- FlexibleAdapter<CatalogueSearchCardItem>(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
+ * Adapter that holds the manga items from search results.
+ * @param controller instance of [CatalogueSearchController].
+class CatalogueSearchCardAdapter(controller: CatalogueSearchController) :
+ FlexibleAdapter<CatalogueSearchCardItem>(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)
-import com.bumptech.glide.load.engine.DiskCacheStrategy
-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) {
- // Call onMangaClickListener when item is pressed.
- itemView.setOnClickListener {
- val item = adapter.getItem(adapterPosition)
- if (item != null) {
- adapter.mangaClickListener.onMangaClick(item.manga)
- itemView.setOnLongClickListener {
- 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))
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+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) {
+ // Call onMangaClickListener when item is pressed.
+ itemView.setOnClickListener {
+ val item = adapter.getItem(adapterPosition)
+ if (item != null) {
+ adapter.mangaClickListener.onMangaClick(item.manga)
+ itemView.setOnLongClickListener {
+ 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))
@@ -1,35 +1,35 @@
-import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
-class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem<CatalogueSearchCardHolder>() {
- 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<Any?>?) {
- holder.bind(manga)
- if (other is CatalogueSearchCardItem) {
- return manga.id == other.manga.id
- return false
- return manga.id?.toInt() ?: 0
+import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
+class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem<CatalogueSearchCardHolder>() {
+ 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<Any?>?) {
+ holder.bind(manga)
+ if (other is CatalogueSearchCardItem) {
+ return manga.id == other.manga.id
+ return false
+ return manga.id?.toInt() ?: 0
@@ -1,247 +1,247 @@
-package eu.kanade.tachiyomi.ui.download
-import android.support.v7.widget.LinearLayoutManager
-import android.view.*
-import eu.kanade.tachiyomi.data.download.DownloadService
-import eu.kanade.tachiyomi.data.download.model.Download
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
-import kotlinx.android.synthetic.main.download_controller.*
-import rx.android.schedulers.AndroidSchedulers
- * Controller that shows the currently active downloads.
- * Uses R.layout.fragment_download_queue.
-class DownloadController : NucleusController<DownloadPresenter>() {
- * Adapter containing the active downloads.
- private var adapter: DownloadAdapter? = null
- * Map of subscriptions for active downloads.
- private val progressSubscriptions by lazy { HashMap<Download, Subscription>() }
- * Whether the download queue is running or not.
- private var isRunning: Boolean = false
- 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()
- .subscribeUntilDestroy { onStatusChange(it) }
- presenter.getDownloadProgressObservable()
- .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 -> {
- 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)
- 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.
- Observable.from(download.pages)
- .map(Page::progress)
- .reduce { x, y -> x + y }
- // Keep only the latest emission to avoid backpressure.
- .onBackpressureLatest()
- .subscribe { progress ->
- // Update the view only if the progress has changed.
- if (download.totalProgress != progress) {
- download.totalProgress = progress
- // 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) {
- * 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()
- * Called from the presenter to assign the downloads for the adapter.
- * @param downloads the downloads from the queue.
- fun onNextDownloads(downloads: List<Download>) {
- 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)
- empty_view?.hide()
+package eu.kanade.tachiyomi.ui.download
+import android.support.v7.widget.LinearLayoutManager
+import android.view.*
+import eu.kanade.tachiyomi.data.download.DownloadService
+import eu.kanade.tachiyomi.data.download.model.Download
+import eu.kanade.tachiyomi.ui.base.controller.NucleusController
+import kotlinx.android.synthetic.main.download_controller.*
+import rx.android.schedulers.AndroidSchedulers
+ * Controller that shows the currently active downloads.
+ * Uses R.layout.fragment_download_queue.
+class DownloadController : NucleusController<DownloadPresenter>() {
+ * Adapter containing the active downloads.
+ private var adapter: DownloadAdapter? = null
+ * Map of subscriptions for active downloads.
+ private val progressSubscriptions by lazy { HashMap<Download, Subscription>() }
+ * Whether the download queue is running or not.
+ private var isRunning: Boolean = false
+ 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()
+ .subscribeUntilDestroy { onStatusChange(it) }
+ presenter.getDownloadProgressObservable()
+ .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 -> {
+ 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)
+ 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.
+ Observable.from(download.pages)
+ .map(Page::progress)
+ .reduce { x, y -> x + y }
+ // Keep only the latest emission to avoid backpressure.
+ .onBackpressureLatest()
+ .subscribe { progress ->
+ // Update the view only if the progress has changed.
+ if (download.totalProgress != progress) {
+ download.totalProgress = progress
+ // 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) {
+ * 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()
+ * Called from the presenter to assign the downloads for the adapter.
+ * @param downloads the downloads from the queue.
+ fun onNextDownloads(downloads: List<Download>) {
+ 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)
+ empty_view?.hide()
-package eu.kanade.tachiyomi.ui.library
-import android.app.Dialog
-import com.afollestad.materialdialogs.MaterialDialog
-import com.bluelinelabs.conductor.Controller
-import eu.kanade.tachiyomi.data.database.models.Category
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-class ChangeMangaCategoriesDialog<T>(bundle: Bundle? = null) :
- DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener {
- private var mangas = emptyList<Manga>()
- private var categories = emptyList<Category>()
- private var preselected = emptyArray<Int>()
- constructor(target: T, mangas: List<Manga>, categories: List<Category>,
- preselected: Array<Int>) : 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)
- .positiveText(android.R.string.ok)
- .negativeText(android.R.string.cancel)
- interface Listener {
- fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>)
+package eu.kanade.tachiyomi.ui.library
+import android.app.Dialog
+import com.afollestad.materialdialogs.MaterialDialog
+import com.bluelinelabs.conductor.Controller
+import eu.kanade.tachiyomi.data.database.models.Category
+import eu.kanade.tachiyomi.ui.base.controller.DialogController
+class ChangeMangaCategoriesDialog<T>(bundle: Bundle? = null) :
+ DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener {
+ private var mangas = emptyList<Manga>()
+ private var categories = emptyList<Category>()
+ private var preselected = emptyArray<Int>()
+ constructor(target: T, mangas: List<Manga>, categories: List<Category>,
+ preselected: Array<Int>) : 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)
+ .positiveText(android.R.string.ok)
+ .negativeText(android.R.string.cancel)
+ interface Listener {
+ fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>)
@@ -1,43 +1,43 @@
-import eu.kanade.tachiyomi.widget.DialogCheckboxView
-class DeleteLibraryMangasDialog<T>(bundle: Bundle? = null) :
- DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener {
- constructor(target: T, mangas: List<Manga>) : this() {
- val view = DialogCheckboxView(activity!!).apply {
- setDescription(R.string.confirm_delete_manga)
- setOptionDescription(R.string.also_delete_chapters)
- .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)
- fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean)
+import eu.kanade.tachiyomi.widget.DialogCheckboxView
+class DeleteLibraryMangasDialog<T>(bundle: Bundle? = null) :
+ DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener {
+ constructor(target: T, mangas: List<Manga>) : this() {
+ val view = DialogCheckboxView(activity!!).apply {
+ setDescription(R.string.confirm_delete_manga)
+ setOptionDescription(R.string.also_delete_chapters)
+ .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)
+ fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean)
@@ -1,103 +1,103 @@
-import android.view.ViewGroup
-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<Category> = emptyList()
- // This setter helps to not refresh the adapter if the reference to the list doesn't change.
- if (field !== value) {
- notifyDataSetChanged()
- private var boundViews = arrayListOf<View>()
- * 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.
- 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()
+import android.view.ViewGroup
+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<Category> = emptyList()
+ // This setter helps to not refresh the adapter if the reference to the list doesn't change.
+ if (field !== value) {
+ notifyDataSetChanged()
+ private var boundViews = arrayListOf<View>()
+ * 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.
+ 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()
- * Adapter storing a list of manga in a certain category.
- * @param view the fragment containing this adapter.
-class LibraryCategoryAdapter(view: LibraryCategoryView) :
- FlexibleAdapter<LibraryItem>(null, view, true) {
- * The list of manga in this category.
- private var mangas: List<LibraryItem> = emptyList()
- * Sets a list of manga in the adapter.
- * @param list the list to set.
- fun setItems(list: List<LibraryItem>) {
- // 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() {
- updateDataSet(mangas.filter { it.filter(searchText) })
+ * Adapter storing a list of manga in a certain category.
+ * @param view the fragment containing this adapter.
+class LibraryCategoryAdapter(view: LibraryCategoryView) :
+ FlexibleAdapter<LibraryItem>(null, view, true) {
+ * The list of manga in this category.
+ private var mangas: List<LibraryItem> = emptyList()
+ * Sets a list of manga in the adapter.
+ * @param list the list to set.
+ fun setItems(list: List<LibraryItem>) {
+ // 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() {
+ updateDataSet(mangas.filter { it.filter(searchText) })
-import android.support.v7.widget.RecyclerView
-import android.util.AttributeSet
-import android.widget.FrameLayout
-import eu.davidea.flexibleadapter.SelectableAdapter
-import eu.kanade.tachiyomi.data.library.LibraryUpdateService
-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
- * 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)
- (swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
- spanCount = controller.mangaPerRow
- adapter = LibraryCategoryAdapter(this)
- 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
- SelectableAdapter.Mode.SINGLE
- subscriptions += controller.searchRelay
- .doOnNext { adapter.searchText = 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 -> {
- if (controller.selectedMangas.isEmpty()) {
- adapter.mode = SelectableAdapter.Mode.SINGLE
- is LibrarySelectionEvent.Cleared -> {
- * Toggles the selection for the given manga and updates the view if needed.
- * @param manga the manga to toggle.
- private fun findAndToggleSelection(manga: Manga) {
- if (position != -1) {
- * 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(position: Int): Boolean {
- // If the action mode is created and the position is valid, toggle the selection.
- val item = adapter.getItem(position) ?: return false
- toggleSelection(position)
- openManga(item.manga)
- * Called when a manga is long clicked.
- override fun onItemLongClick(position: Int) {
- controller.createActionModeIfNeeded()
- * 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()
+import android.support.v7.widget.RecyclerView
+import android.util.AttributeSet
+import android.widget.FrameLayout
+import eu.davidea.flexibleadapter.SelectableAdapter
+import eu.kanade.tachiyomi.data.library.LibraryUpdateService
+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
+ * 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)
+ (swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
+ spanCount = controller.mangaPerRow
+ adapter = LibraryCategoryAdapter(this)
+ 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
+ SelectableAdapter.Mode.SINGLE
+ subscriptions += controller.searchRelay
+ .doOnNext { adapter.searchText = 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 -> {
+ if (controller.selectedMangas.isEmpty()) {
+ adapter.mode = SelectableAdapter.Mode.SINGLE
+ is LibrarySelectionEvent.Cleared -> {
+ * Toggles the selection for the given manga and updates the view if needed.
+ * @param manga the manga to toggle.
+ private fun findAndToggleSelection(manga: Manga) {
+ if (position != -1) {
+ * 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(position: Int): Boolean {
+ // If the action mode is created and the position is valid, toggle the selection.
+ val item = adapter.getItem(position) ?: return false
+ toggleSelection(position)
+ openManga(item.manga)
+ * Called when a manga is long clicked.
+ override fun onItemLongClick(position: Int) {
+ controller.createActionModeIfNeeded()
+ * 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()
@@ -1,523 +1,523 @@
-import android.app.Activity
-import android.content.Intent
-import android.content.res.Configuration
-import android.support.design.widget.TabLayout
-import android.support.v4.graphics.drawable.DrawableCompat
-import android.support.v4.widget.DrawerLayout
-import android.support.v7.app.AppCompatActivity
-import android.support.v7.view.ActionMode
-import android.support.v7.widget.SearchView
-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.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 kotlinx.android.synthetic.main.library_controller.*
-import kotlinx.android.synthetic.main.main_activity.*
-import timber.log.Timber
-import uy.kohesive.injekt.Injekt
-import uy.kohesive.injekt.api.get
-class LibraryController(
- bundle: Bundle? = null,
- private val preferences: PreferencesHelper = Injekt.get()
-) : NucleusController<LibraryPresenter>(bundle),
- TabbedController,
- SecondaryDrawerController,
- ActionMode.Callback,
- ChangeMangaCategoriesDialog.Listener,
- DeleteLibraryMangasDialog.Listener {
- * Position of the active category.
- var activeCategory: Int = preferences.lastUsedCategory().getOrDefault()
- * Action mode for selections.
- private var actionMode: ActionMode? = null
- * Library search query.
- private var query = ""
- * Currently selected mangas.
- val selectedMangas = mutableSetOf<Manga>()
- private var selectedCoverManga: Manga? = null
- * Relay to notify the UI of selection updates.
- val selectionRelay: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
- * Relay to notify search query changes.
- val searchRelay: BehaviorRelay<String> = BehaviorRelay.create()
- * Relay to notify the library's viewpager for updates.
- val libraryMangaRelay: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
- * Number of manga per row in grid mode.
- var mangaPerRow = 0
- * 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<Boolean> = BehaviorRelay.create(false)
- private var tabsVisibilitySubscription: Subscription? = null
- private var searchViewSubscription: Subscription? = null
- retainViewMode = RetainViewMode.RETAIN_DETACH
- return resources?.getString(R.string.label_library)
- override fun createPresenter(): LibraryPresenter {
- return LibraryPresenter()
- return inflater.inflate(R.layout.library_controller, container, false)
- adapter = LibraryAdapter(this)
- library_pager.adapter = adapter
- library_pager.pageSelections().skip(1).subscribeUntilDestroy {
- preferences.lastUsedCategory().set(it)
- activeCategory = it
- getColumnsPreferenceForCurrentOrientation().asObservable()
- .doOnNext { mangaPerRow = it }
- // 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()
- adapter?.onDestroy()
- actionMode = null
- tabsVisibilitySubscription?.unsubscribe()
- tabsVisibilitySubscription = null
- 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()
- override fun cleanupSecondaryDrawer(drawer: DrawerLayout) {
- navView = null
- override fun configureTabs(tabs: TabLayout) {
- with(tabs) {
- tabGravity = TabLayout.GRAVITY_CENTER
- tabMode = TabLayout.MODE_SCROLLABLE
- tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible ->
- val tabAnimator = (activity as? MainActivity)?.tabAnimator
- if (visible) {
- tabAnimator?.expand()
- tabAnimator?.collapse()
- override fun cleanupTabs(tabs: TabLayout) {
- fun onNextLibraryUpdate(categories: List<Category>, mangaMap: Map<Int, List<LibraryItem>>) {
- val view = view ?: return
- val adapter = adapter ?: return
- // Show empty view if needed
- if (mangaMap.isNotEmpty()) {
- empty_view.hide()
- 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
- 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<Int> {
- return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT)
- preferences.portraitColumns()
- preferences.landscapeColumns()
- * Called when a filter is changed.
- private fun onFilterChanged() {
- presenter.requestFilterUpdate()
- 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 position = library_pager.currentItem
- adapter.recycle = false
- 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()
- 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()
- 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)
- R.id.action_filter -> {
- navView?.let { activity?.drawer?.openDrawer(Gravity.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())
- * 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)
- 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()
- mode.title = resources?.getString(R.string.label_selected, count)
- menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1
- override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
- R.id.action_edit_cover -> {
- changeSelectedCover()
- R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
- R.id.action_delete -> showDeleteMangaDialog()
- else -> return false
- override fun onDestroyActionMode(mode: ActionMode?) {
- // Clear all the manga selections and notify child views.
- selectedMangas.clear()
- selectionRelay.call(LibrarySelectionEvent.Cleared())
- 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))
- 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<Manga>, categories: List<Category>) {
- presenter.moveMangasToCategories(categories, mangas)
- override fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
- presenter.removeMangaFromLibrary(mangas, deleteChapters)
- * 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)
- 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
- // 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
- activity.toast(R.string.notification_cover_update_failed)
- } catch (error: IOException) {
- Timber.e(error)
- selectedCoverManga = null
- private companion object {
- * Key to change the cover of a manga in [onActivityResult].
- const val REQUEST_IMAGE_OPEN = 101
+import android.app.Activity
+import android.content.Intent
+import android.content.res.Configuration
+import android.support.design.widget.TabLayout
+import android.support.v4.graphics.drawable.DrawableCompat
+import android.support.v4.widget.DrawerLayout
+import android.support.v7.app.AppCompatActivity
+import android.support.v7.view.ActionMode
+import android.support.v7.widget.SearchView
+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.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 kotlinx.android.synthetic.main.library_controller.*
+import kotlinx.android.synthetic.main.main_activity.*
+import timber.log.Timber
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+class LibraryController(
+ bundle: Bundle? = null,
+ private val preferences: PreferencesHelper = Injekt.get()
+) : NucleusController<LibraryPresenter>(bundle),
+ TabbedController,
+ SecondaryDrawerController,
+ ActionMode.Callback,
+ ChangeMangaCategoriesDialog.Listener,
+ DeleteLibraryMangasDialog.Listener {
+ * Position of the active category.
+ var activeCategory: Int = preferences.lastUsedCategory().getOrDefault()
+ * Action mode for selections.
+ private var actionMode: ActionMode? = null
+ * Library search query.
+ private var query = ""
+ * Currently selected mangas.
+ val selectedMangas = mutableSetOf<Manga>()
+ private var selectedCoverManga: Manga? = null
+ * Relay to notify the UI of selection updates.
+ val selectionRelay: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
+ * Relay to notify search query changes.
+ val searchRelay: BehaviorRelay<String> = BehaviorRelay.create()
+ * Relay to notify the library's viewpager for updates.
+ val libraryMangaRelay: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
+ * Number of manga per row in grid mode.
+ var mangaPerRow = 0
+ * 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<Boolean> = BehaviorRelay.create(false)
+ private var tabsVisibilitySubscription: Subscription? = null
+ private var searchViewSubscription: Subscription? = null
+ retainViewMode = RetainViewMode.RETAIN_DETACH
+ return resources?.getString(R.string.label_library)
+ override fun createPresenter(): LibraryPresenter {
+ return LibraryPresenter()
+ return inflater.inflate(R.layout.library_controller, container, false)
+ adapter = LibraryAdapter(this)
+ library_pager.adapter = adapter
+ library_pager.pageSelections().skip(1).subscribeUntilDestroy {
+ preferences.lastUsedCategory().set(it)
+ activeCategory = it
+ getColumnsPreferenceForCurrentOrientation().asObservable()
+ .doOnNext { mangaPerRow = it }
+ // 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()
+ adapter?.onDestroy()
+ actionMode = null
+ tabsVisibilitySubscription?.unsubscribe()
+ tabsVisibilitySubscription = null
+ 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()
+ override fun cleanupSecondaryDrawer(drawer: DrawerLayout) {
+ navView = null
+ override fun configureTabs(tabs: TabLayout) {
+ with(tabs) {
+ tabGravity = TabLayout.GRAVITY_CENTER
+ tabMode = TabLayout.MODE_SCROLLABLE
+ tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible ->
+ val tabAnimator = (activity as? MainActivity)?.tabAnimator
+ if (visible) {
+ tabAnimator?.expand()
+ tabAnimator?.collapse()
+ override fun cleanupTabs(tabs: TabLayout) {
+ fun onNextLibraryUpdate(categories: List<Category>, mangaMap: Map<Int, List<LibraryItem>>) {
+ val view = view ?: return
+ val adapter = adapter ?: return
+ // Show empty view if needed
+ if (mangaMap.isNotEmpty()) {
+ empty_view.hide()
+ 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
+ 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<Int> {
+ return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT)
+ preferences.portraitColumns()
+ preferences.landscapeColumns()
+ * Called when a filter is changed.
+ private fun onFilterChanged() {
+ presenter.requestFilterUpdate()
+ 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 position = library_pager.currentItem
+ adapter.recycle = false
+ 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()
+ 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()
+ 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)
+ R.id.action_filter -> {
+ navView?.let { activity?.drawer?.openDrawer(Gravity.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())
+ * 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)
+ 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()
+ mode.title = resources?.getString(R.string.label_selected, count)
+ menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ R.id.action_edit_cover -> {
+ changeSelectedCover()
+ R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
+ R.id.action_delete -> showDeleteMangaDialog()
+ else -> return false
+ override fun onDestroyActionMode(mode: ActionMode?) {
+ // Clear all the manga selections and notify child views.
+ selectedMangas.clear()
+ selectionRelay.call(LibrarySelectionEvent.Cleared())
+ 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))
+ 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<Manga>, categories: List<Category>) {
+ presenter.moveMangasToCategories(categories, mangas)
+ override fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
+ presenter.removeMangaFromLibrary(mangas, deleteChapters)
+ * 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)
+ 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
+ // 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
+ activity.toast(R.string.notification_cover_update_failed)
+ } catch (error: IOException) {
+ Timber.e(error)
+ selectedCoverManga = null
+ private companion object {
+ * Key to change the cover of a manga in [onActivityResult].
+ const val REQUEST_IMAGE_OPEN = 101
@@ -1,57 +1,57 @@
-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)
- .into(thumbnail)
+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)
+ .into(thumbnail)
@@ -1,27 +1,27 @@
- * Generic class used to hold the displayed data of a manga in the library.
- * @param listener a listener to react to the single tap and long tap events.
-abstract class LibraryHolder(
- view: View,
- adapter: FlexibleAdapter<*>
-) : BaseFlexibleViewHolder(view, adapter) {
- abstract fun onSetValues(item: LibraryItem)
+ * Generic class used to hold the displayed data of a manga in the library.
+ * @param listener a listener to react to the single tap and long tap events.
+abstract class LibraryHolder(
+ view: View,
+ adapter: FlexibleAdapter<*>
+) : BaseFlexibleViewHolder(view, adapter) {
+ abstract fun onSetValues(item: LibraryItem)
@@ -1,73 +1,73 @@
-import android.view.Gravity
-import android.view.ViewGroup.LayoutParams.MATCH_PARENT
-import eu.davidea.flexibleadapter.items.IFilterable
-import eu.kanade.tachiyomi.data.database.models.LibraryManga
-import kotlinx.android.synthetic.main.catalogue_grid_item.view.*
-class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference<Boolean>) :
- AbstractFlexibleItem<LibraryHolder>(), IFilterable {
- var downloadCount = -1
- return if (libraryAsList.getOrDefault())
- R.layout.catalogue_list_item
- 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)
- LibraryListHolder(view, adapter)
- override fun bindViewHolder(adapter: FlexibleAdapter<*>,
- holder: LibraryHolder,
- position: Int,
- payloads: List<Any?>?) {
- 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)
- if (other is LibraryItem) {
- return manga.id!!.hashCode()
+import android.view.Gravity
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import eu.davidea.flexibleadapter.items.IFilterable
+import eu.kanade.tachiyomi.data.database.models.LibraryManga
+import kotlinx.android.synthetic.main.catalogue_grid_item.view.*
+class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference<Boolean>) :
+ AbstractFlexibleItem<LibraryHolder>(), IFilterable {
+ var downloadCount = -1
+ return if (libraryAsList.getOrDefault())
+ R.layout.catalogue_list_item
+ 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)
+ LibraryListHolder(view, adapter)
+ override fun bindViewHolder(adapter: FlexibleAdapter<*>,
+ holder: LibraryHolder,
+ position: Int,
+ payloads: List<Any?>?) {
+ 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)
+ if (other is LibraryItem) {
+ return manga.id!!.hashCode()
@@ -1,65 +1,65 @@
-import kotlinx.android.synthetic.main.catalogue_list_item.*
- * All the elements from the layout file "item_library_list" are available in this class.
-class LibraryListHolder(
- 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)
- GlideApp.with(itemView.context).clear(thumbnail)
- .circleCrop()
- .dontAnimate()
+import kotlinx.android.synthetic.main.catalogue_list_item.*
+ * All the elements from the layout file "item_library_list" are available in this class.
+class LibraryListHolder(
+ 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)
+ GlideApp.with(itemView.context).clear(thumbnail)
+ .circleCrop()
+ .dontAnimate()
@@ -1,217 +1,217 @@
-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
- * 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.
- * 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 = {}
- 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<Item>) : 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()
- 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)
- 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
- 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)
- downloadBadge.checked = preferences.downloadBadge().getOrDefault()
- preferences.downloadBadge().set((item.checked))
- * 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)
- val asList = preferences.libraryAsList().getOrDefault()
- grid.checked = !asList
- list.checked = asList
- 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)
+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
+ * 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.
+ * 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 = {}
+ 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<Item>) : 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()
+ 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)
+ 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
+ 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)
+ downloadBadge.checked = preferences.downloadBadge().getOrDefault()
+ preferences.downloadBadge().set((item.checked))
+ * 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)
+ val asList = preferences.libraryAsList().getOrDefault()
+ grid.checked = !asList
+ list.checked = asList
+ 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)
@@ -1,371 +1,371 @@
-import eu.kanade.tachiyomi.data.cache.CoverCache
-import eu.kanade.tachiyomi.data.database.DatabaseHelper
-import eu.kanade.tachiyomi.data.database.models.MangaCategory
-import eu.kanade.tachiyomi.data.download.DownloadManager
-import eu.kanade.tachiyomi.source.SourceManager
-import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
-import eu.kanade.tachiyomi.util.combineLatest
-import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
-import rx.schedulers.Schedulers
-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<Category>, val mangaMap: LibraryMap)
- * Typealias for the library manga, using the category as keys, and list of manga as values.
-private typealias LibraryMap = Map<Int, List<LibraryItem>>
- * 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<LibraryController>() {
- private val context = preferences.context
- * Categories of the library.
- * 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)) })
- .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) {
- // 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
- 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
- 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 {
- 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)
- 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<Library> {
- return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
- { dbCategories, libraryManga ->
- val categories = if (libraryManga.containsKey(0))
- arrayListOf(Category.createDefault()) + dbCategories
- dbCategories
- Library(categories, libraryManga)
- * Get the categories from the database.
- * @return an observable of the categories.
- private fun getCategoriesObservable(): Observable<List<Category>> {
- 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<LibraryMap> {
- 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<Manga>): Collection<Category> {
- if (mangas.isEmpty()) return emptyList()
- return mangas.toSet()
- .map { db.getCategoriesForManga(it).executeAsBlocking() }
- .reduce { set1: Iterable<Category>, 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<Manga>, 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)
- * 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<Category>, mangas: List<Manga>) {
- val mc = ArrayList<MangaCategory>()
- 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
- fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
- if (manga.source == LocalSource.ID) {
- LocalSource.updateCover(context, manga, inputStream)
- if (manga.thumbnail_url != null && manga.favorite) {
- coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
+import eu.kanade.tachiyomi.data.cache.CoverCache
+import eu.kanade.tachiyomi.data.database.DatabaseHelper
+import eu.kanade.tachiyomi.data.database.models.MangaCategory
+import eu.kanade.tachiyomi.data.download.DownloadManager
+import eu.kanade.tachiyomi.source.SourceManager
+import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
+import eu.kanade.tachiyomi.util.combineLatest
+import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
+import rx.schedulers.Schedulers
+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<Category>, val mangaMap: LibraryMap)
+ * Typealias for the library manga, using the category as keys, and list of manga as values.
+private typealias LibraryMap = Map<Int, List<LibraryItem>>
+ * 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<LibraryController>() {
+ private val context = preferences.context
+ * Categories of the library.
+ * 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)) })
+ .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) {
+ // 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
+ 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
+ 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 {
+ 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)
+ 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<Library> {
+ return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
+ { dbCategories, libraryManga ->
+ val categories = if (libraryManga.containsKey(0))
+ arrayListOf(Category.createDefault()) + dbCategories
+ dbCategories
+ Library(categories, libraryManga)
+ * Get the categories from the database.
+ * @return an observable of the categories.
+ private fun getCategoriesObservable(): Observable<List<Category>> {
+ 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<LibraryMap> {
+ 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<Manga>): Collection<Category> {
+ if (mangas.isEmpty()) return emptyList()
+ return mangas.toSet()
+ .map { db.getCategoriesForManga(it).executeAsBlocking() }
+ .reduce { set1: Iterable<Category>, 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<Manga>, 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)
+ * 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<Category>, mangas: List<Manga>) {
+ val mc = ArrayList<MangaCategory>()
+ 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
+ fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
+ if (manga.source == LocalSource.ID) {
+ LocalSource.updateCover(context, manga, inputStream)
+ if (manga.thumbnail_url != null && manga.favorite) {
+ coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
-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
+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.main
-import eu.kanade.tachiyomi.BuildConfig
-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)
- 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 eu.kanade.tachiyomi.BuildConfig
+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)
+ 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
@@ -1,282 +1,282 @@
-import android.animation.ObjectAnimator
-import android.app.SearchManager
-import android.support.v4.view.GravityCompat
-import android.support.v7.graphics.drawable.DrawerArrowDrawable
-import com.bluelinelabs.conductor.*
-import eu.kanade.tachiyomi.Migrations
-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.recent_updates.RecentChaptersController
-import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController
-import eu.kanade.tachiyomi.ui.setting.SettingsMainController
-import eu.kanade.tachiyomi.util.openInBrowser
-class MainActivity : BaseActivity() {
- private lateinit var router: Router
- 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()
- 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)
- 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)
- 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,
- syncActivityViewWithController(router.backstack.lastOrNull()?.controller())
- if (savedInstanceState == null) {
- // Show changelog if needed
- if (Migrations.upgrade(preferences)) {
- ChangelogDialogController().showDialog(router)
- override fun onNewIntent(intent: 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)
- router.pushController(CatalogueSearchController(query, filter).withFadeTransaction())
- 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) {
- } 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) {
- val showHamburger = router.backstackSize == 1
- if (showHamburger) {
- drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
- 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)
- 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()
- appbar.enableElevation()
- // 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/"
+import android.animation.ObjectAnimator
+import android.app.SearchManager
+import android.support.v4.view.GravityCompat
+import android.support.v7.graphics.drawable.DrawerArrowDrawable
+import com.bluelinelabs.conductor.*
+import eu.kanade.tachiyomi.Migrations
+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.recent_updates.RecentChaptersController
+import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController
+import eu.kanade.tachiyomi.ui.setting.SettingsMainController
+import eu.kanade.tachiyomi.util.openInBrowser
+class MainActivity : BaseActivity() {
+ private lateinit var router: Router
+ 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()
+ 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)
+ 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)
+ 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,
+ syncActivityViewWithController(router.backstack.lastOrNull()?.controller())
+ if (savedInstanceState == null) {
+ // Show changelog if needed
+ if (Migrations.upgrade(preferences)) {
+ ChangelogDialogController().showDialog(router)
+ override fun onNewIntent(intent: 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)
+ router.pushController(CatalogueSearchController(query, filter).withFadeTransaction())
+ 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) {
+ } 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) {
+ val showHamburger = router.backstackSize == 1
+ if (showHamburger) {
+ drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
+ 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)
+ 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()
+ appbar.enableElevation()
+ // 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/"
@@ -1,193 +1,193 @@
-package eu.kanade.tachiyomi.ui.manga
-import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
-import android.support.graphics.drawable.VectorDrawableCompat
-import android.view.LayoutInflater
-import android.widget.LinearLayout
-import android.widget.TextView
-import com.bluelinelabs.conductor.Router
-import com.bluelinelabs.conductor.RouterTransaction
-import com.bluelinelabs.conductor.support.RouterPagerAdapter
-import eu.kanade.tachiyomi.data.track.TrackManager
-import eu.kanade.tachiyomi.ui.base.controller.RxController
-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 kotlinx.android.synthetic.main.manga_controller.*
-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<SourceManager>().getOrStub(manga.source)
- constructor(mangaId: Long) : this(
- Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking())
- @Suppress("unused")
- constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
- var manga: Manga? = null
- var source: Source? = null
- private var adapter: MangaDetailAdapter? = null
- val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false)
- val lastUpdateRelay: BehaviorRelay<Date> = BehaviorRelay.create()
- val chapterCountRelay: BehaviorRelay<Float> = BehaviorRelay.create()
- val mangaFavoriteRelay: PublishRelay<Boolean> = PublishRelay.create()
- private val trackingIconRelay: BehaviorRelay<Boolean> = BehaviorRelay.create()
- private var trackingIconSubscription: Subscription? = null
- return manga?.title
- return inflater.inflate(R.layout.manga_controller, container, false)
- 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
- 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)
- tabGravity = TabLayout.GRAVITY_FILL
- tabMode = TabLayout.MODE_FIXED
- 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<TrackManager>().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) }
- return tabCount
- override fun configureRouter(router: Router, position: Int) {
- val controller = when (position) {
- INFO_CONTROLLER -> MangaInfoController()
- CHAPTERS_CONTROLLER -> ChaptersController()
- TRACK_CONTROLLER -> TrackController()
- else -> error("Wrong position $position")
- router.setRoot(RouterTransaction.with(controller))
- return tabTitles[position]
- 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.support.graphics.drawable.VectorDrawableCompat
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.bluelinelabs.conductor.Router
+import com.bluelinelabs.conductor.RouterTransaction
+import com.bluelinelabs.conductor.support.RouterPagerAdapter
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.ui.base.controller.RxController
+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 kotlinx.android.synthetic.main.manga_controller.*
+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<SourceManager>().getOrStub(manga.source)
+ constructor(mangaId: Long) : this(
+ Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking())
+ @Suppress("unused")
+ constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
+ var manga: Manga? = null
+ var source: Source? = null
+ private var adapter: MangaDetailAdapter? = null
+ val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false)
+ val lastUpdateRelay: BehaviorRelay<Date> = BehaviorRelay.create()
+ val chapterCountRelay: BehaviorRelay<Float> = BehaviorRelay.create()
+ val mangaFavoriteRelay: PublishRelay<Boolean> = PublishRelay.create()
+ private val trackingIconRelay: BehaviorRelay<Boolean> = BehaviorRelay.create()
+ private var trackingIconSubscription: Subscription? = null
+ return manga?.title
+ return inflater.inflate(R.layout.manga_controller, container, false)
+ 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
+ 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)
+ tabGravity = TabLayout.GRAVITY_FILL
+ tabMode = TabLayout.MODE_FIXED
+ 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<TrackManager>().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) }
+ return tabCount
+ override fun configureRouter(router: Router, position: Int) {
+ val controller = when (position) {
+ INFO_CONTROLLER -> MangaInfoController()
+ CHAPTERS_CONTROLLER -> ChaptersController()
+ TRACK_CONTROLLER -> TrackController()
+ else -> error("Wrong position $position")
+ router.setRoot(RouterTransaction.with(controller))
+ return tabTitles[position]
+ 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 }
@@ -1,122 +1,122 @@
-package eu.kanade.tachiyomi.ui.manga.chapter
-import android.widget.PopupMenu
-import eu.kanade.tachiyomi.util.getResourceColor
-import eu.kanade.tachiyomi.util.gone
-import kotlinx.android.synthetic.main.chapters_item.*
-class ChapterHolder(
- private val adapter: ChaptersAdapter
- // 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)
- 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()
- 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)
- ""
- notifyStatus(item.status)
- fun notifyStatus(status: Int) = with(download_text) {
- 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)
- // 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)
- // Finally show the PopupMenu
- popup.show()
+package eu.kanade.tachiyomi.ui.manga.chapter
+import android.widget.PopupMenu
+import eu.kanade.tachiyomi.util.getResourceColor
+import eu.kanade.tachiyomi.util.gone
+import kotlinx.android.synthetic.main.chapters_item.*
+class ChapterHolder(
+ private val adapter: ChaptersAdapter
+ // 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)
+ 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()
+ 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)
+ ""
+ notifyStatus(item.status)
+ fun notifyStatus(status: Int) = with(download_text) {
+ 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)
+ // 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)
+ // Finally show the PopupMenu
+ popup.show()
@@ -1,53 +1,53 @@
-import eu.kanade.tachiyomi.data.database.models.Chapter
-class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(),
- Chapter by chapter {
- private var _status: Int = 0
- get() = download?.status ?: _status
- set(value) { _status = value }
- @Transient var download: Download? = null
- val isDownloaded: Boolean
- get() = status == Download.DOWNLOADED
- return R.layout.chapters_item
- override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): ChapterHolder {
- return ChapterHolder(view, adapter as ChaptersAdapter)
- holder: ChapterHolder,
- holder.bind(this, manga)
- if (other is ChapterItem) {
- return chapter.id!! == other.chapter.id!!
- return chapter.id!!.hashCode()
+import eu.kanade.tachiyomi.data.database.models.Chapter
+class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(),
+ Chapter by chapter {
+ private var _status: Int = 0
+ get() = download?.status ?: _status
+ set(value) { _status = value }
+ @Transient var download: Download? = null
+ val isDownloaded: Boolean
+ get() = status == Download.DOWNLOADED
+ return R.layout.chapters_item
+ override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): ChapterHolder {
+ return ChapterHolder(view, adapter as ChaptersAdapter)
+ holder: ChapterHolder,
+ holder.bind(this, manga)
+ if (other is ChapterItem) {
+ return chapter.id!! == other.chapter.id!!
+ return chapter.id!!.hashCode()
@@ -1,45 +1,45 @@
-import android.view.MenuItem
-import java.text.DateFormat
-import java.text.DecimalFormatSymbols
-class ChaptersAdapter(
- controller: ChaptersController,
- context: Context
-) : FlexibleAdapter<ChapterItem>(null, controller, true) {
- var items: List<ChapterItem> = 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<ChapterItem>?) {
- this.items = items ?: emptyList()
- super.updateDataSet(items)
- fun indexOf(item: ChapterItem): Int {
- return items.indexOf(item)
- interface OnMenuItemClickListener {
- fun onMenuItemClick(position: Int, item: MenuItem)
+import android.view.MenuItem
+import java.text.DateFormat
+import java.text.DecimalFormatSymbols
+class ChaptersAdapter(
+ controller: ChaptersController,
+ context: Context
+) : FlexibleAdapter<ChapterItem>(null, controller, true) {
+ var items: List<ChapterItem> = 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<ChapterItem>?) {
+ this.items = items ?: emptyList()
+ super.updateDataSet(items)
+ fun indexOf(item: ChapterItem): Int {
+ return items.indexOf(item)
+ interface OnMenuItemClickListener {
+ fun onMenuItemClick(position: Int, item: MenuItem)
@@ -1,486 +1,486 @@
-import android.animation.Animator
-import android.animation.AnimatorListenerAdapter
-import android.support.design.widget.Snackbar
-import android.support.v7.widget.DividerItemDecoration
-import com.jakewharton.rxbinding.support.v4.widget.refreshes
-import com.jakewharton.rxbinding.view.clicks
-import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
-import eu.kanade.tachiyomi.ui.reader.ReaderActivity
-import eu.kanade.tachiyomi.util.getCoordinates
-import eu.kanade.tachiyomi.util.snack
-import kotlinx.android.synthetic.main.chapters_controller.*
-class ChaptersController : NucleusController<ChaptersPresenter>(),
- 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.
- * Selected items. Used to restore selections after a rotation.
- private val selectedItems = mutableSetOf<ChapterItem>()
- setOptionsMenuHidden(true)
- override fun createPresenter(): ChaptersPresenter {
- val ctrl = parentController as MangaController
- return ChaptersPresenter(ctrl.manga!!, ctrl.source!!,
- ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay)
- return inflater.inflate(R.layout.chapters_controller, container, false)
- // Init RecyclerView and adapter
- adapter = ChaptersAdapter(this, view.context)
- recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
- adapter?.fastScroller = fast_scroller
- swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() }
- fab.clicks().subscribeUntilDestroy {
- val item = presenter.getNextUnreadChapter()
- // 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)
- view.context.toast(R.string.no_next_chapter)
- 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
- reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920)
- super.onActivityResumed(activity)
- inflater.inflate(R.menu.chapters, 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
- 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)
- R.id.action_filter_read -> {
- presenter.setReadFilter(item.isChecked)
- R.id.action_filter_downloaded -> {
- presenter.setDownloadedFilter(item.isChecked)
- R.id.action_filter_bookmarked -> {
- presenter.setBookmarkedFilter(item.isChecked)
- R.id.action_filter_empty -> {
- presenter.removeFilters()
- R.id.action_sort -> presenter.revertSortOrder()
- fun onNextChapters(chapters: List<ChapterItem>) {
- // 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()
- adapter.updateDataSet(chapters)
- if (selectedItems.isNotEmpty()) {
- adapter.clearSelection() // we need to start from a clean state, index may have changed
- selectedItems.forEach { item ->
- val position = adapter.indexOf(item)
- 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) {
- 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 intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
- if (hasAnimation) {
- intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
- startActivity(intent)
- val adapter = adapter ?: return false
- if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
- // SELECTIONS & ACTION MODE
- if (adapter.isSelected(position)) {
- selectedItems.add(item)
- selectedItems.remove(item)
- private fun getSelectedChapters(): List<ChapterItem> {
- val adapter = adapter ?: return emptyList()
- return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
- private fun createActionModeIfNeeded() {
- actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
- private fun destroyActionModeIfNeeded() {
- mode.menuInflater.inflate(R.menu.chapter_selection, menu)
- adapter?.mode = SelectableAdapter.Mode.MULTI
- @SuppressLint("StringFormatInvalid")
- val count = adapter?.selectedItemCount ?: 0
- 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()
- override fun onDestroyActionMode(mode: ActionMode) {
- adapter?.mode = SelectableAdapter.Mode.SINGLE
- adapter?.clearSelection()
- selectedItems.clear()
- override fun onMenuItemClick(position: Int, item: MenuItem) {
- val chapter = adapter?.getItem(position) ?: return
- val chapters = listOf(chapter)
- 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() {
- adapter.selectAll()
- selectedItems.addAll(adapter.items)
- private fun markAsRead(chapters: List<ChapterItem>) {
- presenter.markChaptersRead(chapters, true)
- if (presenter.preferences.removeAfterMarkedAsRead()) {
- deleteChapters(chapters)
- private fun markAsUnread(chapters: List<ChapterItem>) {
- presenter.markChaptersRead(chapters, false)
- private fun downloadChapters(chapters: List<ChapterItem>) {
- val view = view
- 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 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<ChapterItem>, bookmarked: Boolean) {
- presenter.bookmarkChapters(chapters, bookmarked)
- fun deleteChapters(chapters: List<ChapterItem>) {
- if (chapters.isEmpty()) return
- DeletingChaptersDialog().showDialog(router)
- presenter.deleteChapters(chapters)
- fun onChaptersDeleted() {
- dismissDeletingDialog()
- adapter?.notifyDataSetChanged()
- fun onChaptersDeletedError(error: Throwable) {
- 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)
- 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()
- 4 -> presenter.chapters.filter { !it.read }
- 5 -> presenter.chapters
- else -> emptyList()
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.support.design.widget.Snackbar
+import android.support.v7.widget.DividerItemDecoration
+import com.jakewharton.rxbinding.support.v4.widget.refreshes
+import com.jakewharton.rxbinding.view.clicks
+import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
+import eu.kanade.tachiyomi.ui.reader.ReaderActivity
+import eu.kanade.tachiyomi.util.getCoordinates
+import eu.kanade.tachiyomi.util.snack
+import kotlinx.android.synthetic.main.chapters_controller.*
+class ChaptersController : NucleusController<ChaptersPresenter>(),
+ 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.
+ * Selected items. Used to restore selections after a rotation.
+ private val selectedItems = mutableSetOf<ChapterItem>()
+ setOptionsMenuHidden(true)
+ override fun createPresenter(): ChaptersPresenter {
+ val ctrl = parentController as MangaController
+ return ChaptersPresenter(ctrl.manga!!, ctrl.source!!,
+ ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay)
+ return inflater.inflate(R.layout.chapters_controller, container, false)
+ // Init RecyclerView and adapter
+ adapter = ChaptersAdapter(this, view.context)
+ recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
+ adapter?.fastScroller = fast_scroller
+ swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() }
+ fab.clicks().subscribeUntilDestroy {
+ val item = presenter.getNextUnreadChapter()
+ // 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)
+ view.context.toast(R.string.no_next_chapter)
+ 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
+ reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920)
+ super.onActivityResumed(activity)
+ inflater.inflate(R.menu.chapters, 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
+ 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)
+ R.id.action_filter_read -> {
+ presenter.setReadFilter(item.isChecked)
+ R.id.action_filter_downloaded -> {
+ presenter.setDownloadedFilter(item.isChecked)
+ R.id.action_filter_bookmarked -> {
+ presenter.setBookmarkedFilter(item.isChecked)
+ R.id.action_filter_empty -> {
+ presenter.removeFilters()
+ R.id.action_sort -> presenter.revertSortOrder()
+ fun onNextChapters(chapters: List<ChapterItem>) {
+ // 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()
+ adapter.updateDataSet(chapters)
+ if (selectedItems.isNotEmpty()) {
+ adapter.clearSelection() // we need to start from a clean state, index may have changed
+ selectedItems.forEach { item ->
+ val position = adapter.indexOf(item)
+ 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) {
+ 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 intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
+ if (hasAnimation) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
+ startActivity(intent)
+ val adapter = adapter ?: return false
+ if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
+ // SELECTIONS & ACTION MODE
+ if (adapter.isSelected(position)) {
+ selectedItems.add(item)
+ selectedItems.remove(item)
+ private fun getSelectedChapters(): List<ChapterItem> {
+ val adapter = adapter ?: return emptyList()
+ return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
+ private fun createActionModeIfNeeded() {
+ actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
+ private fun destroyActionModeIfNeeded() {
+ mode.menuInflater.inflate(R.menu.chapter_selection, menu)
+ adapter?.mode = SelectableAdapter.Mode.MULTI
+ @SuppressLint("StringFormatInvalid")
+ val count = adapter?.selectedItemCount ?: 0
+ 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()
+ override fun onDestroyActionMode(mode: ActionMode) {
+ adapter?.mode = SelectableAdapter.Mode.SINGLE
+ adapter?.clearSelection()
+ selectedItems.clear()
+ override fun onMenuItemClick(position: Int, item: MenuItem) {
+ val chapter = adapter?.getItem(position) ?: return
+ val chapters = listOf(chapter)
+ 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() {
+ adapter.selectAll()
+ selectedItems.addAll(adapter.items)
+ private fun markAsRead(chapters: List<ChapterItem>) {
+ presenter.markChaptersRead(chapters, true)
+ if (presenter.preferences.removeAfterMarkedAsRead()) {
+ deleteChapters(chapters)
+ private fun markAsUnread(chapters: List<ChapterItem>) {
+ presenter.markChaptersRead(chapters, false)
+ private fun downloadChapters(chapters: List<ChapterItem>) {
+ val view = view
+ 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 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<ChapterItem>, bookmarked: Boolean) {
+ presenter.bookmarkChapters(chapters, bookmarked)
+ fun deleteChapters(chapters: List<ChapterItem>) {
+ if (chapters.isEmpty()) return
+ DeletingChaptersDialog().showDialog(router)
+ presenter.deleteChapters(chapters)
+ fun onChaptersDeleted() {
+ dismissDeletingDialog()
+ adapter?.notifyDataSetChanged()
+ fun onChaptersDeletedError(error: Throwable) {
+ 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)
+ 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()
+ 4 -> presenter.chapters.filter { !it.read }
+ 5 -> presenter.chapters
+ else -> emptyList()
@@ -1,418 +1,418 @@
-import eu.kanade.tachiyomi.util.syncChaptersWithSource
- * Presenter of [ChaptersController].
-class ChaptersPresenter(
- val manga: Manga,
- val source: Source,
- private val chapterCountRelay: BehaviorRelay<Float>,
- private val lastUpdateRelay: BehaviorRelay<Date>,
- private val mangaFavoriteRelay: PublishRelay<Boolean>,
- val preferences: PreferencesHelper = Injekt.get(),
-) : BasePresenter<ChaptersController>() {
- * List of chapters of the manga. It's always unfiltered and unsorted.
- var chapters: List<ChapterItem> = emptyList()
- * Subject of list of chapters to allow updating the view without going to DB.
- val chaptersRelay: PublishRelay<List<ChapterItem>>
- by lazy { PublishRelay.create<List<ChapterItem>>() }
- * Whether the chapter list has been requested to the source.
- var hasRequested = false
- * 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
- // Prepare the relay.
- chaptersRelay.flatMap { applyChapterFilters(it) }
- .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()
- .filter { download -> download.manga.id == manga.id }
- .doOnNext { onDownloadStatusChange(it) }
- .subscribeLatestCache(ChaptersController::onChapterStatusChange,
- * 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<ChapterItem>) {
- 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) }
- .map { syncChaptersWithSource(db, it, manga, source) }
- .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<ChapterItem>): Observable<List<ChapterItem>> {
- 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<ChapterItem>, read: Boolean) {
- Observable.from(selectedChapters)
- .doOnNext { chapter ->
- chapter.read = read
- if (!read) {
- chapter.last_page_read = 0
- .toList()
- .flatMap { db.updateChaptersProgress(it).asRxObservable() }
- * Downloads the given list of chapters with the manager.
- * @param chapters the list of chapters to download.
- fun downloadChapters(chapters: List<ChapterItem>) {
- downloadManager.downloadChapters(manga, chapters)
- * Bookmarks the given list of chapters.
- * @param selectedChapters the list of chapters to bookmark.
- fun bookmarkChapters(selectedChapters: List<ChapterItem>, bookmarked: Boolean) {
- chapter.bookmark = bookmarked
- * Deletes the given list of chapter.
- * @param chapters the list of chapters to delete.
- Observable.just(chapters)
- .doOnNext { deleteChaptersInternal(chapters) }
- .doOnNext { if (onlyDownloaded()) refreshChapters() }
- 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<ChapterItem>) {
- 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()
- * 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
- * @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
- * 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
- * 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
- * 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
- * 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
- * Sets the sorting method and requests an UI update.
- * @param sort the sorting mode.
- fun setSorting(sort: Int) {
- manga.sorting = sort
- * Whether the display only downloaded filter is enabled.
- fun onlyDownloaded(): Boolean {
- return manga.downloadedFilter == Manga.SHOW_DOWNLOADED
- 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()
+import eu.kanade.tachiyomi.util.syncChaptersWithSource
+ * Presenter of [ChaptersController].
+class ChaptersPresenter(
+ val manga: Manga,
+ val source: Source,
+ private val chapterCountRelay: BehaviorRelay<Float>,
+ private val lastUpdateRelay: BehaviorRelay<Date>,
+ private val mangaFavoriteRelay: PublishRelay<Boolean>,
+ val preferences: PreferencesHelper = Injekt.get(),
+) : BasePresenter<ChaptersController>() {
+ * List of chapters of the manga. It's always unfiltered and unsorted.
+ var chapters: List<ChapterItem> = emptyList()
+ * Subject of list of chapters to allow updating the view without going to DB.
+ val chaptersRelay: PublishRelay<List<ChapterItem>>
+ by lazy { PublishRelay.create<List<ChapterItem>>() }
+ * Whether the chapter list has been requested to the source.
+ var hasRequested = false
+ * 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
+ // Prepare the relay.
+ chaptersRelay.flatMap { applyChapterFilters(it) }
+ .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()
+ .filter { download -> download.manga.id == manga.id }
+ .doOnNext { onDownloadStatusChange(it) }
+ .subscribeLatestCache(ChaptersController::onChapterStatusChange,
+ * 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<ChapterItem>) {
+ 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) }
+ .map { syncChaptersWithSource(db, it, manga, source) }
+ .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<ChapterItem>): Observable<List<ChapterItem>> {
+ 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<ChapterItem>, read: Boolean) {
+ Observable.from(selectedChapters)
+ .doOnNext { chapter ->
+ chapter.read = read
+ if (!read) {
+ chapter.last_page_read = 0
+ .toList()
+ .flatMap { db.updateChaptersProgress(it).asRxObservable() }
+ * Downloads the given list of chapters with the manager.
+ * @param chapters the list of chapters to download.
+ fun downloadChapters(chapters: List<ChapterItem>) {
+ downloadManager.downloadChapters(manga, chapters)
+ * Bookmarks the given list of chapters.
+ * @param selectedChapters the list of chapters to bookmark.
+ fun bookmarkChapters(selectedChapters: List<ChapterItem>, bookmarked: Boolean) {
+ chapter.bookmark = bookmarked
+ * Deletes the given list of chapter.
+ * @param chapters the list of chapters to delete.
+ Observable.just(chapters)
+ .doOnNext { deleteChaptersInternal(chapters) }
+ .doOnNext { if (onlyDownloaded()) refreshChapters() }
+ 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<ChapterItem>) {
+ 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()
+ * 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
+ * @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
+ * 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
+ * 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
+ * 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
+ * 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
+ * Sets the sorting method and requests an UI update.
+ * @param sort the sorting mode.
+ fun setSorting(sort: Int) {
+ manga.sorting = sort
+ * Whether the display only downloaded filter is enabled.
+ fun onlyDownloaded(): Boolean {
+ return manga.downloadedFilter == Manga.SHOW_DOWNLOADED
+ 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()
-class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
- where T : Controller, T : DeleteChaptersDialog.Listener {
- constructor(target: T) : this() {
- .content(R.string.confirm_delete_chapters)
- (targetController as? Listener)?.deleteChapters()
- .show()
- fun deleteChapters()
+class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
+ where T : Controller, T : DeleteChaptersDialog.Listener {
+ constructor(target: T) : this() {
+ .content(R.string.confirm_delete_chapters)
+ (targetController as? Listener)?.deleteChapters()
+ .show()
+ fun deleteChapters()
-class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) {
- const val TAG = "deleting_dialog"
- .progress(true, 0)
- .content(R.string.deleting)
- override fun showDialog(router: Router) {
- showDialog(router, TAG)
+class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) {
+ const val TAG = "deleting_dialog"
+ .progress(true, 0)
+ .content(R.string.deleting)
+ override fun showDialog(router: Router) {
+ showDialog(router, TAG)
@@ -1,42 +1,42 @@
-class DownloadChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
- where T : Controller, T : DownloadChaptersDialog.Listener {
- 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) }
- .items(choices)
- .itemsCallback { _, _, position, _ ->
- (targetController as? Listener)?.downloadChapters(position)
- fun downloadChapters(choice: Int)
+class DownloadChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
+ where T : Controller, T : DownloadChaptersDialog.Listener {
+ 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) }
+ .items(choices)
+ .itemsCallback { _, _, position, _ ->
+ (targetController as? Listener)?.downloadChapters(position)
+ fun downloadChapters(choice: Int)
-class SetDisplayModeDialog<T>(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)
- 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) }
- .title(R.string.action_display_mode)
- .itemsIds(ids)
- .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
- (targetController as? Listener)?.setDisplayMode(itemView.id)
- fun setDisplayMode(id: Int)
+class SetDisplayModeDialog<T>(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)
+ 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) }
+ .title(R.string.action_display_mode)
+ .itemsIds(ids)
+ .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
+ (targetController as? Listener)?.setDisplayMode(itemView.id)
+ fun setDisplayMode(id: Int)
-class SetSortingDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
- where T : Controller, T : SetSortingDialog.Listener {
- val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER)
- val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number)
- .title(R.string.sorting_mode)
- (targetController as? Listener)?.setSorting(itemView.id)
- fun setSorting(id: Int)
+class SetSortingDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
+ where T : Controller, T : SetSortingDialog.Listener {
+ val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER)
+ val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number)
+ .title(R.string.sorting_mode)
+ (targetController as? Listener)?.setSorting(itemView.id)
+ fun setSorting(id: Int)
@@ -1,577 +1,577 @@
-package eu.kanade.tachiyomi.ui.manga.info
-import android.app.PendingIntent
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.graphics.Bitmap
-import android.graphics.drawable.Drawable
-import android.support.customtabs.CustomTabsIntent
-import android.support.v4.content.pm.ShortcutInfoCompat
-import android.support.v4.content.pm.ShortcutManagerCompat
-import android.support.v4.graphics.drawable.IconCompat
-import android.widget.Toast
-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.view.longClicks
-import eu.kanade.tachiyomi.data.notification.NotificationReceiver
-import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
-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.*
- * Fragment that shows manga information.
- * Uses R.layout.manga_info_controller.
- * UI related actions should be called from here.
-class MangaInfoController : NucleusController<MangaInfoPresenter>(),
- ChangeMangaCategoriesDialog.Listener {
- override fun createPresenter(): MangaInfoPresenter {
- return MangaInfoPresenter(ctrl.manga!!, ctrl.source!!,
- return inflater.inflate(R.layout.manga_info_controller, container, false)
- // 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)
- inflater.inflate(R.menu.manga_info, menu)
- 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()
- * 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)
- // Initialize manga.
- fetchMangaFromSource()
- * Update the view with manga information.
- private fun setMangaInfo(manga: Manga, source: Source?) {
- //update full title TextView.
- manga_full_title.text = if (manga.title.isBlank()) {
- view.context.getString(R.string.unknown)
- manga.title
- // Update artist TextView.
- manga_artist.text = if (manga.artist.isNullOrBlank()) {
- manga.artist
- // Update author TextView.
- manga_author.text = if (manga.author.isNullOrBlank()) {
- manga.author
- // If manga source is known update source TextView.
- manga_source.text = if (source == null) {
- 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()) {
- 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()) {
- .into(manga_cover)
- if (backdrop != null) {
- .into(backdrop)
- manga_genres_tags.setOnTagClickListener(null)
- * Update chapter count TextView.
- * @param count number of chapters.
- fun setChapterCount(count: Float) {
- if (count > 0f) {
- manga_chapters?.text = DecimalFormat("#.#").format(count)
- 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)
- 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 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 url = try {
- source.mangaDetailsRequest(presenter.manga).url().toString()
- parentController?.router?.pushController(MangaWebViewController(source.id, url)
- .withFadeTransaction())
- * Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
- private fun shareManga() {
- 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)))
- 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
- 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) {
- * 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()
- 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)
- activity?.toast(activity?.getString(R.string.manga_added_library))
- activity?.toast(activity?.getString(R.string.manga_removed_library))
- * Called when the fab is long clicked.
- private fun onFabLongClick() {
- if (!manga.favorite) {
- if (categories.isEmpty()) {
- // no categories exist, display a message about adding categories
- activity?.toast(activity?.getString(R.string.action_add_category))
- 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()
- ChooseShapeDialog(this).showDialog(router)
- * Dialog to choose a shape for the icon.
- private class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) {
- constructor(target: MangaInfoController) : this() {
- val modes = intArrayOf(R.string.circular_icon,
- R.string.rounded_icon,
- R.string.square_icon,
- R.string.star_icon)
- .title(R.string.icon_shape)
- .items(modes.map { activity?.getString(it) })
- .itemsCallback { _, _, i, _ ->
- (targetController as? MangaInfoController)?.createShortcutForShape(i)
- * 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<Bitmap>(96, 96) {
- override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
- 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 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
- * Create shortcut using ShortcutManager.
- * @param icon The image of the shortcut.
- private fun createShortcut(icon: Bitmap) {
- 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)
- 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)
- NotificationReceiver.shortcutCreatedBroadcast(activity)
- // Request shortcut.
- ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfo,
- successCallback.intentSender)
+package eu.kanade.tachiyomi.ui.manga.info
+import android.app.PendingIntent
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.support.customtabs.CustomTabsIntent
+import android.support.v4.content.pm.ShortcutInfoCompat
+import android.support.v4.content.pm.ShortcutManagerCompat
+import android.support.v4.graphics.drawable.IconCompat
+import android.widget.Toast
+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.view.longClicks
+import eu.kanade.tachiyomi.data.notification.NotificationReceiver
+import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
+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.*
+ * Fragment that shows manga information.
+ * Uses R.layout.manga_info_controller.
+ * UI related actions should be called from here.
+class MangaInfoController : NucleusController<MangaInfoPresenter>(),
+ ChangeMangaCategoriesDialog.Listener {
+ override fun createPresenter(): MangaInfoPresenter {
+ return MangaInfoPresenter(ctrl.manga!!, ctrl.source!!,
+ return inflater.inflate(R.layout.manga_info_controller, container, false)
+ // 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)
+ inflater.inflate(R.menu.manga_info, menu)
+ 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()
+ * 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)
+ // Initialize manga.
+ fetchMangaFromSource()
+ * Update the view with manga information.
+ private fun setMangaInfo(manga: Manga, source: Source?) {
+ //update full title TextView.
+ manga_full_title.text = if (manga.title.isBlank()) {
+ view.context.getString(R.string.unknown)
+ manga.title
+ // Update artist TextView.
+ manga_artist.text = if (manga.artist.isNullOrBlank()) {
+ manga.artist
+ // Update author TextView.
+ manga_author.text = if (manga.author.isNullOrBlank()) {
+ manga.author
+ // If manga source is known update source TextView.
+ manga_source.text = if (source == null) {
+ 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()) {
+ 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()) {
+ .into(manga_cover)
+ if (backdrop != null) {
+ .into(backdrop)
+ manga_genres_tags.setOnTagClickListener(null)
+ * Update chapter count TextView.
+ * @param count number of chapters.
+ fun setChapterCount(count: Float) {
+ if (count > 0f) {
+ manga_chapters?.text = DecimalFormat("#.#").format(count)
+ 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)
+ 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 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 url = try {
+ source.mangaDetailsRequest(presenter.manga).url().toString()
+ parentController?.router?.pushController(MangaWebViewController(source.id, url)
+ .withFadeTransaction())
+ * Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
+ private fun shareManga() {
+ 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)))
+ 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
+ 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) {
+ * 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()
+ 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)
+ activity?.toast(activity?.getString(R.string.manga_added_library))
+ activity?.toast(activity?.getString(R.string.manga_removed_library))
+ * Called when the fab is long clicked.
+ private fun onFabLongClick() {
+ if (!manga.favorite) {
+ if (categories.isEmpty()) {
+ // no categories exist, display a message about adding categories
+ activity?.toast(activity?.getString(R.string.action_add_category))
+ 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()
+ ChooseShapeDialog(this).showDialog(router)
+ * Dialog to choose a shape for the icon.
+ private class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) {
+ constructor(target: MangaInfoController) : this() {
+ val modes = intArrayOf(R.string.circular_icon,
+ R.string.rounded_icon,
+ R.string.square_icon,
+ R.string.star_icon)
+ .title(R.string.icon_shape)
+ .items(modes.map { activity?.getString(it) })
+ .itemsCallback { _, _, i, _ ->
+ (targetController as? MangaInfoController)?.createShortcutForShape(i)
+ * 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<Bitmap>(96, 96) {
+ override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
+ 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 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
+ * Create shortcut using ShortcutManager.
+ * @param icon The image of the shortcut.
+ private fun createShortcut(icon: Bitmap) {
+ 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)
+ 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)
+ NotificationReceiver.shortcutCreatedBroadcast(activity)
+ // Request shortcut.
+ ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfo,
+ successCallback.intentSender)
@@ -1,173 +1,173 @@
- * Presenter of MangaInfoFragment.
- * Contains information and data for fragment.
- * Observable updates should be called from here.
-class MangaInfoPresenter(
- private val downloadManager: DownloadManager = Injekt.get(),
- private val coverCache: CoverCache = Injekt.get()
-) : BasePresenter<MangaInfoController>() {
- * 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
- 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
- .doOnNext { sendMangaToView() }
- 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
- return manga.favorite
- private fun setFavorite(favorite: Boolean) {
- if (manga.favorite == favorite) {
- * 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() {
- * Get user categories.
- * @return List of categories, not including the default category
- fun getCategories(): List<Category> {
- 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<Int> {
- val categories = db.getCategoriesForManga(manga).executeAsBlocking()
- return categories.mapNotNull { it.id }.toTypedArray()
- * Move the given manga to categories.
- * @param manga the manga to move.
- fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
- 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 category the selected category, or null for default category.
- fun moveMangaToCategory(manga: Manga, category: Category?) {
- moveMangaToCategories(manga, listOfNotNull(category))
+ * Presenter of MangaInfoFragment.
+ * Contains information and data for fragment.
+ * Observable updates should be called from here.
+class MangaInfoPresenter(
+ private val downloadManager: DownloadManager = Injekt.get(),
+ private val coverCache: CoverCache = Injekt.get()
+) : BasePresenter<MangaInfoController>() {
+ * 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
+ 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
+ .doOnNext { sendMangaToView() }
+ 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
+ return manga.favorite
+ private fun setFavorite(favorite: Boolean) {
+ if (manga.favorite == favorite) {
+ * 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() {
+ * Get user categories.
+ * @return List of categories, not including the default category
+ fun getCategories(): List<Category> {
+ 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<Int> {
+ val categories = db.getCategoriesForManga(manga).executeAsBlocking()
+ return categories.mapNotNull { it.id }.toTypedArray()
+ * Move the given manga to categories.
+ * @param manga the manga to move.
+ fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
+ 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 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.track
-import android.widget.NumberPicker
-class SetTrackChaptersDialog<T> : 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)
- this.item = item
- constructor(bundle: Bundle) : super(bundle) {
- val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
- val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
- item = TrackItem(track, service)
- val item = item
- val dialog = MaterialDialog.Builder(activity!!)
- .title(R.string.chapters)
- .customView(R.layout.track_chapters_dialog, false)
- .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)
- // 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
- fun setChaptersRead(item: TrackItem, chaptersRead: Int)
- const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track"
+package eu.kanade.tachiyomi.ui.manga.track
+import android.widget.NumberPicker
+class SetTrackChaptersDialog<T> : 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)
+ this.item = item
+ constructor(bundle: Bundle) : super(bundle) {
+ val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
+ val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
+ item = TrackItem(track, service)
+ val item = item
+ val dialog = MaterialDialog.Builder(activity!!)
+ .title(R.string.chapters)
+ .customView(R.layout.track_chapters_dialog, false)
+ .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)
+ // 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
+ fun setChaptersRead(item: TrackItem, chaptersRead: Int)
+ const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track"
@@ -1,80 +1,80 @@
-class SetTrackScoreDialog<T> : DialogController
- where T : Controller, T : SetTrackScoreDialog.Listener {
- .title(R.string.score)
- .customView(R.layout.track_score_dialog, false)
- val np: NumberPicker = view.findViewById(R.id.score_picker)
- (targetController as? Listener)?.setScore(item, np.value)
- val scores = item.service.getScoreList().toTypedArray()
- np.maxValue = scores.size - 1
- np.displayedValues = scores
- val displayedScore = item.service.displayScore(item.track!!)
- if (displayedScore != "-") {
- val index = scores.indexOf(displayedScore)
- np.value = if (index != -1) index else 0
- fun setScore(item: TrackItem, score: Int)
- const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track"
+class SetTrackScoreDialog<T> : DialogController
+ where T : Controller, T : SetTrackScoreDialog.Listener {
+ .title(R.string.score)
+ .customView(R.layout.track_score_dialog, false)
+ val np: NumberPicker = view.findViewById(R.id.score_picker)
+ (targetController as? Listener)?.setScore(item, np.value)
+ val scores = item.service.getScoreList().toTypedArray()
+ np.maxValue = scores.size - 1
+ np.displayedValues = scores
+ val displayedScore = item.service.displayScore(item.track!!)
+ if (displayedScore != "-") {
+ val index = scores.indexOf(displayedScore)
+ np.value = if (index != -1) index else 0
+ fun setScore(item: TrackItem, score: Int)
+ const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track"
-class SetTrackStatusDialog<T> : DialogController
- where T : Controller, T : SetTrackStatusDialog.Listener {
- val statusList = item.service.getStatusList().orEmpty()
- val statusString = statusList.mapNotNull { item.service.getStatus(it) }
- val selectedIndex = statusList.indexOf(item.track?.status)
- .title(R.string.status)
- .items(statusString)
- .itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ ->
- (targetController as? Listener)?.setStatus(item, i)
- fun setStatus(item: TrackItem, selection: Int)
- const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track"
+class SetTrackStatusDialog<T> : DialogController
+ where T : Controller, T : SetTrackStatusDialog.Listener {
+ val statusList = item.service.getStatusList().orEmpty()
+ val statusString = statusList.mapNotNull { item.service.getStatus(it) }
+ val selectedIndex = statusList.indexOf(item.track?.status)
+ .title(R.string.status)
+ .items(statusString)
+ .itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ ->
+ (targetController as? Listener)?.setStatus(item, i)
+ fun setStatus(item: TrackItem, selection: Int)
+ const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track"
-class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHolder>() {
- var items = emptyList<TrackItem>()
- 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)
+class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHolder>() {
+ var items = emptyList<TrackItem>()
+ 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)
@@ -1,142 +1,142 @@
-import kotlinx.android.synthetic.main.track_controller.*
-class TrackController : NucleusController<TrackPresenter>(),
- TrackAdapter.OnClickListener,
- SetTrackStatusDialog.Listener,
- SetTrackChaptersDialog.Listener,
- SetTrackScoreDialog.Listener {
- private var adapter: TrackAdapter? = null
- // There's no menu, but this avoids a bug when coming from the catalogue, where the menu
- // disappears if the searchview is expanded
- override fun createPresenter(): TrackPresenter {
- return TrackPresenter((parentController as MangaController).manga!!)
- return inflater.inflate(R.layout.track_controller, container, false)
- adapter = TrackAdapter(this)
- with(view) {
- track_recycler.layoutManager = LinearLayoutManager(context)
- track_recycler.adapter = adapter
- swipe_refresh.isEnabled = false
- swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() }
- fun onNextTrackings(trackings: List<TrackItem>) {
- val atLeastOneLink = trackings.any { it.track != null }
- adapter?.items = trackings
- swipe_refresh?.isEnabled = atLeastOneLink
- (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink)
- fun onSearchResults(results: List<TrackSearch>) {
- getSearchDialog()?.onSearchResults(results)
- @Suppress("UNUSED_PARAMETER")
- fun onSearchResultsError(error: Throwable) {
- getSearchDialog()?.onSearchResultsError()
- private fun getSearchDialog(): TrackSearchDialog? {
- return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog
- fun onRefreshDone() {
- fun onRefreshError(error: Throwable) {
- override fun onLogoClick(position: Int) {
- val track = adapter?.getItem(position)?.track ?: return
- if (track.tracking_url.isNullOrBlank()) {
- activity?.toast(R.string.url_not_set)
- 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) {
- if (item.track == null) return
- SetTrackStatusDialog(this, item).showDialog(router)
- override fun onChaptersClick(position: Int) {
- SetTrackChaptersDialog(this, item).showDialog(router)
- override fun onScoreClick(position: Int) {
- SetTrackScoreDialog(this, item).showDialog(router)
- override fun setStatus(item: TrackItem, selection: Int) {
- presenter.setStatus(item, selection)
- override fun setScore(item: TrackItem, score: Int) {
- presenter.setScore(item, score)
- override fun setChaptersRead(item: TrackItem, chaptersRead: Int) {
- presenter.setLastChapterRead(item, chaptersRead)
- const val TAG_SEARCH_CONTROLLER = "track_search_controller"
+import kotlinx.android.synthetic.main.track_controller.*
+class TrackController : NucleusController<TrackPresenter>(),
+ TrackAdapter.OnClickListener,
+ SetTrackStatusDialog.Listener,
+ SetTrackChaptersDialog.Listener,
+ SetTrackScoreDialog.Listener {
+ private var adapter: TrackAdapter? = null
+ // There's no menu, but this avoids a bug when coming from the catalogue, where the menu
+ // disappears if the searchview is expanded
+ override fun createPresenter(): TrackPresenter {
+ return TrackPresenter((parentController as MangaController).manga!!)
+ return inflater.inflate(R.layout.track_controller, container, false)
+ adapter = TrackAdapter(this)
+ with(view) {
+ track_recycler.layoutManager = LinearLayoutManager(context)
+ track_recycler.adapter = adapter
+ swipe_refresh.isEnabled = false
+ swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() }
+ fun onNextTrackings(trackings: List<TrackItem>) {
+ val atLeastOneLink = trackings.any { it.track != null }
+ adapter?.items = trackings
+ swipe_refresh?.isEnabled = atLeastOneLink
+ (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink)
+ fun onSearchResults(results: List<TrackSearch>) {
+ getSearchDialog()?.onSearchResults(results)
+ @Suppress("UNUSED_PARAMETER")
+ fun onSearchResultsError(error: Throwable) {
+ getSearchDialog()?.onSearchResultsError()
+ private fun getSearchDialog(): TrackSearchDialog? {
+ return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog
+ fun onRefreshDone() {
+ fun onRefreshError(error: Throwable) {
+ override fun onLogoClick(position: Int) {
+ val track = adapter?.getItem(position)?.track ?: return
+ if (track.tracking_url.isNullOrBlank()) {
+ activity?.toast(R.string.url_not_set)
+ 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) {
+ if (item.track == null) return
+ SetTrackStatusDialog(this, item).showDialog(router)
+ override fun onChaptersClick(position: Int) {
+ SetTrackChaptersDialog(this, item).showDialog(router)
+ override fun onScoreClick(position: Int) {
+ SetTrackScoreDialog(this, item).showDialog(router)
+ override fun setStatus(item: TrackItem, selection: Int) {
+ presenter.setStatus(item, selection)
+ override fun setScore(item: TrackItem, score: Int) {
+ presenter.setScore(item, score)
+ override fun setChaptersRead(item: TrackItem, chaptersRead: Int) {
+ presenter.setLastChapterRead(item, chaptersRead)
+ const val TAG_SEARCH_CONTROLLER = "track_search_controller"
-import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
-import kotlinx.android.synthetic.main.track_item.*
-class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
- 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)
- 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 = ""
+import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
+import kotlinx.android.synthetic.main.track_item.*
+class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
+ 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)
+ 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 = ""
@@ -1,6 +1,6 @@
-data class TrackItem(val track: Track?, val service: TrackService)
+data class TrackItem(val track: Track?, val service: TrackService)
@@ -1,130 +1,130 @@
-class TrackPresenter(
- preferences: PreferencesHelper = Injekt.get(),
- private val trackManager: TrackManager = Injekt.get()
-) : BasePresenter<TrackController>() {
- private var trackList: List<TrackItem> = 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
- 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)
- .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 }
- .subscribeFirst({ view, _ -> view.onRefreshDone() },
- TrackController::onRefreshError)
- fun search(query: String, service: TrackService) {
- searchSubscription?.let { remove(it) }
- searchSubscription = service.search(query)
- .subscribeLatestCache(TrackController::onSearchResults,
- TrackController::onSearchResultsError)
- fun registerTracking(item: Track?, service: TrackService) {
- item.manga_id = manga.id!!
- add(service.bind(item)
- .flatMap { db.insertTrack(item).asRxObservable() }
- .subscribe({ },
- { error -> context.toast(error.message) }))
- db.deleteTrackForManga(manga, service).executeAsBlocking()
- private fun updateRemote(track: Track, service: TrackService) {
- service.update(track)
- .flatMap { db.insertTrack(track).asRxObservable() }
- { view, error ->
- view.onRefreshError(error)
- // Restart on error to set old values
- 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) {
- track.score = item.service.indexToScore(index)
- fun setLastChapterRead(item: TrackItem, chapterNumber: Int) {
- track.last_chapter_read = chapterNumber
+class TrackPresenter(
+ preferences: PreferencesHelper = Injekt.get(),
+ private val trackManager: TrackManager = Injekt.get()
+) : BasePresenter<TrackController>() {
+ private var trackList: List<TrackItem> = 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
+ 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)
+ .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 }
+ .subscribeFirst({ view, _ -> view.onRefreshDone() },
+ TrackController::onRefreshError)
+ fun search(query: String, service: TrackService) {
+ searchSubscription?.let { remove(it) }
+ searchSubscription = service.search(query)
+ .subscribeLatestCache(TrackController::onSearchResults,
+ TrackController::onSearchResultsError)
+ fun registerTracking(item: Track?, service: TrackService) {
+ item.manga_id = manga.id!!
+ add(service.bind(item)
+ .flatMap { db.insertTrack(item).asRxObservable() }
+ .subscribe({ },
+ { error -> context.toast(error.message) }))
+ db.deleteTrackForManga(manga, service).executeAsBlocking()
+ private fun updateRemote(track: Track, service: TrackService) {
+ service.update(track)
+ .flatMap { db.insertTrack(track).asRxObservable() }
+ { view, error ->
+ view.onRefreshError(error)
+ // Restart on error to set old values
+ 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) {
+ track.score = item.service.indexToScore(index)
+ fun setLastChapterRead(item: TrackItem, chapterNumber: Int) {
+ track.last_chapter_read = chapterNumber
@@ -1,79 +1,79 @@
-import android.widget.ArrayAdapter
-import kotlinx.android.synthetic.main.track_search_item.view.*
-class TrackSearchAdapter(context: Context)
- : ArrayAdapter<TrackSearch>(context, R.layout.track_search_item, ArrayList<TrackSearch>()) {
- 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
- holder = v.tag as TrackSearchHolder
- holder.onSetValues(track)
- return v
- fun setItems(syncs: List<TrackSearch>) {
- setNotifyOnChange(false)
- clear()
- addAll(syncs)
- 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()) {
- .load(track.cover_url)
- .into(view.track_search_cover)
- if (track.publishing_status.isNullOrBlank()) {
- view.track_search_status.gone()
- view.track_search_status_result.gone()
- 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()
- 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()
- view.track_search_start_result.text = track.start_date
+import android.widget.ArrayAdapter
+import kotlinx.android.synthetic.main.track_search_item.view.*
+class TrackSearchAdapter(context: Context)
+ : ArrayAdapter<TrackSearch>(context, R.layout.track_search_item, ArrayList<TrackSearch>()) {
+ 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
+ holder = v.tag as TrackSearchHolder
+ holder.onSetValues(track)
+ return v
+ fun setItems(syncs: List<TrackSearch>) {
+ setNotifyOnChange(false)
+ clear()
+ addAll(syncs)
+ 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()) {
+ .load(track.cover_url)
+ .into(view.track_search_cover)
+ if (track.publishing_status.isNullOrBlank()) {
+ view.track_search_status.gone()
+ view.track_search_status_result.gone()
+ 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()
+ 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()
+ view.track_search_start_result.text = track.start_date
-import com.jakewharton.rxbinding.widget.itemClicks
-import com.jakewharton.rxbinding.widget.textChanges
-import kotlinx.android.synthetic.main.track_search_dialog.view.*
-class TrackSearchDialog : DialogController {
- private var dialogView: View? = null
- private var adapter: TrackSearchAdapter? = null
- private var selectedItem: Track? = null
- private val service: TrackService
- 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)
- this.service = service
- service = Injekt.get<TrackManager>().getService(bundle.getInt(KEY_SERVICE))!!
- .customView(R.layout.track_search_dialog, false)
- .onPositive { _, _ -> onPositiveButtonClick() }
- if (subscriptions.isUnsubscribed) {
- subscriptions = CompositeSubscription()
- dialogView = dialog.view
- onViewCreated(dialog.view, savedState)
- 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)
- subscriptions.unsubscribe()
- dialogView = null
- override fun onAttach(view: View) {
- super.onAttach(view)
- searchTextSubscription = dialogView!!.track_search.textChanges()
- .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)
- view.progress.visibility = View.INVISIBLE
- view.track_search_list.visibility = View.VISIBLE
- adapter?.setItems(results)
- fun onSearchResultsError() {
- adapter?.setItems(emptyList())
- private fun onPositiveButtonClick() {
- trackController.presenter.registerTracking(selectedItem, service)
- const val KEY_SERVICE = "service_id"
+import com.jakewharton.rxbinding.widget.itemClicks
+import com.jakewharton.rxbinding.widget.textChanges
+import kotlinx.android.synthetic.main.track_search_dialog.view.*
+class TrackSearchDialog : DialogController {
+ private var dialogView: View? = null
+ private var adapter: TrackSearchAdapter? = null
+ private var selectedItem: Track? = null
+ private val service: TrackService
+ 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)
+ this.service = service
+ service = Injekt.get<TrackManager>().getService(bundle.getInt(KEY_SERVICE))!!
+ .customView(R.layout.track_search_dialog, false)
+ .onPositive { _, _ -> onPositiveButtonClick() }
+ if (subscriptions.isUnsubscribed) {
+ subscriptions = CompositeSubscription()
+ dialogView = dialog.view
+ onViewCreated(dialog.view, savedState)
+ 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)
+ subscriptions.unsubscribe()
+ dialogView = null
+ override fun onAttach(view: View) {
+ super.onAttach(view)
+ searchTextSubscription = dialogView!!.track_search.textChanges()
+ .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)
+ view.progress.visibility = View.INVISIBLE
+ view.track_search_list.visibility = View.VISIBLE
+ adapter?.setItems(results)
+ fun onSearchResultsError() {
+ adapter?.setItems(emptyList())
+ private fun onPositiveButtonClick() {
+ trackController.presenter.registerTracking(selectedItem, service)
+ const val KEY_SERVICE = "service_id"
@@ -1,333 +1,333 @@
-package eu.kanade.tachiyomi.ui.recent_updates
-import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
-import kotlinx.android.synthetic.main.recent_chapters_controller.*
- * Fragment that shows recent chapters.
- * Uses [R.layout.recent_chapters_controller].
-class RecentChaptersController : NucleusController<RecentChaptersPresenter>(),
- NoToolbarElevationController,
- FlexibleAdapter.OnUpdateListener,
- ConfirmDeleteChaptersDialog.Listener,
- RecentChaptersAdapter.OnCoverClickListener {
- * Adapter containing the recent chapters.
- var adapter: RecentChaptersAdapter? = null
- return resources?.getString(R.string.label_recent_updates)
- override fun createPresenter(): RecentChaptersPresenter {
- return RecentChaptersPresenter()
- return inflater.inflate(R.layout.recent_chapters_controller, container, false)
- * Called when view is created
- * @param view created view
- val layoutManager = LinearLayoutManager(view.context)
- recycler.layoutManager = layoutManager
- adapter = RecentChaptersAdapter(this@RecentChaptersController)
- recycler.scrollStateChanges().subscribeUntilDestroy {
- val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition()
- 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)
- * Returns selected chapters
- * @return list of selected chapters
- fun getSelectedChapters(): List<RecentChapterItem> {
- return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem }
- * Called when item in list is clicked
- * @param position position of clicked item
- // Get item from position
- val item = adapter.getItem(position) as? RecentChapterItem ?: return false
- openChapter(item)
- * Called when item in list is long clicked
- if (actionMode == null)
- * Called to toggle selection
- * @param position position of selected item
- * Open chapter in reader
- * @param chapter selected chapter
- private fun openChapter(item: RecentChapterItem) {
- val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter)
- * Download selected items
- * @param chapters list of selected [RecentChapter]s
- fun downloadChapters(chapters: List<RecentChapterItem>) {
- * Populate adapter with chapters
- * @param chapters list of [Any]
- fun onNextRecentChapters(chapters: List<IFlexible<*>>) {
- adapter?.updateDataSet(chapters)
- override fun onUpdateEmptyView(size: Int) {
- if (size > 0) {
- 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.
- getHolder(download)?.notifyStatus(download.status)
- * Returns holder belonging to chapter
- 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<RecentChapterItem>) {
- presenter.markChapterRead(chapters, true)
- override fun deleteChapters(chaptersToDelete: List<RecentChapterItem>) {
- presenter.deleteChapters(chaptersToDelete)
- * Destory [ActionMode] if it's shown
- * Mark chapter as unread
- * @param chapters list of selected [RecentChapter]
- fun markAsUnread(chapters: List<RecentChapterItem>) {
- presenter.markChapterRead(chapters, false)
- * Start downloading chapter
- * @param chapter selected chapter with manga
- fun downloadChapter(chapter: RecentChapterItem) {
- presenter.downloadChapters(listOf(chapter))
- * Start deleting chapter
- fun deleteChapter(chapter: RecentChapterItem) {
- 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
- * Called when error while deleting
- * @param error error message
- * Called to dismiss deleting dialog
- fun dismissDeletingDialog() {
- * Called when ActionMode created.
- * @param mode the ActionMode object
- * @param menu menu object of ActionMode
- mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu)
- * Called when ActionMode item clicked
- * @param item item from ActionMode.
- R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters())
- * Called when ActionMode destroyed
- adapter?.mode = SelectableAdapter.Mode.IDLE
+package eu.kanade.tachiyomi.ui.recent_updates
+import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges
+import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
+import kotlinx.android.synthetic.main.recent_chapters_controller.*
+ * Fragment that shows recent chapters.
+ * Uses [R.layout.recent_chapters_controller].
+class RecentChaptersController : NucleusController<RecentChaptersPresenter>(),
+ NoToolbarElevationController,
+ FlexibleAdapter.OnUpdateListener,
+ ConfirmDeleteChaptersDialog.Listener,
+ RecentChaptersAdapter.OnCoverClickListener {
+ * Adapter containing the recent chapters.
+ var adapter: RecentChaptersAdapter? = null
+ return resources?.getString(R.string.label_recent_updates)
+ override fun createPresenter(): RecentChaptersPresenter {
+ return RecentChaptersPresenter()
+ return inflater.inflate(R.layout.recent_chapters_controller, container, false)
+ * Called when view is created
+ * @param view created view
+ val layoutManager = LinearLayoutManager(view.context)
+ recycler.layoutManager = layoutManager
+ adapter = RecentChaptersAdapter(this@RecentChaptersController)
+ recycler.scrollStateChanges().subscribeUntilDestroy {
+ val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition()
+ 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)
+ * Returns selected chapters
+ * @return list of selected chapters
+ fun getSelectedChapters(): List<RecentChapterItem> {
+ return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem }
+ * Called when item in list is clicked
+ * @param position position of clicked item
+ // Get item from position
+ val item = adapter.getItem(position) as? RecentChapterItem ?: return false
+ openChapter(item)
+ * Called when item in list is long clicked
+ if (actionMode == null)
+ * Called to toggle selection
+ * @param position position of selected item
+ * Open chapter in reader
+ * @param chapter selected chapter
+ private fun openChapter(item: RecentChapterItem) {
+ val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter)
+ * Download selected items
+ * @param chapters list of selected [RecentChapter]s
+ fun downloadChapters(chapters: List<RecentChapterItem>) {
+ * Populate adapter with chapters
+ * @param chapters list of [Any]
+ fun onNextRecentChapters(chapters: List<IFlexible<*>>) {
+ adapter?.updateDataSet(chapters)
+ override fun onUpdateEmptyView(size: Int) {
+ if (size > 0) {
+ 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.
+ getHolder(download)?.notifyStatus(download.status)
+ * Returns holder belonging to chapter
+ 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<RecentChapterItem>) {
+ presenter.markChapterRead(chapters, true)
+ override fun deleteChapters(chaptersToDelete: List<RecentChapterItem>) {
+ presenter.deleteChapters(chaptersToDelete)
+ * Destory [ActionMode] if it's shown
+ * Mark chapter as unread
+ * @param chapters list of selected [RecentChapter]
+ fun markAsUnread(chapters: List<RecentChapterItem>) {
+ presenter.markChapterRead(chapters, false)
+ * Start downloading chapter
+ * @param chapter selected chapter with manga
+ fun downloadChapter(chapter: RecentChapterItem) {
+ presenter.downloadChapters(listOf(chapter))
+ * Start deleting chapter
+ fun deleteChapter(chapter: RecentChapterItem) {
+ 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
+ * Called when error while deleting
+ * @param error error message
+ * Called to dismiss deleting dialog
+ fun dismissDeletingDialog() {
+ * Called when ActionMode created.
+ * @param mode the ActionMode object
+ * @param menu menu object of ActionMode
+ mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu)
+ * Called when ActionMode item clicked
+ * @param item item from ActionMode.
+ R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters())
+ * Called when ActionMode destroyed
+ adapter?.mode = SelectableAdapter.Mode.IDLE
@@ -1,87 +1,87 @@
-package eu.kanade.tachiyomi.ui.setting
-import android.support.v7.preference.PreferenceController
-import android.support.v7.preference.PreferenceScreen
-import android.util.TypedValue
-import android.view.ContextThemeWrapper
-import eu.kanade.tachiyomi.ui.base.controller.BaseController
-abstract class SettingsController : PreferenceController() {
- val preferences: PreferencesHelper = Injekt.get()
- var untilDestroySubscriptions = CompositeSubscription()
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View {
- if (untilDestroySubscriptions.isUnsubscribed) {
- untilDestroySubscriptions = CompositeSubscription()
- return super.onCreateView(inflater, container, savedInstanceState)
- 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) {
- parentController = parentController.parentController
- (activity as? AppCompatActivity)?.supportActionBar?.title = getTitle()
- setTitle()
- fun <T> Observable<T>.subscribeUntilDestroy(): Subscription {
- return subscribe().also { untilDestroySubscriptions.add(it) }
- fun <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription {
- return subscribe(onNext).also { untilDestroySubscriptions.add(it) }
+package eu.kanade.tachiyomi.ui.setting
+import android.support.v7.preference.PreferenceController
+import android.support.v7.preference.PreferenceScreen
+import android.util.TypedValue
+import android.view.ContextThemeWrapper
+import eu.kanade.tachiyomi.ui.base.controller.BaseController
+abstract class SettingsController : PreferenceController() {
+ val preferences: PreferencesHelper = Injekt.get()
+ var untilDestroySubscriptions = CompositeSubscription()
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View {
+ if (untilDestroySubscriptions.isUnsubscribed) {
+ untilDestroySubscriptions = CompositeSubscription()
+ return super.onCreateView(inflater, container, savedInstanceState)
+ 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) {
+ parentController = parentController.parentController
+ (activity as? AppCompatActivity)?.supportActionBar?.title = getTitle()
+ setTitle()
+ fun <T> Observable<T>.subscribeUntilDestroy(): Subscription {
+ return subscribe().also { untilDestroySubscriptions.add(it) }
+ fun <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription {
+ return subscribe(onNext).also { untilDestroySubscriptions.add(it) }
-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()) }
- iconRes = R.drawable.ic_chrome_reader_mode_black_24dp
- titleRes = R.string.pref_category_reader
- onClick { navigateTo(SettingsReaderController()) }
- iconRes = R.drawable.ic_file_download_black_24dp
- titleRes = R.string.pref_category_downloads
- onClick { navigateTo(SettingsDownloadController()) }
- iconRes = R.drawable.ic_sync_black_24dp
- titleRes = R.string.pref_category_tracking
- onClick { navigateTo(SettingsTrackingController()) }
- iconRes = R.drawable.ic_backup_black_24dp
- titleRes = R.string.backup
- onClick { navigateTo(SettingsBackupController()) }
- iconRes = R.drawable.ic_code_black_24dp
- titleRes = R.string.pref_category_advanced
- onClick { navigateTo(SettingsAdvancedController()) }
- iconRes = R.drawable.ic_help_black_24dp
- titleRes = R.string.pref_category_about
- onClick { navigateTo(SettingsAboutController()) }
- private fun navigateTo(controller: SettingsController) {
- router.pushController(controller.withFadeTransaction())
+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()) }
+ iconRes = R.drawable.ic_chrome_reader_mode_black_24dp
+ titleRes = R.string.pref_category_reader
+ onClick { navigateTo(SettingsReaderController()) }
+ iconRes = R.drawable.ic_file_download_black_24dp
+ titleRes = R.string.pref_category_downloads
+ onClick { navigateTo(SettingsDownloadController()) }
+ iconRes = R.drawable.ic_sync_black_24dp
+ titleRes = R.string.pref_category_tracking
+ onClick { navigateTo(SettingsTrackingController()) }
+ iconRes = R.drawable.ic_backup_black_24dp
+ titleRes = R.string.backup
+ onClick { navigateTo(SettingsBackupController()) }
+ iconRes = R.drawable.ic_code_black_24dp
+ titleRes = R.string.pref_category_advanced
+ onClick { navigateTo(SettingsAdvancedController()) }
+ iconRes = R.drawable.ic_help_black_24dp
+ titleRes = R.string.pref_category_about
+ onClick { navigateTo(SettingsAboutController()) }
+ private fun navigateTo(controller: SettingsController) {
+ router.pushController(controller.withFadeTransaction())
@@ -1,239 +1,239 @@
-package eu.kanade.tachiyomi.widget
-import android.support.v4.content.ContextCompat
- * 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) {
- 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<Item>
- * Creates all the elements of this group. Implementations can override this method for more
- * customization.
- fun createItems() = (mutableListOf<Item>() + 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<Item>) : RecyclerView.Adapter<Holder>() {
- 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 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
- 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")
- 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.support.v4.content.ContextCompat
+ * 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) {
+ 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<Item>
+ * Creates all the elements of this group. Implementations can override this method for more
+ * customization.
+ fun createItems() = (mutableListOf<Item>() + 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<Item>) : RecyclerView.Adapter<Holder>() {
+ 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 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
+ 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")
+ 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)
@@ -1,8 +1,8 @@
-<shape
- xmlns:android="http://schemas.android.com/apk/res/android"
- android:shape="rectangle">
- <solid android:color="@android:color/transparent"/>
- <size
- android:width="32dp"
- android:height="32dp" />
+<shape
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <solid android:color="@android:color/transparent"/>
+ <size
+ android:width="32dp"
+ android:height="32dp" />
</shape>
@@ -1,9 +1,9 @@
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="18dp"
- android:height="18dp"
- android:viewportWidth="24.0"
- android:viewportHeight="24.0">
- <path
- android:fillColor="#FFFFFFFF"
- android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
-</vector>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="18dp"
+ android:height="18dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0">
+ <path
+ android:fillColor="#FFFFFFFF"
+ android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
+</vector>
- android:width="24dp"
- android:height="24dp"
- android:fillColor="#FF000000"
- android:pathData="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10 10,-4.5 10,-10S17.5,2 12,2zM16.2,16.2L11,13L11,7h1.5v5.2l4.5,2.7 -0.8,1.3z"/>
+ android:width="24dp"
+ android:height="24dp"
+ android:fillColor="#FF000000"
+ android:pathData="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10 10,-4.5 10,-10S17.5,2 12,2zM16.2,16.2L11,13L11,7h1.5v5.2l4.5,2.7 -0.8,1.3z"/>
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout
- android:layout_width="match_parent"
- android:layout_height="?attr/listPreferredItemHeightSmall"
- android:paddingLeft="?attr/listPreferredItemPaddingLeft"
- android:paddingRight="?attr/listPreferredItemPaddingRight"
- android:background="?attr/selectableItemBackground"
- android:focusable="true">
- <CheckBox
- android:id="@+id/nav_view_item"
- android:layout_width="0dp"
- android:layout_height="match_parent"
- android:layout_weight="1"
- android:paddingLeft="@dimen/material_component_lists_icon_left_padding"
- android:background="@android:color/transparent"
- android:gravity="center_vertical|start"
- android:maxLines="1"
- android:clickable="false"
- android:textAppearance="@style/TextAppearance.AppCompat.Body2" />
-</LinearLayout>
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="?attr/listPreferredItemHeightSmall"
+ android:paddingLeft="?attr/listPreferredItemPaddingLeft"
+ android:paddingRight="?attr/listPreferredItemPaddingRight"
+ android:background="?attr/selectableItemBackground"
+ android:focusable="true">
+ <CheckBox
+ android:id="@+id/nav_view_item"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:paddingLeft="@dimen/material_component_lists_icon_left_padding"
+ android:background="@android:color/transparent"
+ android:gravity="center_vertical|start"
+ android:maxLines="1"
+ android:clickable="false"
+ android:textAppearance="@style/TextAppearance.AppCompat.Body2" />
+</LinearLayout>
@@ -1,30 +1,30 @@
- xmlns:tools="http://schemas.android.com/tools"
- android:background="?colorPrimary"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:elevation="2dp">
- <TextView
- android:id="@+id/title"
- android:layout_height="wrap_content"
- android:ellipsize="end"
- android:textAppearance="@style/TextAppearance.AppCompat.Body2"
- android:textColor="@color/textColorPrimaryDark"
- tools:text="Header"/>
- <ImageView
- android:id="@+id/expand_icon"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"/>
+ xmlns:tools="http://schemas.android.com/tools"
+ android:background="?colorPrimary"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:elevation="2dp">
+ <TextView
+ android:id="@+id/title"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:textAppearance="@style/TextAppearance.AppCompat.Body2"
+ android:textColor="@color/textColorPrimaryDark"
+ tools:text="Header"/>
+ <ImageView
+ android:id="@+id/expand_icon"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
</LinearLayout>
@@ -1,62 +1,62 @@
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:background="?selectableItemBackground"
- android:baselineAligned="false"
- android:clipToPadding="false"
- android:minHeight="42dp"
- android:paddingLeft="?listPreferredItemPaddingLeft"
- android:paddingRight="?listPreferredItemPaddingRight"
- tools:ignore="RtlHardcoded">
- <LinearLayout
- android:id="@android:id/widget_frame"
- android:gravity="start|center_vertical"
- android:orientation="vertical"
- android:paddingLeft="16dp"
- android:paddingRight="16dp"/>
- android:id="@android:id/title"
- android:ellipsize="marquee"
- android:singleLine="true"
- android:textAppearance="?textAppearanceListItem"/>
- <!-- Hidden view -->
- android:id="@android:id/summary"
- android:visibility="gone" />
- android:id="@+id/login_frame"
- android:layout_marginEnd="-16dp"
- android:layout_marginRight="-16dp"
- android:gravity="end|center_vertical"
- android:paddingRight="16dp"
- android:visibility="gone">
- android:id="@+id/login" />
- </LinearLayout>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:background="?selectableItemBackground"
+ android:baselineAligned="false"
+ android:clipToPadding="false"
+ android:minHeight="42dp"
+ android:paddingLeft="?listPreferredItemPaddingLeft"
+ android:paddingRight="?listPreferredItemPaddingRight"
+ tools:ignore="RtlHardcoded">
+ <LinearLayout
+ android:id="@android:id/widget_frame"
+ android:gravity="start|center_vertical"
+ android:orientation="vertical"
+ android:paddingLeft="16dp"
+ android:paddingRight="16dp"/>
+ android:id="@android:id/title"
+ android:ellipsize="marquee"
+ android:singleLine="true"
+ android:textAppearance="?textAppearanceListItem"/>
+ <!-- Hidden view -->
+ android:id="@android:id/summary"
+ android:visibility="gone" />
+ android:id="@+id/login_frame"
+ android:layout_marginEnd="-16dp"
+ android:layout_marginRight="-16dp"
+ android:gravity="end|center_vertical"
+ android:paddingRight="16dp"
+ android:visibility="gone">
+ android:id="@+id/login" />
+ </LinearLayout>
@@ -1,191 +1,191 @@
-<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:id="@+id/track"
- style="@style/Theme.Widget.CardView.Item"
- android:padding="0dp">
- <android.support.constraint.ConstraintLayout
- android:layout_height="wrap_content">
- <FrameLayout
- android:id="@+id/logo_container"
- android:layout_width="48dp"
- android:layout_height="0dp"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintLeft_toLeftOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- android:clickable="true"
- tools:background="#2E51A2">
- android:id="@+id/track_logo"
- android:layout_gravity="center"
- tools:src="@drawable/mal" />
- </FrameLayout>
- android:id="@+id/title_container"
- android:background="?attr/selectable_list_drawable"
- android:padding="16dp"
- app:layout_constraintLeft_toRightOf="@+id/logo_container"
- app:layout_constraintRight_toRightOf="parent"
- app:layout_constraintTop_toTopOf="parent">
- style="@style/TextAppearance.Regular.Body1"
- android:text="@string/title" />
- android:id="@+id/track_title"
- style="@style/TextAppearance.Medium.Button"
- android:layout_marginLeft="4dp"
- android:layout_marginStart="4dp"
- android:ellipsize="middle"
- android:gravity="end"
- android:text="@string/action_edit" />
- <View
- android:id="@+id/divider1"
- android:layout_height="1dp"
- android:layout_marginEnd="16dp"
- android:layout_marginLeft="16dp"
- android:layout_marginRight="16dp"
- android:layout_marginStart="16dp"
- android:background="?android:attr/divider"
- app:layout_constraintTop_toBottomOf="@+id/title_container" />
- android:id="@+id/status_container"
- app:layout_constraintTop_toBottomOf="@+id/divider1">
- android:text="@string/status" />
- android:id="@+id/track_status"
- style="@style/TextAppearance.Regular.Body1.Secondary"
- tools:text="Reading" />
- android:id="@+id/divider2"
- app:layout_constraintTop_toBottomOf="@+id/status_container" />
- android:id="@+id/chapters_container"
- app:layout_constraintTop_toBottomOf="@+id/divider2">
- android:text="@string/chapters" />
- android:id="@+id/track_chapters"
- tools:text="12/24" />
- android:id="@+id/divider3"
- app:layout_constraintTop_toBottomOf="@+id/chapters_container" />
- android:id="@+id/score_container"
- app:layout_constraintTop_toBottomOf="@+id/divider3">
- android:text="@string/score" />
- android:id="@+id/track_score"
- tools:text="10" />
- </android.support.constraint.ConstraintLayout>
+<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/track"
+ style="@style/Theme.Widget.CardView.Item"
+ android:padding="0dp">
+ <android.support.constraint.ConstraintLayout
+ android:layout_height="wrap_content">
+ <FrameLayout
+ android:id="@+id/logo_container"
+ android:layout_width="48dp"
+ android:layout_height="0dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintLeft_toLeftOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ android:clickable="true"
+ tools:background="#2E51A2">
+ android:id="@+id/track_logo"
+ android:layout_gravity="center"
+ tools:src="@drawable/mal" />
+ </FrameLayout>
+ android:id="@+id/title_container"
+ android:background="?attr/selectable_list_drawable"
+ android:padding="16dp"
+ app:layout_constraintLeft_toRightOf="@+id/logo_container"
+ app:layout_constraintRight_toRightOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+ style="@style/TextAppearance.Regular.Body1"
+ android:text="@string/title" />
+ android:id="@+id/track_title"
+ style="@style/TextAppearance.Medium.Button"
+ android:layout_marginLeft="4dp"
+ android:layout_marginStart="4dp"
+ android:ellipsize="middle"
+ android:gravity="end"
+ android:text="@string/action_edit" />
+ <View
+ android:id="@+id/divider1"
+ android:layout_height="1dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginLeft="16dp"
+ android:layout_marginRight="16dp"
+ android:layout_marginStart="16dp"
+ android:background="?android:attr/divider"
+ app:layout_constraintTop_toBottomOf="@+id/title_container" />
+ android:id="@+id/status_container"
+ app:layout_constraintTop_toBottomOf="@+id/divider1">
+ android:text="@string/status" />
+ android:id="@+id/track_status"
+ style="@style/TextAppearance.Regular.Body1.Secondary"
+ tools:text="Reading" />
+ android:id="@+id/divider2"
+ app:layout_constraintTop_toBottomOf="@+id/status_container" />
+ android:id="@+id/chapters_container"
+ app:layout_constraintTop_toBottomOf="@+id/divider2">
+ android:text="@string/chapters" />
+ android:id="@+id/track_chapters"
+ tools:text="12/24" />
+ android:id="@+id/divider3"
+ app:layout_constraintTop_toBottomOf="@+id/chapters_container" />
+ android:id="@+id/score_container"
+ app:layout_constraintTop_toBottomOf="@+id/divider3">
+ android:text="@string/score" />
+ android:id="@+id/track_score"
+ tools:text="10" />
+ </android.support.constraint.ConstraintLayout>
</android.support.v7.widget.CardView>