Update files to use unix line endings

cmd: `find . -type f -print0 | xargs -0 dos2unix`
This commit is contained in:
arkon 2020-01-05 14:43:07 -05:00
parent b4aedb5f84
commit 600fbb2ef8
95 changed files with 9766 additions and 9766 deletions

View file

@ -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"
}
}

View file

@ -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()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
.whereArgs(manga.id, sync.id)
.build())
.prepare()
package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.tables.TrackTable
import eu.kanade.tachiyomi.data.track.TrackService
interface TrackQueries : DbProvider {
fun getTracks(manga: Manga) = db.get()
.listOfObjects(Track::class.java)
.withQuery(Query.builder()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id)
.build())
.prepare()
fun insertTrack(track: Track) = db.put().`object`(track).prepare()
fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare()
fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
.byQuery(DeleteQuery.builder()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
.whereArgs(manga.id, sync.id)
.build())
.prepare()
}

View file

@ -1,132 +1,132 @@
package eu.kanade.tachiyomi.data.preference
/**
* This class stores the keys for the preferences in the application.
*/
object PreferenceKeys {
const val theme = "pref_theme_key"
const val rotation = "pref_rotation_type_key"
const val enableTransitions = "pref_enable_transitions_key"
const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
const val showPageNumber = "pref_show_page_number_key"
const val trueColor = "pref_true_color_key"
const val fullscreen = "fullscreen"
const val keepScreenOn = "pref_keep_screen_on_key"
const val customBrightness = "pref_custom_brightness_key"
const val customBrightnessValue = "custom_brightness_value"
const val colorFilter = "pref_color_filter_key"
const val colorFilterValue = "color_filter_value"
const val colorFilterMode = "color_filter_mode"
const val defaultViewer = "pref_default_viewer_key"
const val imageScaleType = "pref_image_scale_type_key"
const val zoomStart = "pref_zoom_start_key"
const val readerTheme = "pref_reader_theme_key"
const val cropBorders = "crop_borders"
const val cropBordersWebtoon = "crop_borders_webtoon"
const val readWithTapping = "reader_tap"
const val readWithLongTap = "reader_long_tap"
const val readWithVolumeKeys = "reader_volume_keys"
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
const val portraitColumns = "pref_library_columns_portrait_key"
const val landscapeColumns = "pref_library_columns_landscape_key"
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
const val lastUsedCatalogueSource = "last_catalogue_source"
const val lastUsedCategory = "last_used_category"
const val catalogueAsList = "pref_display_catalogue_as_list"
const val enabledLanguages = "source_languages"
const val backupDirectory = "backup_directory"
const val downloadsDirectory = "download_directory"
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
const val numberOfBackups = "backup_slots"
const val backupInterval = "backup_interval"
const val removeAfterReadSlots = "remove_after_read_slots"
const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key"
const val libraryUpdateInterval = "pref_library_update_interval_key"
const val libraryUpdateRestriction = "library_update_restriction"
const val libraryUpdateCategories = "library_update_categories"
const val libraryUpdatePrioritization = "library_update_prioritization"
const val filterDownloaded = "pref_filter_downloaded_key"
const val filterUnread = "pref_filter_unread_key"
const val filterCompleted = "pref_filter_completed_key"
const val librarySortingMode = "library_sorting_mode"
const val automaticUpdates = "automatic_updates"
const val startScreen = "start_screen"
const val downloadNew = "download_new"
const val downloadNewCategories = "download_new_categories"
const val libraryAsList = "pref_display_library_as_list"
const val lang = "app_language"
const val defaultCategory = "default_category"
const val skipRead = "skip_read"
const val downloadBadge = "display_download_badge"
@Deprecated("Use the preferences of the source")
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
@Deprecated("Use the preferences of the source")
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
fun trackToken(syncId: Int) = "track_token_$syncId"
}
package eu.kanade.tachiyomi.data.preference
/**
* This class stores the keys for the preferences in the application.
*/
object PreferenceKeys {
const val theme = "pref_theme_key"
const val rotation = "pref_rotation_type_key"
const val enableTransitions = "pref_enable_transitions_key"
const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
const val showPageNumber = "pref_show_page_number_key"
const val trueColor = "pref_true_color_key"
const val fullscreen = "fullscreen"
const val keepScreenOn = "pref_keep_screen_on_key"
const val customBrightness = "pref_custom_brightness_key"
const val customBrightnessValue = "custom_brightness_value"
const val colorFilter = "pref_color_filter_key"
const val colorFilterValue = "color_filter_value"
const val colorFilterMode = "color_filter_mode"
const val defaultViewer = "pref_default_viewer_key"
const val imageScaleType = "pref_image_scale_type_key"
const val zoomStart = "pref_zoom_start_key"
const val readerTheme = "pref_reader_theme_key"
const val cropBorders = "crop_borders"
const val cropBordersWebtoon = "crop_borders_webtoon"
const val readWithTapping = "reader_tap"
const val readWithLongTap = "reader_long_tap"
const val readWithVolumeKeys = "reader_volume_keys"
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
const val portraitColumns = "pref_library_columns_portrait_key"
const val landscapeColumns = "pref_library_columns_landscape_key"
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
const val lastUsedCatalogueSource = "last_catalogue_source"
const val lastUsedCategory = "last_used_category"
const val catalogueAsList = "pref_display_catalogue_as_list"
const val enabledLanguages = "source_languages"
const val backupDirectory = "backup_directory"
const val downloadsDirectory = "download_directory"
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
const val numberOfBackups = "backup_slots"
const val backupInterval = "backup_interval"
const val removeAfterReadSlots = "remove_after_read_slots"
const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key"
const val libraryUpdateInterval = "pref_library_update_interval_key"
const val libraryUpdateRestriction = "library_update_restriction"
const val libraryUpdateCategories = "library_update_categories"
const val libraryUpdatePrioritization = "library_update_prioritization"
const val filterDownloaded = "pref_filter_downloaded_key"
const val filterUnread = "pref_filter_unread_key"
const val filterCompleted = "pref_filter_completed_key"
const val librarySortingMode = "library_sorting_mode"
const val automaticUpdates = "automatic_updates"
const val startScreen = "start_screen"
const val downloadNew = "download_new"
const val downloadNewCategories = "download_new_categories"
const val libraryAsList = "pref_display_library_as_list"
const val lang = "app_language"
const val defaultCategory = "default_category"
const val skipRead = "skip_read"
const val downloadBadge = "display_download_badge"
@Deprecated("Use the preferences of the source")
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
@Deprecated("Use the preferences of the source")
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
fun trackToken(syncId: Int) = "track_token_$syncId"
}

View file

@ -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 }
}

View file

@ -1,70 +1,70 @@
package eu.kanade.tachiyomi.data.track
import androidx.annotation.CallSuper
import androidx.annotation.DrawableRes
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) {
val preferences: PreferencesHelper by injectLazy()
val networkService: NetworkHelper by injectLazy()
open val client: OkHttpClient
get() = networkService.client
// Name of the manga sync service to display
abstract val name: String
@DrawableRes
abstract fun getLogo(): Int
abstract fun getLogoColor(): Int
abstract fun getStatusList(): List<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)
}
}
package eu.kanade.tachiyomi.data.track
import androidx.annotation.CallSuper
import androidx.annotation.DrawableRes
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) {
val preferences: PreferencesHelper by injectLazy()
val networkService: NetworkHelper by injectLazy()
open val client: OkHttpClient
get() = networkService.client
// Name of the manga sync service to display
abstract val name: String
@DrawableRes
abstract fun getLogo(): Int
abstract fun getLogoColor(): Int
abstract fun getStatusList(): List<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)
}
}

View file

@ -1,214 +1,214 @@
package eu.kanade.tachiyomi.data.track.anilist
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Anilist(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val POINT_100 = "POINT_100"
const val POINT_10 = "POINT_10"
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
const val POINT_5 = "POINT_5"
const val POINT_3 = "POINT_3"
}
override val name = "AniList"
private val gson: Gson by injectLazy()
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
private val api by lazy { AnilistApi(client, interceptor) }
private val scorePreference = preferences.anilistScoreType()
init {
// If the preference is an int from APIv1, logout user to force using APIv2
try {
scorePreference.get()
} catch (e: ClassCastException) {
logout()
scorePreference.delete()
}
}
override fun getLogo() = R.drawable.al
override fun getLogoColor() = Color.rgb(18, 25, 35)
override fun getStatusList(): List<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 {
return when (scorePreference.getOrDefault()) {
// 10 point
POINT_10 -> index * 10f
// 100 point
POINT_100 -> index.toFloat()
// 5 stars
POINT_5 -> when {
index == 0 -> 0f
else -> index * 20f - 10f
}
// Smiley
POINT_3 -> when {
index == 0 -> 0f
else -> index * 25f + 10f
}
// 10 point decimal
POINT_10_DECIMAL -> index.toFloat()
else -> throw Exception("Unknown score type")
}
}
override fun displayScore(track: Track): String {
val score = track.score
return when (scorePreference.getOrDefault()) {
POINT_5 -> when {
score == 0f -> "0 ★"
else -> "${((score + 10) / 20).toInt()}"
}
POINT_3 -> when {
score == 0f -> "0"
score <= 35 -> "😦"
score <= 60 -> "😐"
else -> "😊"
}
else -> track.toAnilistScore()
}
}
override fun add(track: Track): Observable<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.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String) = login(password)
fun login(token: String): Completable {
val oauth = api.createOAuth(token)
interceptor.setAuth(oauth)
return api.getCurrentUser().map { (username, scoreType) ->
scorePreference.set(scoreType)
saveCredentials(username.toString(), oauth.access_token)
}.doOnError{
logout()
}.toCompletable()
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.setAuth(null)
}
fun saveOAuth(oAuth: OAuth?) {
preferences.trackToken(this).set(gson.toJson(oAuth))
}
fun loadOAuth(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
}
package eu.kanade.tachiyomi.data.track.anilist
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Anilist(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val POINT_100 = "POINT_100"
const val POINT_10 = "POINT_10"
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
const val POINT_5 = "POINT_5"
const val POINT_3 = "POINT_3"
}
override val name = "AniList"
private val gson: Gson by injectLazy()
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
private val api by lazy { AnilistApi(client, interceptor) }
private val scorePreference = preferences.anilistScoreType()
init {
// If the preference is an int from APIv1, logout user to force using APIv2
try {
scorePreference.get()
} catch (e: ClassCastException) {
logout()
scorePreference.delete()
}
}
override fun getLogo() = R.drawable.al
override fun getLogoColor() = Color.rgb(18, 25, 35)
override fun getStatusList(): List<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 {
return when (scorePreference.getOrDefault()) {
// 10 point
POINT_10 -> index * 10f
// 100 point
POINT_100 -> index.toFloat()
// 5 stars
POINT_5 -> when {
index == 0 -> 0f
else -> index * 20f - 10f
}
// Smiley
POINT_3 -> when {
index == 0 -> 0f
else -> index * 25f + 10f
}
// 10 point decimal
POINT_10_DECIMAL -> index.toFloat()
else -> throw Exception("Unknown score type")
}
}
override fun displayScore(track: Track): String {
val score = track.score
return when (scorePreference.getOrDefault()) {
POINT_5 -> when {
score == 0f -> "0 ★"
else -> "${((score + 10) / 20).toInt()}"
}
POINT_3 -> when {
score == 0f -> "0"
score <= 35 -> "😦"
score <= 60 -> "😐"
else -> "😊"
}
else -> track.toAnilistScore()
}
}
override fun add(track: Track): Observable<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.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String) = login(password)
fun login(token: String): Completable {
val oauth = api.createOAuth(token)
interceptor.setAuth(oauth)
return api.getCurrentUser().map { (username, scoreType) ->
scorePreference.set(scoreType)
saveCredentials(username.toString(), oauth.access_token)
}.doOnError{
logout()
}.toCompletable()
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.setAuth(null)
}
fun saveOAuth(oAuth: OAuth?) {
preferences.trackToken(this).set(gson.toJson(oAuth))
}
fun loadOAuth(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
}

View file

@ -1,291 +1,291 @@
package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.nullInt
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import rx.Observable
import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val parser = JsonParser()
private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<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
track
}
}
fun updateLibManga(track: Track): Observable<Track> {
val query = """
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|id
|status
|progress
|}
|}
|""".trimMargin()
val variables = jsonObject(
"listId" to track.library_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus(),
"score" to track.score.toInt()
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun search(search: String): Observable<List<TrackSearch>> {
val query = """
|query Search(${'$'}query: String) {
|Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|id
|title {
|romaji
|}
|coverImage {
|large
|}
|type
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|}
|}
|}
|""".trimMargin()
val variables = jsonObject(
"query" to search
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["media"].array
val entries = media.map { jsonToALManga(it.obj) }
entries.map { it.toTrack() }
}
}
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
val query = """
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|Page {
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|id
|status
|scoreRaw: score(format: POINT_100)
|progress
|media {
|id
|title {
|romaji
|}
|coverImage {
|large
|}
|type
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|}
|}
|}
|}
|""".trimMargin()
val variables = jsonObject(
"id" to userid,
"manga_id" to track.media_id
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) }
entries.firstOrNull()?.toTrack()
}
}
fun getLibManga(track: Track, userid: Int): Observable<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>> {
val query = """
|query User {
|Viewer {
|id
|mediaListOptions {
|scoreFormat
|}
|}
|}
|""".trimMargin()
val payload = jsonObject(
"query" to query
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val viewer = data["Viewer"].obj
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
}
}
private fun jsonToALManga(struct: JsonObject): ALManga {
val date = try {
val date = Calendar.getInstance()
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0)
date.timeInMillis
} catch (_: Exception) {
0L
}
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
date, struct["chapters"].nullInt ?: 0)
}
private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj))
}
companion object {
private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/"
fun mangaUrl(mediaId: Int): String {
return baseMangaUrl + mediaId
}
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "token")
.build()
}
}
package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.nullInt
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import rx.Observable
import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val parser = JsonParser()
private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<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
track
}
}
fun updateLibManga(track: Track): Observable<Track> {
val query = """
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|id
|status
|progress
|}
|}
|""".trimMargin()
val variables = jsonObject(
"listId" to track.library_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus(),
"score" to track.score.toInt()
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun search(search: String): Observable<List<TrackSearch>> {
val query = """
|query Search(${'$'}query: String) {
|Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|id
|title {
|romaji
|}
|coverImage {
|large
|}
|type
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|}
|}
|}
|""".trimMargin()
val variables = jsonObject(
"query" to search
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["media"].array
val entries = media.map { jsonToALManga(it.obj) }
entries.map { it.toTrack() }
}
}
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
val query = """
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|Page {
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|id
|status
|scoreRaw: score(format: POINT_100)
|progress
|media {
|id
|title {
|romaji
|}
|coverImage {
|large
|}
|type
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|}
|}
|}
|}
|""".trimMargin()
val variables = jsonObject(
"id" to userid,
"manga_id" to track.media_id
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) }
entries.firstOrNull()?.toTrack()
}
}
fun getLibManga(track: Track, userid: Int): Observable<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>> {
val query = """
|query User {
|Viewer {
|id
|mediaListOptions {
|scoreFormat
|}
|}
|}
|""".trimMargin()
val payload = jsonObject(
"query" to query
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val viewer = data["Viewer"].obj
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
}
}
private fun jsonToALManga(struct: JsonObject): ALManga {
val date = try {
val date = Calendar.getInstance()
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0)
date.timeInMillis
} catch (_: Exception) {
0L
}
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
date, struct["chapters"].nullInt ?: 0)
}
private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj))
}
companion object {
private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/"
fun mangaUrl(mediaId: Int): String {
return baseMangaUrl + mediaId
}
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "token")
.build()
}
}

View file

@ -1,58 +1,58 @@
package eu.kanade.tachiyomi.data.track.anilist
import okhttp3.Interceptor
import okhttp3.Response
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
/**
* OAuth object used for authenticated requests.
*
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date.
*/
private var oauth: OAuth? = null
set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
if (token.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist")
}
if (oauth == null){
oauth = anilist.loadOAuth()
}
// Refresh access token if null or expired.
if (oauth!!.isExpired()) {
anilist.logout()
throw Exception("Token expired")
}
// Throw on null auth.
if (oauth == null) {
throw Exception("No authentication token")
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build()
return chain.proceed(authRequest)
}
/**
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
token = oauth?.access_token
this.oauth = oauth
anilist.saveOAuth(oauth)
}
package eu.kanade.tachiyomi.data.track.anilist
import okhttp3.Interceptor
import okhttp3.Response
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
/**
* OAuth object used for authenticated requests.
*
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date.
*/
private var oauth: OAuth? = null
set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
if (token.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist")
}
if (oauth == null){
oauth = anilist.loadOAuth()
}
// Refresh access token if null or expired.
if (oauth!!.isExpired()) {
anilist.logout()
throw Exception("Token expired")
}
// Throw on null auth.
if (oauth == null) {
throw Exception("No authentication token")
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build()
return chain.proceed(authRequest)
}
/**
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
token = oauth?.access_token
this.oauth = oauth
anilist.saveOAuth(oauth)
}
}

View file

@ -1,10 +1,10 @@
package eu.kanade.tachiyomi.data.track.anilist
data class OAuth(
val access_token: String,
val token_type: String,
val expires: Long,
val expires_in: Long) {
fun isExpired() = System.currentTimeMillis() > expires
package eu.kanade.tachiyomi.data.track.anilist
data class OAuth(
val access_token: String,
val token_type: String,
val expires: Long,
val expires_in: Long) {
fun isExpired() = System.currentTimeMillis() > expires
}

View file

@ -1,144 +1,144 @@
package eu.kanade.tachiyomi.data.track.bangumi
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
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
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
api.findLibManga(track).flatMap { remoteTrack ->
if (remoteTrack != null && it != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
track.status = remoteTrack.status
track.last_chapter_read = remoteTrack.last_chapter_read
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
update(track)
}
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
track.copyPersonalFrom(it!!)
api.findLibManga(track)
.map { remoteTrack ->
if (remoteTrack != null) {
track.total_chapters = remoteTrack.total_chapters
track.status = remoteTrack.status
}
track
}
}
}
companion object {
const val READING = 3
const val COMPLETED = 2
const val ON_HOLD = 4
const val DROPPED = 5
const val PLANNING = 1
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Bangumi"
private val gson: Gson by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this, gson) }
private val api by lazy { BangumiApi(client, interceptor) }
override fun getLogo() = R.drawable.bangumi
override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLANNING -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth)
if (oauth != null) {
saveCredentials(oauth.user_id.toString(), oauth.access_token)
}
}.doOnError {
logout()
}.toCompletable()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
}
}
package eu.kanade.tachiyomi.data.track.bangumi
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
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
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
api.findLibManga(track).flatMap { remoteTrack ->
if (remoteTrack != null && it != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
track.status = remoteTrack.status
track.last_chapter_read = remoteTrack.last_chapter_read
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
update(track)
}
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
track.copyPersonalFrom(it!!)
api.findLibManga(track)
.map { remoteTrack ->
if (remoteTrack != null) {
track.total_chapters = remoteTrack.total_chapters
track.status = remoteTrack.status
}
track
}
}
}
companion object {
const val READING = 3
const val COMPLETED = 2
const val ON_HOLD = 4
const val DROPPED = 5
const val PLANNING = 1
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Bangumi"
private val gson: Gson by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this, gson) }
private val api by lazy { BangumiApi(client, interceptor) }
override fun getLogo() = R.drawable.bangumi
override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLANNING -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth)
if (oauth != null) {
saveCredentials(oauth.user_id.toString(), oauth.access_token)
}
}.doOnError {
logout()
}.toCompletable()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
}
}

View file

@ -1,16 +1,16 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?,
val user_id: Long?
) {
// Access token refersh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}
package eu.kanade.tachiyomi.data.track.bangumi
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?,
val user_id: Long?
) {
// Access token refersh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View file

@ -1,144 +1,144 @@
package eu.kanade.tachiyomi.data.track.kitsu
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0f
}
override val name = "Kitsu"
private val gson: Gson by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this, gson) }
private val api by lazy { KitsuApi(client, interceptor) }
override fun getLogo(): Int {
return R.drawable.kitsu
}
override fun getLogoColor(): Int {
return Color.rgb(51, 37, 50)
}
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun getScoreList(): List<String> {
val df = DecimalFormat("0.#")
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
}
override fun indexToScore(index: Int): Float {
return if (index > 0) (index + 1) / 2f else 0f
}
override fun displayScore(track: Track): String {
val df = DecimalFormat("0.#")
return df.format(track.score)
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUserId())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUserId())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id
update(track)
} else {
track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String): Completable {
return api.login(username, password)
.doOnNext { interceptor.newAuth(it) }
.flatMap { api.getCurrentUser() }
.doOnNext { userId -> saveCredentials(username, userId) }
.doOnError { logout() }
.toCompletable()
}
override fun logout() {
super.logout()
interceptor.newAuth(null)
}
private fun getUserId(): String {
return getPassword()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
}
package eu.kanade.tachiyomi.data.track.kitsu
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0f
}
override val name = "Kitsu"
private val gson: Gson by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this, gson) }
private val api by lazy { KitsuApi(client, interceptor) }
override fun getLogo(): Int {
return R.drawable.kitsu
}
override fun getLogoColor(): Int {
return Color.rgb(51, 37, 50)
}
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun getScoreList(): List<String> {
val df = DecimalFormat("0.#")
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
}
override fun indexToScore(index: Int): Float {
return if (index > 0) (index + 1) / 2f else 0f
}
override fun displayScore(track: Track): String {
val df = DecimalFormat("0.#")
return df.format(track.score)
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUserId())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUserId())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id
update(track)
} else {
track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String): Completable {
return api.login(username, password)
.doOnNext { interceptor.newAuth(it) }
.flatMap { api.getCurrentUser() }
.doOnNext { userId -> saveCredentials(username, userId) }
.doOnError { logout() }
.toCompletable()
}
override fun logout() {
super.logout()
interceptor.newAuth(null)
}
private fun getUserId(): String {
return getPassword()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
}

View file

@ -1,11 +1,11 @@
package eu.kanade.tachiyomi.data.track.kitsu
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
package eu.kanade.tachiyomi.data.track.kitsu
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View file

@ -1,163 +1,163 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import android.content.Context
import android.graphics.Color
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import rx.Completable
import rx.Observable
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in"
}
private val interceptor by lazy { MyAnimeListInterceptor(this) }
private val api by lazy { MyanimelistApi(client, interceptor) }
override val name: String
get() = "MyAnimeList"
override fun getLogo() = R.drawable.mal
override fun getLogoColor() = Color.rgb(46, 81, 162)
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
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
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track)
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String): Completable {
logout()
return Observable.fromCallable { api.login(username, password) }
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
}
fun refreshLogin() {
val username = getUsername()
val password = getPassword()
logout()
try {
val csrf = api.login(username, password)
saveCSRF(csrf)
saveCredentials(username, password)
} catch (e: Exception) {
logout()
throw e
}
}
// Attempt to login again if cookies have been cleared but credentials are still filled
fun ensureLoggedIn() {
if (isAuthorized) return
if (!isLogged) throw Exception("MAL Login Credentials not found")
refreshLogin()
}
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!)
}
val isAuthorized: Boolean
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
private fun checkCookies(): Boolean {
var ckCount = 0
val url = BASE_URL.toHttpUrlOrNull()!!
for (ck in networkService.cookieManager.get(url)) {
if (ck.name == USER_SESSION_COOKIE || ck.name == LOGGED_IN_COOKIE)
ckCount++
}
return ckCount == 2
}
}
package eu.kanade.tachiyomi.data.track.myanimelist
import android.content.Context
import android.graphics.Color
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import rx.Completable
import rx.Observable
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in"
}
private val interceptor by lazy { MyAnimeListInterceptor(this) }
private val api by lazy { MyanimelistApi(client, interceptor) }
override val name: String
get() = "MyAnimeList"
override fun getLogo() = R.drawable.mal
override fun getLogoColor() = Color.rgb(46, 81, 162)
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
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
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track)
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String): Completable {
logout()
return Observable.fromCallable { api.login(username, password) }
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
}
fun refreshLogin() {
val username = getUsername()
val password = getPassword()
logout()
try {
val csrf = api.login(username, password)
saveCSRF(csrf)
saveCredentials(username, password)
} catch (e: Exception) {
logout()
throw e
}
}
// Attempt to login again if cookies have been cleared but credentials are still filled
fun ensureLoggedIn() {
if (isAuthorized) return
if (!isLogged) throw Exception("MAL Login Credentials not found")
refreshLogin()
}
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!)
}
val isAuthorized: Boolean
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
private fun checkCookies(): Boolean {
var ckCount = 0
val url = BASE_URL.toHttpUrlOrNull()!!
for (ck in networkService.cookieManager.get(url)) {
if (ck.name == USER_SESSION_COOKIE || ck.name == LOGGED_IN_COOKIE)
ckCount++
}
return ckCount == 2
}
}

View file

@ -1,13 +1,13 @@
package eu.kanade.tachiyomi.data.track.shikimori
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
// Access token lives 1 day
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}
package eu.kanade.tachiyomi.data.track.shikimori
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
// Access token lives 1 day
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View file

@ -1,139 +1,139 @@
package eu.kanade.tachiyomi.data.track.shikimori
import android.content.Context
import android.graphics.Color
import android.util.Log
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUsername())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track, getUsername())
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.map { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
}
track
}
}
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Shikimori"
private val gson: Gson by injectLazy()
private val interceptor by lazy { ShikimoriInterceptor(this, gson) }
private val api by lazy { ShikimoriApi(client, interceptor) }
override fun getLogo() = R.drawable.shikimori
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getStatusList(): List<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 login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth)
if (oauth != null) {
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
}
}.doOnError {
logout()
}.toCompletable()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
}
}
package eu.kanade.tachiyomi.data.track.shikimori
import android.content.Context
import android.graphics.Color
import android.util.Log
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUsername())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track, getUsername())
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.map { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
}
track
}
}
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Shikimori"
private val gson: Gson by injectLazy()
private val interceptor by lazy { ShikimoriInterceptor(this, gson) }
private val api by lazy { ShikimoriApi(client, interceptor) }
override fun getLogo() = R.drawable.shikimori
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getStatusList(): List<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 login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth)
if (oauth != null) {
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
}
}.doOnError {
logout()
}.toCompletable()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
}
}

View file

@ -1,154 +1,154 @@
package eu.kanade.tachiyomi.network
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import eu.kanade.tachiyomi.util.WebViewClientCompat
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class CloudflareInterceptor(private val context: Context) : Interceptor {
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
private val handler = Handler(Looper.getMainLooper())
/**
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
* blocking the main thread too much. If used too often we could consider moving it to the
* Application class.
*/
private val initWebView by lazy {
if (Build.VERSION.SDK_INT >= 17) {
WebSettings.getDefaultUserAgent(context)
} else {
null
}
}
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
initWebView
val response = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on
if (response.code == 503 && response.header("Server") in serverCheck) {
try {
response.close()
val solutionRequest = resolveWithWebView(chain.request())
return chain.proceed(solutionRequest)
} catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
throw IOException(e)
}
}
return response
}
private fun isChallengeSolutionUrl(url: String): Boolean {
return "chk_jschl" in url
}
@SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(request: Request): Request {
// We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
var webView: WebView? = null
var solutionUrl: String? = null
var challengeFound = false
val origRequestUrl = request.url.toString()
val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
handler.post {
val view = WebView(context)
webView = view
view.settings.javaScriptEnabled = true
view.settings.userAgentString = request.header("User-Agent")
view.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
if (isChallengeSolutionUrl(url)) {
solutionUrl = url
latch.countDown()
}
return solutionUrl != null
}
override fun shouldInterceptRequestCompat(
view: WebView,
url: String
): WebResourceResponse? {
if (solutionUrl != null) {
// Intercept any request when we have the solution.
return WebResourceResponse("text/plain", "UTF-8", null)
}
return null
}
override fun onPageFinished(view: WebView, url: String) {
// Http error codes are only received since M
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
url == origRequestUrl && !challengeFound
) {
// The first request didn't return the challenge, abort.
latch.countDown()
}
}
override fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean
) {
if (isMainFrame) {
if (errorCode == 503) {
// Found the cloudflare challenge page.
challengeFound = true
} else {
// Unlock thread, the challenge wasn't found.
latch.countDown()
}
}
}
}
webView?.loadUrl(origRequestUrl, headers)
}
// Wait a reasonable amount of time to retrieve the solution. The minimum should be
// around 4 seconds but it can take more due to slow networks or server issues.
latch.await(12, TimeUnit.SECONDS)
handler.post {
webView?.stopLoading()
webView?.destroy()
}
val solution = solutionUrl ?: throw Exception("Challenge not found")
return Request.Builder().get()
.url(solution)
.headers(request.headers)
.addHeader("Referer", origRequestUrl)
.addHeader("Accept", "text/html,application/xhtml+xml,application/xml")
.addHeader("Accept-Language", "en")
.build()
}
}
package eu.kanade.tachiyomi.network
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import eu.kanade.tachiyomi.util.WebViewClientCompat
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class CloudflareInterceptor(private val context: Context) : Interceptor {
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
private val handler = Handler(Looper.getMainLooper())
/**
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
* blocking the main thread too much. If used too often we could consider moving it to the
* Application class.
*/
private val initWebView by lazy {
if (Build.VERSION.SDK_INT >= 17) {
WebSettings.getDefaultUserAgent(context)
} else {
null
}
}
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
initWebView
val response = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on
if (response.code == 503 && response.header("Server") in serverCheck) {
try {
response.close()
val solutionRequest = resolveWithWebView(chain.request())
return chain.proceed(solutionRequest)
} catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
throw IOException(e)
}
}
return response
}
private fun isChallengeSolutionUrl(url: String): Boolean {
return "chk_jschl" in url
}
@SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(request: Request): Request {
// We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
var webView: WebView? = null
var solutionUrl: String? = null
var challengeFound = false
val origRequestUrl = request.url.toString()
val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
handler.post {
val view = WebView(context)
webView = view
view.settings.javaScriptEnabled = true
view.settings.userAgentString = request.header("User-Agent")
view.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
if (isChallengeSolutionUrl(url)) {
solutionUrl = url
latch.countDown()
}
return solutionUrl != null
}
override fun shouldInterceptRequestCompat(
view: WebView,
url: String
): WebResourceResponse? {
if (solutionUrl != null) {
// Intercept any request when we have the solution.
return WebResourceResponse("text/plain", "UTF-8", null)
}
return null
}
override fun onPageFinished(view: WebView, url: String) {
// Http error codes are only received since M
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
url == origRequestUrl && !challengeFound
) {
// The first request didn't return the challenge, abort.
latch.countDown()
}
}
override fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean
) {
if (isMainFrame) {
if (errorCode == 503) {
// Found the cloudflare challenge page.
challengeFound = true
} else {
// Unlock thread, the challenge wasn't found.
latch.countDown()
}
}
}
}
webView?.loadUrl(origRequestUrl, headers)
}
// Wait a reasonable amount of time to retrieve the solution. The minimum should be
// around 4 seconds but it can take more due to slow networks or server issues.
latch.await(12, TimeUnit.SECONDS)
handler.post {
webView?.stopLoading()
webView?.destroy()
}
val solution = solutionUrl ?: throw Exception("Challenge not found")
return Request.Builder().get()
.url(solution)
.headers(request.headers)
.addHeader("Referer", origRequestUrl)
.addHeader("Accept", "text/html,application/xhtml+xml,application/xml")
.addHeader("Accept-Language", "en")
.build()
}
}

View file

@ -1,117 +1,117 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import android.os.Build
import okhttp3.*
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.Socket
import java.net.UnknownHostException
import java.security.KeyManagementException
import java.security.KeyStore
import java.security.NoSuchAlgorithmException
import javax.net.ssl.*
class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
val cookieManager = AndroidCookieJar(context)
val client = OkHttpClient.Builder()
.cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize))
.enableTLS12()
.build()
val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor(context))
.build()
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
return this
}
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(null as KeyStore?)
val trustManagers = trustManagerFactory.trustManagers
if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
constructor() : SSLSocketFactory() {
private val internalSSLSocketFactory: SSLSocketFactory
init {
val context = SSLContext.getInstance("TLS")
context.init(null, null, null)
internalSSLSocketFactory = context.socketFactory
}
override fun getDefaultCipherSuites(): Array<String> {
return internalSSLSocketFactory.defaultCipherSuites
}
override fun getSupportedCipherSuites(): Array<String> {
return internalSSLSocketFactory.supportedCipherSuites
}
@Throws(IOException::class)
override fun createSocket(): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
}
@Throws(IOException::class)
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose))
}
@Throws(IOException::class, UnknownHostException::class)
override fun createSocket(host: String, port: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
}
@Throws(IOException::class, UnknownHostException::class)
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
}
@Throws(IOException::class)
override fun createSocket(host: InetAddress, port: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
}
@Throws(IOException::class)
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort))
}
private fun enableTLSOnSocket(socket: Socket?): Socket? {
if (socket != null && socket is SSLSocket) {
socket.enabledProtocols = socket.supportedProtocols
}
return socket
}
}
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
}
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
.cipherSuites(
*ConnectionSpec.MODERN_TLS.cipherSuites.orEmpty().toTypedArray(),
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
)
.build()
val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
connectionSpecs(specs)
return this
}
}
package eu.kanade.tachiyomi.network
import android.content.Context
import android.os.Build
import okhttp3.*
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.Socket
import java.net.UnknownHostException
import java.security.KeyManagementException
import java.security.KeyStore
import java.security.NoSuchAlgorithmException
import javax.net.ssl.*
class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
val cookieManager = AndroidCookieJar(context)
val client = OkHttpClient.Builder()
.cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize))
.enableTLS12()
.build()
val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor(context))
.build()
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
return this
}
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(null as KeyStore?)
val trustManagers = trustManagerFactory.trustManagers
if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
constructor() : SSLSocketFactory() {
private val internalSSLSocketFactory: SSLSocketFactory
init {
val context = SSLContext.getInstance("TLS")
context.init(null, null, null)
internalSSLSocketFactory = context.socketFactory
}
override fun getDefaultCipherSuites(): Array<String> {
return internalSSLSocketFactory.defaultCipherSuites
}
override fun getSupportedCipherSuites(): Array<String> {
return internalSSLSocketFactory.supportedCipherSuites
}
@Throws(IOException::class)
override fun createSocket(): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
}
@Throws(IOException::class)
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose))
}
@Throws(IOException::class, UnknownHostException::class)
override fun createSocket(host: String, port: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
}
@Throws(IOException::class, UnknownHostException::class)
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
}
@Throws(IOException::class)
override fun createSocket(host: InetAddress, port: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
}
@Throws(IOException::class)
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort))
}
private fun enableTLSOnSocket(socket: Socket?): Socket? {
if (socket != null && socket is SSLSocket) {
socket.enabledProtocols = socket.supportedProtocols
}
return socket
}
}
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
}
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
.cipherSuites(
*ConnectionSpec.MODERN_TLS.cipherSuites.orEmpty().toTypedArray(),
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
)
.build()
val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
connectionSpecs(specs)
return this
}
}

View file

@ -1,70 +1,70 @@
package eu.kanade.tachiyomi.network
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import rx.Producer
import rx.Subscription
import java.util.concurrent.atomic.AtomicBoolean
fun Call.asObservable(): Observable<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
try {
val response = call.execute()
if (!subscriber.isUnsubscribed) {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (error: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(error)
}
}
}
override fun unsubscribe() {
call.cancel()
}
override fun isUnsubscribed(): Boolean {
return call.isCanceled()
}
}
subscriber.add(requestArbiter)
subscriber.setProducer(requestArbiter)
}
}
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw Exception("HTTP error ${response.code}")
}
}
}
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body!!, listener))
.build()
}
.build()
return progressClient.newCall(request)
}
package eu.kanade.tachiyomi.network
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import rx.Producer
import rx.Subscription
import java.util.concurrent.atomic.AtomicBoolean
fun Call.asObservable(): Observable<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
try {
val response = call.execute()
if (!subscriber.isUnsubscribed) {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (error: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(error)
}
}
}
override fun unsubscribe() {
call.cancel()
}
override fun isUnsubscribed(): Boolean {
return call.isCanceled()
}
}
subscriber.add(requestArbiter)
subscriber.setProducer(requestArbiter)
}
}
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw Exception("HTTP error ${response.code}")
}
}
}
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body!!, listener))
.build()
}
.build()
return progressClient.newCall(request)
}

View file

@ -1,5 +1,5 @@
package eu.kanade.tachiyomi.network
interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
package eu.kanade.tachiyomi.network
interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
}

View file

@ -1,40 +1,40 @@
package eu.kanade.tachiyomi.network
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.*
import java.io.IOException
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy {
source(responseBody.source()).buffer()
}
override fun contentType(): MediaType {
return responseBody.contentType()!!
}
override fun contentLength(): Long {
return responseBody.contentLength()
}
override fun source(): BufferedSource {
return bufferedSource
}
private fun source(source: Source): Source {
return object : ForwardingSource(source) {
var totalBytesRead = 0L
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
}
package eu.kanade.tachiyomi.network
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.*
import java.io.IOException
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy {
source(responseBody.source()).buffer()
}
override fun contentType(): MediaType {
return responseBody.contentType()!!
}
override fun contentLength(): Long {
return responseBody.contentLength()
}
override fun source(): BufferedSource {
return bufferedSource
}
private fun source(source: Source): Source {
return object : ForwardingSource(source) {
var totalBytesRead = 0L
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
}

View file

@ -1,32 +1,32 @@
package eu.kanade.tachiyomi.network
import okhttp3.*
import java.util.concurrent.TimeUnit.MINUTES
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build()
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
fun GET(url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
return Request.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
}
fun POST(url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
return Request.Builder()
.url(url)
.post(body)
.headers(headers)
.cacheControl(cache)
.build()
}
package eu.kanade.tachiyomi.network
import okhttp3.*
import java.util.concurrent.TimeUnit.MINUTES
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build()
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
fun GET(url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
return Request.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
}
fun POST(url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
return Request.Builder()
.url(url)
.post(body)
.headers(headers)
.cacheControl(cache)
.build()
}

View file

@ -1,46 +1,46 @@
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import rx.Observable
interface CatalogueSource : Source {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
val lang: String
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
*/
fun fetchPopularManga(page: Int): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
fun fetchLatestUpdates(page: Int): Observable<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
import rx.Observable
interface CatalogueSource : Source {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
val lang: String
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
*/
fun fetchPopularManga(page: Int): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
/**
* Returns the list of filters for the source.
*/
fun getFilterList(): FilterList
}

View file

@ -1,44 +1,44 @@
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable
/**
* A basic interface for creating a source. It could be an online source, a local source, etc...
*/
interface Source {
/**
* Id for the source. Must be unique.
*/
val id: Long
/**
* Name of the source.
*/
val name: String
/**
* Returns an observable with the updated details for a manga.
*
* @param manga the manga to update.
*/
fun fetchMangaDetails(manga: SManga): Observable<SManga>
/**
* Returns an observable with all the available chapters for a manga.
*
* @param manga the manga to update.
*/
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>>
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable
/**
* A basic interface for creating a source. It could be an online source, a local source, etc...
*/
interface Source {
/**
* Id for the source. Must be unique.
*/
val id: Long
/**
* Name of the source.
*/
val name: String
/**
* Returns an observable with the updated details for a manga.
*
* @param manga the manga to update.
*/
fun fetchMangaDetails(manga: SManga): Observable<SManga>
/**
* Returns an observable with all the available chapters for a manga.
*
* @param manga the manga to update.
*/
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>>
}

View file

@ -1,74 +1,74 @@
package eu.kanade.tachiyomi.source
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import rx.Observable
open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
init {
createInternalSources().forEach { registerSource(it) }
}
open fun get(sourceKey: Long): Source? {
return sourcesMap[sourceKey]
}
fun getOrStub(sourceKey: Long): Source {
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
StubSource(sourceKey)
}
}
fun getOnlineSources() = sourcesMap.values.filterIsInstance<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 {
override val name: String
get() = id.toString()
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(getSourceNotInstalledException())
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(getSourceNotInstalledException())
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(getSourceNotInstalledException())
}
override fun toString(): String {
return name
}
private fun getSourceNotInstalledException(): Exception {
return Exception(context.getString(R.string.source_not_installed, id.toString()))
}
}
}
package eu.kanade.tachiyomi.source
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import rx.Observable
open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
init {
createInternalSources().forEach { registerSource(it) }
}
open fun get(sourceKey: Long): Source? {
return sourcesMap[sourceKey]
}
fun getOrStub(sourceKey: Long): Source {
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
StubSource(sourceKey)
}
}
fun getOnlineSources() = sourcesMap.values.filterIsInstance<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 {
override val name: String
get() = id.toString()
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(getSourceNotInstalledException())
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(getSourceNotInstalledException())
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(getSourceNotInstalledException())
}
override fun toString(): String {
return name
}
private fun getSourceNotInstalledException(): Exception {
return Exception(context.getString(R.string.source_not_installed, id.toString()))
}
}
}

View file

@ -1,40 +1,40 @@
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
companion object {
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
companion object {
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
}
}

View file

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.source.model
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
package eu.kanade.tachiyomi.source.model
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
}

View file

@ -1,3 +1,3 @@
package eu.kanade.tachiyomi.source.model
package eu.kanade.tachiyomi.source.model
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)

View file

@ -1,48 +1,48 @@
package eu.kanade.tachiyomi.source.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import rx.subjects.Subject
open class Page(
val index: Int,
val url: String = "",
var imageUrl: String? = null,
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener {
val number: Int
get() = index + 1
@Transient @Volatile var status: Int = 0
set(value) {
field = value
statusSubject?.onNext(value)
}
@Transient @Volatile var progress: Int = 0
@Transient private var statusSubject: Subject<Int, Int>? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
}
}
package eu.kanade.tachiyomi.source.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import rx.subjects.Subject
open class Page(
val index: Int,
val url: String = "",
var imageUrl: String? = null,
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener {
val number: Int
get() = index + 1
@Transient @Volatile var status: Int = 0
set(value) {
field = value
statusSubject?.onNext(value)
}
@Transient @Volatile var progress: Int = 0
@Transient private var statusSubject: Subject<Int, Int>? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
}
}

View file

@ -1,31 +1,31 @@
package eu.kanade.tachiyomi.source.model
import java.io.Serializable
interface SChapter : Serializable {
var url: String
var name: String
var date_upload: Long
var chapter_number: Float
var scanlator: String?
fun copyFrom(other: SChapter) {
name = other.name
url = other.url
date_upload = other.date_upload
chapter_number = other.chapter_number
scanlator = other.scanlator
}
companion object {
fun create(): SChapter {
return SChapterImpl()
}
}
package eu.kanade.tachiyomi.source.model
import java.io.Serializable
interface SChapter : Serializable {
var url: String
var name: String
var date_upload: Long
var chapter_number: Float
var scanlator: String?
fun copyFrom(other: SChapter) {
name = other.name
url = other.url
date_upload = other.date_upload
chapter_number = other.chapter_number
scanlator = other.scanlator
}
companion object {
fun create(): SChapter {
return SChapterImpl()
}
}
}

View file

@ -1,15 +1,15 @@
package eu.kanade.tachiyomi.source.model
class SChapterImpl : SChapter {
override lateinit var url: String
override lateinit var name: String
override var date_upload: Long = 0
override var chapter_number: Float = -1f
override var scanlator: String? = null
package eu.kanade.tachiyomi.source.model
class SChapterImpl : SChapter {
override lateinit var url: String
override lateinit var name: String
override var date_upload: Long = 0
override var chapter_number: Float = -1f
override var scanlator: String? = null
}

View file

@ -1,58 +1,58 @@
package eu.kanade.tachiyomi.source.model
import java.io.Serializable
interface SManga : Serializable {
var url: String
var title: String
var artist: String?
var author: String?
var description: String?
var genre: String?
var status: Int
var thumbnail_url: String?
var initialized: Boolean
fun copyFrom(other: SManga) {
if (other.author != null)
author = other.author
if (other.artist != null)
artist = other.artist
if (other.description != null)
description = other.description
if (other.genre != null)
genre = other.genre
if (other.thumbnail_url != null)
thumbnail_url = other.thumbnail_url
status = other.status
if (!initialized)
initialized = other.initialized
}
companion object {
const val UNKNOWN = 0
const val ONGOING = 1
const val COMPLETED = 2
const val LICENSED = 3
fun create(): SManga {
return SMangaImpl()
}
}
package eu.kanade.tachiyomi.source.model
import java.io.Serializable
interface SManga : Serializable {
var url: String
var title: String
var artist: String?
var author: String?
var description: String?
var genre: String?
var status: Int
var thumbnail_url: String?
var initialized: Boolean
fun copyFrom(other: SManga) {
if (other.author != null)
author = other.author
if (other.artist != null)
artist = other.artist
if (other.description != null)
description = other.description
if (other.genre != null)
genre = other.genre
if (other.thumbnail_url != null)
thumbnail_url = other.thumbnail_url
status = other.status
if (!initialized)
initialized = other.initialized
}
companion object {
const val UNKNOWN = 0
const val ONGOING = 1
const val COMPLETED = 2
const val LICENSED = 3
fun create(): SManga {
return SMangaImpl()
}
}
}

View file

@ -1,23 +1,23 @@
package eu.kanade.tachiyomi.source.model
class SMangaImpl : SManga {
override lateinit var url: String
override lateinit var title: String
override var artist: String? = null
override var author: String? = null
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var initialized: Boolean = false
package eu.kanade.tachiyomi.source.model
class SMangaImpl : SManga {
override lateinit var url: String
override lateinit var title: String
override var artist: String? = null
override var author: String? = null
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var initialized: Boolean = false
}

View file

@ -1,367 +1,367 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.*
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.lang.Exception
import java.net.URI
import java.net.URISyntaxException
import java.security.MessageDigest
/**
* A simple implementation for sources from a website.
*/
abstract class HttpSource : CatalogueSource {
/**
* Network service.
*/
protected val network: NetworkHelper by injectLazy()
// /**
// * Preferences that a source may need.
// */
// val preferences: SharedPreferences by lazy {
// Injekt.get<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.
*/
open val client: OkHttpClient
get() = network.client
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
open protected fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
}
/**
* Visible name of the source.
*/
override fun toString() = "$name (${lang.toUpperCase()})"
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
*/
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page))
.asObservableSuccess()
.map { response ->
popularMangaParse(response)
}
}
/**
* Returns the request for the popular manga given the page.
*
* @param page the page number to retrieve.
*/
abstract protected fun popularMangaRequest(page: Int): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
abstract protected fun popularMangaParse(response: Response): MangasPage
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
searchMangaParse(response)
}
}
/**
* Returns the request for the search manga given the page.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
abstract protected fun searchMangaParse(response: Response): MangasPage
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess()
.map { response ->
latestUpdatesParse(response)
}
}
/**
* Returns the request for latest manga given the page.
*
* @param page the page number to retrieve.
*/
abstract protected fun latestUpdatesRequest(page: Int): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
abstract protected fun latestUpdatesParse(response: Response): MangasPage
/**
* Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to be updated.
*/
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
/**
* Returns the request for the details of a manga. Override only if it's needed to change the
* url, send different headers or request method like POST.
*
* @param manga the manga to be updated.
*/
open fun mangaDetailsRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
abstract protected fun mangaDetailsParse(response: Response): SManga
/**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned
*
* @param manga the manga to look for chapters.
*/
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
if (manga.status != SManga.LICENSED) {
return client.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response ->
chapterListParse(response)
}
} else {
return Observable.error(Exception("Licensed - No chapters to show"))
}
}
/**
* Returns the request for updating the chapter list. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param manga the manga to look for chapters.
*/
open protected fun chapterListRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
abstract protected fun chapterListParse(response: Response): List<SChapter>
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return client.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
pageListParse(response)
}
}
/**
* Returns the request for getting the page list. Override only if it's needed to override the
* url, send different headers or request method like POST.
*
* @param chapter the chapter whose page list has to be fetched.
*/
open protected fun pageListRequest(chapter: SChapter): Request {
return GET(baseUrl + chapter.url, headers)
}
/**
* Parses the response from the site and returns a list of pages.
*
* @param response the response from the site.
*/
abstract protected fun pageListParse(response: Response): List<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))
.asObservableSuccess()
.map { imageUrlParse(it) }
}
/**
* Returns the request for getting the url to the source image. Override only if it's needed to
* override the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
open protected fun imageUrlRequest(page: Page): Request {
return GET(page.url, headers)
}
/**
* Parses the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
abstract protected fun imageUrlParse(response: Response): String
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
fun fetchImage(page: Page): Observable<Response> {
return client.newCallWithProgress(imageRequest(page), page)
.asObservableSuccess()
}
/**
* Returns the request for getting the source image. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
open protected fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers)
}
/**
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the chapter.
*/
fun SChapter.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the manga.
*/
fun SManga.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Returns the url of the given string without the scheme and domain.
*
* @param orig the full url.
*/
private fun getUrlWithoutDomain(orig: String): String {
try {
val uri = URI(orig)
var out = uri.path
if (uri.query != null)
out += "?" + uri.query
if (uri.fragment != null)
out += "#" + uri.fragment
return out
} catch (e: URISyntaxException) {
return orig
}
}
/**
* Called before inserting a new chapter into database. Use it if you need to override chapter
* fields, like the title or the chapter number. Do not change anything to [manga].
*
* @param chapter the chapter to be added.
* @param manga the manga of the chapter.
*/
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {
}
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = FilterList()
}
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.*
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.lang.Exception
import java.net.URI
import java.net.URISyntaxException
import java.security.MessageDigest
/**
* A simple implementation for sources from a website.
*/
abstract class HttpSource : CatalogueSource {
/**
* Network service.
*/
protected val network: NetworkHelper by injectLazy()
// /**
// * Preferences that a source may need.
// */
// val preferences: SharedPreferences by lazy {
// Injekt.get<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.
*/
open val client: OkHttpClient
get() = network.client
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
open protected fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
}
/**
* Visible name of the source.
*/
override fun toString() = "$name (${lang.toUpperCase()})"
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
*/
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page))
.asObservableSuccess()
.map { response ->
popularMangaParse(response)
}
}
/**
* Returns the request for the popular manga given the page.
*
* @param page the page number to retrieve.
*/
abstract protected fun popularMangaRequest(page: Int): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
abstract protected fun popularMangaParse(response: Response): MangasPage
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
searchMangaParse(response)
}
}
/**
* Returns the request for the search manga given the page.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
abstract protected fun searchMangaParse(response: Response): MangasPage
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess()
.map { response ->
latestUpdatesParse(response)
}
}
/**
* Returns the request for latest manga given the page.
*
* @param page the page number to retrieve.
*/
abstract protected fun latestUpdatesRequest(page: Int): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
abstract protected fun latestUpdatesParse(response: Response): MangasPage
/**
* Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to be updated.
*/
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
/**
* Returns the request for the details of a manga. Override only if it's needed to change the
* url, send different headers or request method like POST.
*
* @param manga the manga to be updated.
*/
open fun mangaDetailsRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
abstract protected fun mangaDetailsParse(response: Response): SManga
/**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned
*
* @param manga the manga to look for chapters.
*/
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
if (manga.status != SManga.LICENSED) {
return client.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response ->
chapterListParse(response)
}
} else {
return Observable.error(Exception("Licensed - No chapters to show"))
}
}
/**
* Returns the request for updating the chapter list. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param manga the manga to look for chapters.
*/
open protected fun chapterListRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
abstract protected fun chapterListParse(response: Response): List<SChapter>
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return client.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
pageListParse(response)
}
}
/**
* Returns the request for getting the page list. Override only if it's needed to override the
* url, send different headers or request method like POST.
*
* @param chapter the chapter whose page list has to be fetched.
*/
open protected fun pageListRequest(chapter: SChapter): Request {
return GET(baseUrl + chapter.url, headers)
}
/**
* Parses the response from the site and returns a list of pages.
*
* @param response the response from the site.
*/
abstract protected fun pageListParse(response: Response): List<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))
.asObservableSuccess()
.map { imageUrlParse(it) }
}
/**
* Returns the request for getting the url to the source image. Override only if it's needed to
* override the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
open protected fun imageUrlRequest(page: Page): Request {
return GET(page.url, headers)
}
/**
* Parses the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
abstract protected fun imageUrlParse(response: Response): String
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
fun fetchImage(page: Page): Observable<Response> {
return client.newCallWithProgress(imageRequest(page), page)
.asObservableSuccess()
}
/**
* Returns the request for getting the source image. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
open protected fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers)
}
/**
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the chapter.
*/
fun SChapter.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the manga.
*/
fun SManga.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Returns the url of the given string without the scheme and domain.
*
* @param orig the full url.
*/
private fun getUrlWithoutDomain(orig: String): String {
try {
val uri = URI(orig)
var out = uri.path
if (uri.query != null)
out += "?" + uri.query
if (uri.fragment != null)
out += "#" + uri.fragment
return out
} catch (e: URISyntaxException) {
return orig
}
}
/**
* Called before inserting a new chapter into database. Use it if you need to override chapter
* fields, like the title or the chapter number. Do not change anything to [manga].
*
* @param chapter the chapter to be added.
* @param manga the manga of the chapter.
*/
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {
}
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = FilterList()
}

View file

@ -1,25 +1,25 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
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> {
return Observable.from(pages)
.filter { it.imageUrl.isNullOrEmpty() }
.concatMap { getImageUrl(it) }
}
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
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> {
return Observable.from(pages)
.filter { it.imageUrl.isNullOrEmpty() }
.concatMap { getImageUrl(it) }
}

View file

@ -1,15 +1,15 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
import okhttp3.Response
import rx.Observable
interface LoginSource : Source {
fun isLogged(): Boolean
fun login(username: String, password: String): Observable<Boolean>
fun isAuthenticationSuccessful(response: Response): Boolean
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
import okhttp3.Response
import rx.Observable
interface LoginSource : Source {
fun isLogged(): Boolean
fun login(username: String, password: String): Observable<Boolean>
fun isAuthenticationSuccessful(response: Response): Boolean
}

View file

@ -1,200 +1,200 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
/**
* A simple implementation for sources from a website using Jsoup, an HTML parser.
*/
abstract class ParsedHttpSource : HttpSource() {
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element)
}
val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
abstract protected fun popularMangaSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [popularMangaSelector].
*/
abstract protected fun popularMangaFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
abstract protected fun popularMangaNextPageSelector(): String?
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element)
}
val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
abstract protected fun searchMangaSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [searchMangaSelector].
*/
abstract protected fun searchMangaFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
abstract protected fun searchMangaNextPageSelector(): String?
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element)
}
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
abstract protected fun latestUpdatesSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [latestUpdatesSelector].
*/
abstract protected fun latestUpdatesFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
abstract protected fun latestUpdatesNextPageSelector(): String?
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
override fun mangaDetailsParse(response: Response): SManga {
return mangaDetailsParse(response.asJsoup())
}
/**
* Returns the details of the manga from the given [document].
*
* @param document the parsed document.
*/
abstract protected fun mangaDetailsParse(document: Document): SManga
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
return document.select(chapterListSelector()).map { chapterFromElement(it) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
*/
abstract protected fun chapterListSelector(): String
/**
* Returns a chapter from the given element.
*
* @param element an element obtained from [chapterListSelector].
*/
abstract protected fun chapterFromElement(element: Element): SChapter
/**
* Parses the response from the site and returns the page list.
*
* @param response the response from the site.
*/
override fun pageListParse(response: Response): List<Page> {
return pageListParse(response.asJsoup())
}
/**
* Returns a page list from the given document.
*
* @param document the parsed document.
*/
abstract protected fun pageListParse(document: Document): List<Page>
/**
* Parse the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
override fun imageUrlParse(response: Response): String {
return imageUrlParse(response.asJsoup())
}
/**
* Returns the absolute url to the source image from the document.
*
* @param document the parsed document.
*/
abstract protected fun imageUrlParse(document: Document): String
}
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
/**
* A simple implementation for sources from a website using Jsoup, an HTML parser.
*/
abstract class ParsedHttpSource : HttpSource() {
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element)
}
val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
abstract protected fun popularMangaSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [popularMangaSelector].
*/
abstract protected fun popularMangaFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
abstract protected fun popularMangaNextPageSelector(): String?
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element)
}
val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
abstract protected fun searchMangaSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [searchMangaSelector].
*/
abstract protected fun searchMangaFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
abstract protected fun searchMangaNextPageSelector(): String?
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element)
}
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
abstract protected fun latestUpdatesSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [latestUpdatesSelector].
*/
abstract protected fun latestUpdatesFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
abstract protected fun latestUpdatesNextPageSelector(): String?
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
override fun mangaDetailsParse(response: Response): SManga {
return mangaDetailsParse(response.asJsoup())
}
/**
* Returns the details of the manga from the given [document].
*
* @param document the parsed document.
*/
abstract protected fun mangaDetailsParse(document: Document): SManga
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
return document.select(chapterListSelector()).map { chapterFromElement(it) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
*/
abstract protected fun chapterListSelector(): String
/**
* Returns a chapter from the given element.
*
* @param element an element obtained from [chapterListSelector].
*/
abstract protected fun chapterFromElement(element: Element): SChapter
/**
* Parses the response from the site and returns the page list.
*
* @param response the response from the site.
*/
override fun pageListParse(response: Response): List<Page> {
return pageListParse(response.asJsoup())
}
/**
* Returns a page list from the given document.
*
* @param document the parsed document.
*/
abstract protected fun pageListParse(document: Document): List<Page>
/**
* Parse the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
override fun imageUrlParse(response: Response): String {
return imageUrlParse(response.asJsoup())
}
/**
* Returns the absolute url to the source image from the document.
*
* @param document the parsed document.
*/
abstract protected fun imageUrlParse(document: Document): String
}

View file

@ -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
init {
addLifecycleListener(NucleusConductorLifecycleListener(delegate))
}
}
package eu.kanade.tachiyomi.ui.base.controller
import android.os.Bundle
import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate
import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener
import nucleus.factory.PresenterFactory
import nucleus.presenter.Presenter
@Suppress("LeakingThis")
abstract class NucleusController<P : Presenter<*>>(val bundle: Bundle? = null) : RxController(bundle),
PresenterFactory<P> {
private val delegate = NucleusConductorDelegate(this)
val presenter: P
get() = delegate.presenter
init {
addLifecycleListener(NucleusConductorLifecycleListener(delegate))
}
}

View file

@ -1,61 +1,61 @@
package eu.kanade.tachiyomi.ui.base.presenter;
import android.os.Bundle;
import androidx.annotation.Nullable;
import nucleus.factory.PresenterFactory;
import nucleus.presenter.Presenter;
public class NucleusConductorDelegate<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();
if (presenter != null) {
//noinspection unchecked
presenter.takeView(view);
}
}
void onDropView() {
if (presenter != null) {
presenter.dropView();
}
}
void onDestroy() {
if (presenter != null) {
presenter.destroy();
}
}
}
package eu.kanade.tachiyomi.ui.base.presenter;
import android.os.Bundle;
import androidx.annotation.Nullable;
import nucleus.factory.PresenterFactory;
import nucleus.presenter.Presenter;
public class NucleusConductorDelegate<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();
if (presenter != null) {
//noinspection unchecked
presenter.takeView(view);
}
}
void onDropView() {
if (presenter != null) {
presenter.dropView();
}
}
void onDestroy() {
if (presenter != null) {
presenter.destroy();
}
}
}

View file

@ -1,44 +1,44 @@
package eu.kanade.tachiyomi.ui.base.presenter;
import android.os.Bundle;
import androidx.annotation.NonNull;
import android.view.View;
import com.bluelinelabs.conductor.Controller;
public class NucleusConductorLifecycleListener extends Controller.LifecycleListener {
private static final String PRESENTER_STATE_KEY = "presenter_state";
private NucleusConductorDelegate delegate;
public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) {
this.delegate = delegate;
}
@Override
public void postCreateView(@NonNull Controller controller, @NonNull View view) {
delegate.onTakeView(controller);
}
@Override
public void preDestroyView(@NonNull Controller controller, @NonNull View view) {
delegate.onDropView();
}
@Override
public void preDestroy(@NonNull Controller controller) {
delegate.onDestroy();
}
@Override
public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) {
outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState());
}
@Override
public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) {
delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY));
}
}
package eu.kanade.tachiyomi.ui.base.presenter;
import android.os.Bundle;
import androidx.annotation.NonNull;
import android.view.View;
import com.bluelinelabs.conductor.Controller;
public class NucleusConductorLifecycleListener extends Controller.LifecycleListener {
private static final String PRESENTER_STATE_KEY = "presenter_state";
private NucleusConductorDelegate delegate;
public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) {
this.delegate = delegate;
}
@Override
public void postCreateView(@NonNull Controller controller, @NonNull View view) {
delegate.onTakeView(controller);
}
@Override
public void preDestroyView(@NonNull Controller controller, @NonNull View view) {
delegate.onDropView();
}
@Override
public void preDestroy(@NonNull Controller controller) {
delegate.onDestroy();
}
@Override
public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) {
outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState());
}
@Override
public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) {
delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY));
}
}

View file

@ -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
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as TriStateSectionItem).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
}
class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable<TextItem.Holder, GroupItem> {
private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) {
head = header
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as TextSectionItem).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
}
class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable<CheckboxItem.Holder, GroupItem> {
private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) {
head = header
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as CheckboxSectionItem).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
}
class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable<SelectItem.Holder, GroupItem> {
private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) {
head = header
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as SelectSectionItem).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
}
package eu.kanade.tachiyomi.ui.catalogue.filter
import eu.davidea.flexibleadapter.items.ISectionable
import eu.kanade.tachiyomi.source.model.Filter
class TriStateSectionItem(filter: Filter.TriState) : TriStateItem(filter), ISectionable<TriStateItem.Holder, GroupItem> {
private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) {
head = header
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as TriStateSectionItem).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
}
class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable<TextItem.Holder, GroupItem> {
private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) {
head = header
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as TextSectionItem).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
}
class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable<CheckboxItem.Holder, GroupItem> {
private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) {
head = header
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as CheckboxSectionItem).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
}
class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable<SelectItem.Holder, GroupItem> {
private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) {
head = header
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as SelectSectionItem).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
}

View file

@ -1,54 +1,54 @@
package eu.kanade.tachiyomi.ui.catalogue.filter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.flexibleadapter.items.ISectionable
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.util.setVectorCompat
class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem<SortGroup.Holder, ISectionable<*, *>>() {
init {
isExpanded = false
}
override fun getLayoutRes(): Int {
return R.layout.navigation_view_group
}
override fun getItemViewType(): Int {
return 100
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): Holder {
return Holder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, 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)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as SortGroup).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter)
}
package eu.kanade.tachiyomi.ui.catalogue.filter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.flexibleadapter.items.ISectionable
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.util.setVectorCompat
class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem<SortGroup.Holder, ISectionable<*, *>>() {
init {
isExpanded = false
}
override fun getLayoutRes(): Int {
return R.layout.navigation_view_group
}
override fun getItemViewType(): Int {
return 100
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): Holder {
return Holder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, 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)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return filter == (other as SortGroup).filter
}
override fun hashCode(): Int {
return filter.hashCode()
}
class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter)
}

View file

@ -1,28 +1,28 @@
package eu.kanade.tachiyomi.ui.catalogue.global_search
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* Adapter that holds the manga items from search results.
*
* @param controller instance of [CatalogueSearchController].
*/
class CatalogueSearchCardAdapter(controller: CatalogueSearchController) :
FlexibleAdapter<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
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* Adapter that holds the manga items from search results.
*
* @param controller instance of [CatalogueSearchController].
*/
class CatalogueSearchCardAdapter(controller: CatalogueSearchController) :
FlexibleAdapter<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)
}
}

View file

@ -1,52 +1,52 @@
package eu.kanade.tachiyomi.ui.catalogue.global_search
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.widget.StateImageViewTarget
import kotlinx.android.synthetic.main.catalogue_global_search_controller_card_item.*
class CatalogueSearchCardHolder(view: View, adapter: CatalogueSearchCardAdapter)
: BaseFlexibleViewHolder(view, adapter) {
init {
// Call onMangaClickListener when item is pressed.
itemView.setOnClickListener {
val item = adapter.getItem(adapterPosition)
if (item != null) {
adapter.mangaClickListener.onMangaClick(item.manga)
}
}
itemView.setOnLongClickListener {
val item = adapter.getItem(adapterPosition)
if (item != null) {
adapter.mangaClickListener.onMangaLongClick(item.manga)
}
true
}
}
fun bind(manga: Manga) {
tvTitle.text = manga.title
// Set alpha of thumbnail.
itemImage.alpha = if (manga.favorite) 0.3f else 1.0f
setImage(manga)
}
fun setImage(manga: Manga) {
GlideApp.with(itemView.context).clear(itemImage)
if (!manga.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(itemView.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.centerCrop()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent)
.into(StateImageViewTarget(itemImage, progress))
}
}
package eu.kanade.tachiyomi.ui.catalogue.global_search
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.widget.StateImageViewTarget
import kotlinx.android.synthetic.main.catalogue_global_search_controller_card_item.*
class CatalogueSearchCardHolder(view: View, adapter: CatalogueSearchCardAdapter)
: BaseFlexibleViewHolder(view, adapter) {
init {
// Call onMangaClickListener when item is pressed.
itemView.setOnClickListener {
val item = adapter.getItem(adapterPosition)
if (item != null) {
adapter.mangaClickListener.onMangaClick(item.manga)
}
}
itemView.setOnLongClickListener {
val item = adapter.getItem(adapterPosition)
if (item != null) {
adapter.mangaClickListener.onMangaLongClick(item.manga)
}
true
}
}
fun bind(manga: Manga) {
tvTitle.text = manga.title
// Set alpha of thumbnail.
itemImage.alpha = if (manga.favorite) 0.3f else 1.0f
setImage(manga)
}
fun setImage(manga: Manga) {
GlideApp.with(itemView.context).clear(itemImage)
if (!manga.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(itemView.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.centerCrop()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent)
.into(StateImageViewTarget(itemImage, progress))
}
}
}

View file

@ -1,37 +1,37 @@
package eu.kanade.tachiyomi.ui.catalogue.global_search
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem<CatalogueSearchCardHolder>() {
override fun getLayoutRes(): Int {
return R.layout.catalogue_global_search_controller_card_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): CatalogueSearchCardHolder {
return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: CatalogueSearchCardHolder,
position: Int, payloads: List<Any?>?) {
holder.bind(manga)
}
override fun equals(other: Any?): Boolean {
if (other is CatalogueSearchCardItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id?.toInt() ?: 0
}
}
package eu.kanade.tachiyomi.ui.catalogue.global_search
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem<CatalogueSearchCardHolder>() {
override fun getLayoutRes(): Int {
return R.layout.catalogue_global_search_controller_card_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): CatalogueSearchCardHolder {
return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: CatalogueSearchCardHolder,
position: Int, payloads: List<Any?>?) {
holder.bind(manga)
}
override fun equals(other: Any?): Boolean {
if (other is CatalogueSearchCardItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id?.toInt() ?: 0
}
}

View file

@ -1,247 +1,247 @@
package eu.kanade.tachiyomi.ui.download
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.*
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import kotlinx.android.synthetic.main.download_controller.*
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import java.util.*
import java.util.concurrent.TimeUnit
/**
* Controller that shows the currently active downloads.
* Uses R.layout.fragment_download_queue.
*/
class DownloadController : NucleusController<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
init {
setHasOptionsMenu(true)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.download_controller, container, false)
}
override fun createPresenter(): DownloadPresenter {
return DownloadPresenter()
}
override fun getTitle(): String? {
return resources?.getString(R.string.label_download_queue)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Check if download queue is empty and update information accordingly.
setInformationView()
// Initialize adapter.
adapter = DownloadAdapter()
recycler.adapter = adapter
// Set the layout manager for the recycler and fixed size.
recycler.layoutManager = LinearLayoutManager(view.context)
recycler.setHasFixedSize(true)
// Suscribe to changes
DownloadService.runningRelay
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onQueueStatusChange(it) }
presenter.getDownloadStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onStatusChange(it) }
presenter.getDownloadProgressObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onUpdateDownloadedPages(it) }
}
override fun onDestroyView(view: View) {
for (subscription in progressSubscriptions.values) {
subscription.unsubscribe()
}
progressSubscriptions.clear()
adapter = null
super.onDestroyView(view)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.download_queue, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Set start button visibility.
menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
// Set pause button visibility.
menu.findItem(R.id.pause_queue).isVisible = isRunning
// Set clear button visibility.
menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val context = applicationContext ?: return false
when (item.itemId) {
R.id.start_queue -> DownloadService.start(context)
R.id.pause_queue -> {
DownloadService.stop(context)
presenter.pauseDownloads()
}
R.id.clear_queue -> {
DownloadService.stop(context)
presenter.clearQueue()
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
/**
* Called when the status of a download changes.
*
* @param download the download whose status has changed.
*/
private fun onStatusChange(download: Download) {
when (download.status) {
Download.DOWNLOADING -> {
observeProgress(download)
// Initial update of the downloaded pages
onUpdateDownloadedPages(download)
}
Download.DOWNLOADED -> {
unsubscribeProgress(download)
onUpdateProgress(download)
onUpdateDownloadedPages(download)
}
Download.ERROR -> unsubscribeProgress(download)
}
}
/**
* Observe the progress of a download and notify the view.
*
* @param download the download to observe its progress.
*/
private fun observeProgress(download: Download) {
val subscription = Observable.interval(50, TimeUnit.MILLISECONDS)
// Get the sum of percentages for all the pages.
.flatMap {
Observable.from(download.pages)
.map(Page::progress)
.reduce { x, y -> x + y }
}
// Keep only the latest emission to avoid backpressure.
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { progress ->
// Update the view only if the progress has changed.
if (download.totalProgress != progress) {
download.totalProgress = progress
onUpdateProgress(download)
}
}
// Avoid leaking subscriptions
progressSubscriptions.remove(download)?.unsubscribe()
progressSubscriptions.put(download, subscription)
}
/**
* Unsubscribes the given download from the progress subscriptions.
*
* @param download the download to unsubscribe.
*/
private fun unsubscribeProgress(download: Download) {
progressSubscriptions.remove(download)?.unsubscribe()
}
/**
* Called when the queue's status has changed. Updates the visibility of the buttons.
*
* @param running whether the queue is now running or not.
*/
private fun onQueueStatusChange(running: Boolean) {
isRunning = running
activity?.invalidateOptionsMenu()
// Check if download queue is empty and update information accordingly.
setInformationView()
}
/**
* Called from the presenter to assign the downloads for the adapter.
*
* @param downloads the downloads from the queue.
*/
fun onNextDownloads(downloads: List<Download>) {
activity?.invalidateOptionsMenu()
setInformationView()
adapter?.setItems(downloads)
}
/**
* Called when the progress of a download changes.
*
* @param download the download whose progress has changed.
*/
fun onUpdateProgress(download: Download) {
getHolder(download)?.notifyProgress()
}
/**
* Called when a page of a download is downloaded.
*
* @param download the download whose page has been downloaded.
*/
fun onUpdateDownloadedPages(download: Download) {
getHolder(download)?.notifyDownloadedPages()
}
/**
* Returns the holder for the given download.
*
* @param download the download to find.
* @return the holder of the download or null if it's not bound.
*/
private fun getHolder(download: Download): DownloadHolder? {
return recycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
}
/**
* Set information view when queue is empty
*/
private fun setInformationView() {
if (presenter.downloadQueue.isEmpty()) {
empty_view?.show(R.drawable.ic_file_download_black_128dp,
R.string.information_no_downloads)
} else {
empty_view?.hide()
}
}
}
package eu.kanade.tachiyomi.ui.download
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.*
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import kotlinx.android.synthetic.main.download_controller.*
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import java.util.*
import java.util.concurrent.TimeUnit
/**
* Controller that shows the currently active downloads.
* Uses R.layout.fragment_download_queue.
*/
class DownloadController : NucleusController<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
init {
setHasOptionsMenu(true)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.download_controller, container, false)
}
override fun createPresenter(): DownloadPresenter {
return DownloadPresenter()
}
override fun getTitle(): String? {
return resources?.getString(R.string.label_download_queue)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Check if download queue is empty and update information accordingly.
setInformationView()
// Initialize adapter.
adapter = DownloadAdapter()
recycler.adapter = adapter
// Set the layout manager for the recycler and fixed size.
recycler.layoutManager = LinearLayoutManager(view.context)
recycler.setHasFixedSize(true)
// Suscribe to changes
DownloadService.runningRelay
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onQueueStatusChange(it) }
presenter.getDownloadStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onStatusChange(it) }
presenter.getDownloadProgressObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onUpdateDownloadedPages(it) }
}
override fun onDestroyView(view: View) {
for (subscription in progressSubscriptions.values) {
subscription.unsubscribe()
}
progressSubscriptions.clear()
adapter = null
super.onDestroyView(view)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.download_queue, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Set start button visibility.
menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
// Set pause button visibility.
menu.findItem(R.id.pause_queue).isVisible = isRunning
// Set clear button visibility.
menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val context = applicationContext ?: return false
when (item.itemId) {
R.id.start_queue -> DownloadService.start(context)
R.id.pause_queue -> {
DownloadService.stop(context)
presenter.pauseDownloads()
}
R.id.clear_queue -> {
DownloadService.stop(context)
presenter.clearQueue()
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
/**
* Called when the status of a download changes.
*
* @param download the download whose status has changed.
*/
private fun onStatusChange(download: Download) {
when (download.status) {
Download.DOWNLOADING -> {
observeProgress(download)
// Initial update of the downloaded pages
onUpdateDownloadedPages(download)
}
Download.DOWNLOADED -> {
unsubscribeProgress(download)
onUpdateProgress(download)
onUpdateDownloadedPages(download)
}
Download.ERROR -> unsubscribeProgress(download)
}
}
/**
* Observe the progress of a download and notify the view.
*
* @param download the download to observe its progress.
*/
private fun observeProgress(download: Download) {
val subscription = Observable.interval(50, TimeUnit.MILLISECONDS)
// Get the sum of percentages for all the pages.
.flatMap {
Observable.from(download.pages)
.map(Page::progress)
.reduce { x, y -> x + y }
}
// Keep only the latest emission to avoid backpressure.
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { progress ->
// Update the view only if the progress has changed.
if (download.totalProgress != progress) {
download.totalProgress = progress
onUpdateProgress(download)
}
}
// Avoid leaking subscriptions
progressSubscriptions.remove(download)?.unsubscribe()
progressSubscriptions.put(download, subscription)
}
/**
* Unsubscribes the given download from the progress subscriptions.
*
* @param download the download to unsubscribe.
*/
private fun unsubscribeProgress(download: Download) {
progressSubscriptions.remove(download)?.unsubscribe()
}
/**
* Called when the queue's status has changed. Updates the visibility of the buttons.
*
* @param running whether the queue is now running or not.
*/
private fun onQueueStatusChange(running: Boolean) {
isRunning = running
activity?.invalidateOptionsMenu()
// Check if download queue is empty and update information accordingly.
setInformationView()
}
/**
* Called from the presenter to assign the downloads for the adapter.
*
* @param downloads the downloads from the queue.
*/
fun onNextDownloads(downloads: List<Download>) {
activity?.invalidateOptionsMenu()
setInformationView()
adapter?.setItems(downloads)
}
/**
* Called when the progress of a download changes.
*
* @param download the download whose progress has changed.
*/
fun onUpdateProgress(download: Download) {
getHolder(download)?.notifyProgress()
}
/**
* Called when a page of a download is downloaded.
*
* @param download the download whose page has been downloaded.
*/
fun onUpdateDownloadedPages(download: Download) {
getHolder(download)?.notifyDownloadedPages()
}
/**
* Returns the holder for the given download.
*
* @param download the download to find.
* @return the holder of the download or null if it's not bound.
*/
private fun getHolder(download: Download): DownloadHolder? {
return recycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
}
/**
* Set information view when queue is empty
*/
private fun setInformationView() {
if (presenter.downloadQueue.isEmpty()) {
empty_view?.show(R.drawable.ic_file_download_black_128dp,
R.string.information_no_downloads)
} else {
empty_view?.hide()
}
}
}

View file

@ -1,48 +1,48 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ChangeMangaCategoriesDialog<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)
true
}
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.build()
}
interface Listener {
fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>)
}
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ChangeMangaCategoriesDialog<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)
true
}
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.build()
}
interface Listener {
fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>)
}
}

View file

@ -1,43 +1,43 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.DialogCheckboxView
class DeleteLibraryMangasDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener {
private var mangas = emptyList<Manga>()
constructor(target: T, mangas: List<Manga>) : this() {
this.mangas = mangas
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val view = DialogCheckboxView(activity!!).apply {
setDescription(R.string.confirm_delete_manga)
setOptionDescription(R.string.also_delete_chapters)
}
return MaterialDialog.Builder(activity!!)
.title(R.string.action_remove)
.customView(view, true)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { _, _ ->
val deleteChapters = view.isChecked()
(targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters)
}
.build()
}
interface Listener {
fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean)
}
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.DialogCheckboxView
class DeleteLibraryMangasDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener {
private var mangas = emptyList<Manga>()
constructor(target: T, mangas: List<Manga>) : this() {
this.mangas = mangas
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val view = DialogCheckboxView(activity!!).apply {
setDescription(R.string.confirm_delete_manga)
setOptionDescription(R.string.also_delete_chapters)
}
return MaterialDialog.Builder(activity!!)
.title(R.string.action_remove)
.customView(view, true)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { _, _ ->
val deleteChapters = view.isChecked()
(targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters)
}
.build()
}
interface Listener {
fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean)
}
}

View file

@ -1,103 +1,103 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
/**
* This adapter stores the categories from the library, used with a ViewPager.
*
* @constructor creates an instance of the adapter.
*/
class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() {
/**
* The categories to bind in the adapter.
*/
var categories: List<Category> = emptyList()
// This setter helps to not refresh the adapter if the reference to the list doesn't change.
set(value) {
if (field !== value) {
field = value
notifyDataSetChanged()
}
}
private var boundViews = arrayListOf<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.
* @param position the position in the adapter.
*/
override fun recycleView(view: View, position: Int) {
(view as LibraryCategoryView).onRecycle()
boundViews.remove(view)
}
/**
* Returns the number of categories.
*
* @return the number of categories or 0 if the list is null.
*/
override fun getCount(): Int {
return categories.size
}
/**
* Returns the title to display for a category.
*
* @param position the position of the element.
* @return the title to display.
*/
override fun getPageTitle(position: Int): CharSequence {
return categories[position].name
}
/**
* Returns the position of the view.
*/
override fun getItemPosition(obj: Any): Int {
val view = obj as? LibraryCategoryView ?: return POSITION_NONE
val index = categories.indexOfFirst { it.id == view.category.id }
return if (index == -1) POSITION_NONE else index
}
/**
* Called when the view of this adapter is being destroyed.
*/
fun onDestroy() {
for (view in boundViews) {
if (view is LibraryCategoryView) {
view.unsubscribe()
}
}
}
package eu.kanade.tachiyomi.ui.library
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
/**
* This adapter stores the categories from the library, used with a ViewPager.
*
* @constructor creates an instance of the adapter.
*/
class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() {
/**
* The categories to bind in the adapter.
*/
var categories: List<Category> = emptyList()
// This setter helps to not refresh the adapter if the reference to the list doesn't change.
set(value) {
if (field !== value) {
field = value
notifyDataSetChanged()
}
}
private var boundViews = arrayListOf<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.
* @param position the position in the adapter.
*/
override fun recycleView(view: View, position: Int) {
(view as LibraryCategoryView).onRecycle()
boundViews.remove(view)
}
/**
* Returns the number of categories.
*
* @return the number of categories or 0 if the list is null.
*/
override fun getCount(): Int {
return categories.size
}
/**
* Returns the title to display for a category.
*
* @param position the position of the element.
* @return the title to display.
*/
override fun getPageTitle(position: Int): CharSequence {
return categories[position].name
}
/**
* Returns the position of the view.
*/
override fun getItemPosition(obj: Any): Int {
val view = obj as? LibraryCategoryView ?: return POSITION_NONE
val index = categories.indexOfFirst { it.id == view.category.id }
return if (index == -1) POSITION_NONE else index
}
/**
* Called when the view of this adapter is being destroyed.
*/
fun onDestroy() {
for (view in boundViews) {
if (view is LibraryCategoryView) {
view.unsubscribe()
}
}
}
}

View file

@ -1,48 +1,48 @@
package eu.kanade.tachiyomi.ui.library
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* Adapter storing a list of manga in a certain category.
*
* @param view the fragment containing this adapter.
*/
class LibraryCategoryAdapter(view: LibraryCategoryView) :
FlexibleAdapter<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() {
var s = getFilter(String::class.java)
if (s == null) {
s = ""
}
updateDataSet(mangas.filter { it.filter(s) })
}
}
package eu.kanade.tachiyomi.ui.library
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* Adapter storing a list of manga in a certain category.
*
* @param view the fragment containing this adapter.
*/
class LibraryCategoryAdapter(view: LibraryCategoryView) :
FlexibleAdapter<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() {
var s = getFilter(String::class.java)
if (s == null) {
s = ""
}
updateDataSet(mangas.filter { it.filter(s) })
}
}

View file

@ -1,248 +1,248 @@
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.library_category.view.*
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
/**
* Fragment containing the library manga for a certain category.
*/
class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FrameLayout(context, attrs),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener {
/**
* Preferences.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* The fragment containing this view.
*/
private lateinit var controller: LibraryController
/**
* Category for this view.
*/
lateinit var category: Category
private set
/**
* Recycler view of the list of manga.
*/
private lateinit var recycler: RecyclerView
/**
* Adapter to hold the manga in this category.
*/
private lateinit var adapter: LibraryCategoryAdapter
/**
* Subscriptions while the view is bound.
*/
private var subscriptions = CompositeSubscription()
fun onCreate(controller: LibraryController) {
this.controller = controller
recycler = if (preferences.libraryAsList().getOrDefault()) {
(swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply {
layoutManager = LinearLayoutManager(context)
}
} else {
(swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
spanCount = controller.mangaPerRow
}
}
adapter = LibraryCategoryAdapter(this)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
swipe_refresh.addView(recycler)
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) {
// Disable swipe refresh when view is not at the top
val firstPos = (recycler.layoutManager as LinearLayoutManager)
.findFirstCompletelyVisibleItemPosition()
swipe_refresh.isEnabled = firstPos <= 0
}
})
// Double the distance required to trigger sync
swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
swipe_refresh.setOnRefreshListener {
if (!LibraryUpdateService.isRunning(context)) {
LibraryUpdateService.start(context, category)
context.toast(R.string.updating_category)
}
// It can be a very long operation, so we disable swipe refresh and show a toast.
swipe_refresh.isRefreshing = false
}
}
fun onBind(category: Category) {
this.category = category
adapter.mode = if (controller.selectedMangas.isNotEmpty()) {
SelectableAdapter.Mode.MULTI
} else {
SelectableAdapter.Mode.SINGLE
}
subscriptions += controller.searchRelay
.doOnNext { adapter.setFilter(it) }
.skip(1)
.subscribe { adapter.performFilter() }
subscriptions += controller.libraryMangaRelay
.subscribe { onNextLibraryManga(it) }
subscriptions += controller.selectionRelay
.subscribe { onSelectionChanged(it) }
}
fun onRecycle() {
adapter.setItems(emptyList())
adapter.clearSelection()
unsubscribe()
}
fun unsubscribe() {
subscriptions.clear()
}
/**
* Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the
* adapter.
*
* @param event the event received.
*/
fun onNextLibraryManga(event: LibraryMangaEvent) {
// Get the manga list for this category.
val mangaForCategory = event.getMangaForCategory(category).orEmpty()
// Update the category with its manga.
adapter.setItems(mangaForCategory)
if (adapter.mode == SelectableAdapter.Mode.MULTI) {
controller.selectedMangas.forEach { manga ->
val position = adapter.indexOf(manga)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
}
}
}
}
/**
* Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection
* depending on the type of event received.
*
* @param event the selection event received.
*/
private fun onSelectionChanged(event: LibrarySelectionEvent) {
when (event) {
is LibrarySelectionEvent.Selected -> {
if (adapter.mode != SelectableAdapter.Mode.MULTI) {
adapter.mode = SelectableAdapter.Mode.MULTI
}
findAndToggleSelection(event.manga)
}
is LibrarySelectionEvent.Unselected -> {
findAndToggleSelection(event.manga)
if (controller.selectedMangas.isEmpty()) {
adapter.mode = SelectableAdapter.Mode.SINGLE
}
}
is LibrarySelectionEvent.Cleared -> {
adapter.mode = SelectableAdapter.Mode.SINGLE
adapter.clearSelection()
}
}
}
/**
* Toggles the selection for the given manga and updates the view if needed.
*
* @param manga the manga to toggle.
*/
private fun findAndToggleSelection(manga: Manga) {
val position = adapter.indexOf(manga)
if (position != -1) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
}
}
/**
* Called when a manga is clicked.
*
* @param position the position of the element clicked.
* @return true if the item should be selected, false otherwise.
*/
override fun onItemClick(view: View, position: Int): Boolean {
// If the action mode is created and the position is valid, toggle the selection.
val item = adapter.getItem(position) ?: return false
if (adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position)
return true
} else {
openManga(item.manga)
return false
}
}
/**
* Called when a manga is long clicked.
*
* @param position the position of the element clicked.
*/
override fun onItemLongClick(position: Int) {
controller.createActionModeIfNeeded()
toggleSelection(position)
}
/**
* Opens a manga.
*
* @param manga the manga to open.
*/
private fun openManga(manga: Manga) {
controller.openManga(manga)
}
/**
* Tells the presenter to toggle the selection for the given position.
*
* @param position the position to toggle.
*/
private fun toggleSelection(position: Int) {
val item = adapter.getItem(position) ?: return
controller.setSelection(item.manga, !adapter.isSelected(position))
controller.invalidateActionMode()
}
}
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.library_category.view.*
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
/**
* Fragment containing the library manga for a certain category.
*/
class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FrameLayout(context, attrs),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener {
/**
* Preferences.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* The fragment containing this view.
*/
private lateinit var controller: LibraryController
/**
* Category for this view.
*/
lateinit var category: Category
private set
/**
* Recycler view of the list of manga.
*/
private lateinit var recycler: RecyclerView
/**
* Adapter to hold the manga in this category.
*/
private lateinit var adapter: LibraryCategoryAdapter
/**
* Subscriptions while the view is bound.
*/
private var subscriptions = CompositeSubscription()
fun onCreate(controller: LibraryController) {
this.controller = controller
recycler = if (preferences.libraryAsList().getOrDefault()) {
(swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply {
layoutManager = LinearLayoutManager(context)
}
} else {
(swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
spanCount = controller.mangaPerRow
}
}
adapter = LibraryCategoryAdapter(this)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
swipe_refresh.addView(recycler)
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) {
// Disable swipe refresh when view is not at the top
val firstPos = (recycler.layoutManager as LinearLayoutManager)
.findFirstCompletelyVisibleItemPosition()
swipe_refresh.isEnabled = firstPos <= 0
}
})
// Double the distance required to trigger sync
swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
swipe_refresh.setOnRefreshListener {
if (!LibraryUpdateService.isRunning(context)) {
LibraryUpdateService.start(context, category)
context.toast(R.string.updating_category)
}
// It can be a very long operation, so we disable swipe refresh and show a toast.
swipe_refresh.isRefreshing = false
}
}
fun onBind(category: Category) {
this.category = category
adapter.mode = if (controller.selectedMangas.isNotEmpty()) {
SelectableAdapter.Mode.MULTI
} else {
SelectableAdapter.Mode.SINGLE
}
subscriptions += controller.searchRelay
.doOnNext { adapter.setFilter(it) }
.skip(1)
.subscribe { adapter.performFilter() }
subscriptions += controller.libraryMangaRelay
.subscribe { onNextLibraryManga(it) }
subscriptions += controller.selectionRelay
.subscribe { onSelectionChanged(it) }
}
fun onRecycle() {
adapter.setItems(emptyList())
adapter.clearSelection()
unsubscribe()
}
fun unsubscribe() {
subscriptions.clear()
}
/**
* Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the
* adapter.
*
* @param event the event received.
*/
fun onNextLibraryManga(event: LibraryMangaEvent) {
// Get the manga list for this category.
val mangaForCategory = event.getMangaForCategory(category).orEmpty()
// Update the category with its manga.
adapter.setItems(mangaForCategory)
if (adapter.mode == SelectableAdapter.Mode.MULTI) {
controller.selectedMangas.forEach { manga ->
val position = adapter.indexOf(manga)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
}
}
}
}
/**
* Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection
* depending on the type of event received.
*
* @param event the selection event received.
*/
private fun onSelectionChanged(event: LibrarySelectionEvent) {
when (event) {
is LibrarySelectionEvent.Selected -> {
if (adapter.mode != SelectableAdapter.Mode.MULTI) {
adapter.mode = SelectableAdapter.Mode.MULTI
}
findAndToggleSelection(event.manga)
}
is LibrarySelectionEvent.Unselected -> {
findAndToggleSelection(event.manga)
if (controller.selectedMangas.isEmpty()) {
adapter.mode = SelectableAdapter.Mode.SINGLE
}
}
is LibrarySelectionEvent.Cleared -> {
adapter.mode = SelectableAdapter.Mode.SINGLE
adapter.clearSelection()
}
}
}
/**
* Toggles the selection for the given manga and updates the view if needed.
*
* @param manga the manga to toggle.
*/
private fun findAndToggleSelection(manga: Manga) {
val position = adapter.indexOf(manga)
if (position != -1) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
}
}
/**
* Called when a manga is clicked.
*
* @param position the position of the element clicked.
* @return true if the item should be selected, false otherwise.
*/
override fun onItemClick(view: View, position: Int): Boolean {
// If the action mode is created and the position is valid, toggle the selection.
val item = adapter.getItem(position) ?: return false
if (adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position)
return true
} else {
openManga(item.manga)
return false
}
}
/**
* Called when a manga is long clicked.
*
* @param position the position of the element clicked.
*/
override fun onItemLongClick(position: Int) {
controller.createActionModeIfNeeded()
toggleSelection(position)
}
/**
* Opens a manga.
*
* @param manga the manga to open.
*/
private fun openManga(manga: Manga) {
controller.openManga(manga)
}
/**
* Tells the presenter to toggle the selection for the given position.
*
* @param position the position to toggle.
*/
private fun toggleSelection(position: Int) {
val item = adapter.getItem(position) ?: return
controller.setSelection(item.manga, !adapter.isSelected(position))
controller.invalidateActionMode()
}
}

View file

@ -1,57 +1,57 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.source.LocalSource
import kotlinx.android.synthetic.main.catalogue_grid_item.*
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "item_catalogue_grid" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryGridHolder(
private val view: View,
private val adapter: FlexibleAdapter<*>
) : LibraryHolder(view, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
title.text = item.manga.title
// Update the unread count and its visibility.
with(unread_text) {
visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE
text = item.manga.unread.toString()
}
// Update the download count and its visibility.
with(download_text) {
visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE
text = item.downloadCount.toString()
}
//set local visibility if its local manga
local_text.visibility = if(item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE
// Update the cover.
GlideApp.with(view.context).clear(thumbnail)
GlideApp.with(view.context)
.load(item.manga)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(thumbnail)
}
}
package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.source.LocalSource
import kotlinx.android.synthetic.main.catalogue_grid_item.*
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "item_catalogue_grid" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryGridHolder(
private val view: View,
private val adapter: FlexibleAdapter<*>
) : LibraryHolder(view, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
title.text = item.manga.title
// Update the unread count and its visibility.
with(unread_text) {
visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE
text = item.manga.unread.toString()
}
// Update the download count and its visibility.
with(download_text) {
visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE
text = item.downloadCount.toString()
}
//set local visibility if its local manga
local_text.visibility = if(item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE
// Update the cover.
GlideApp.with(view.context).clear(thumbnail)
GlideApp.with(view.context)
.load(item.manga)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(thumbnail)
}
}

View file

@ -1,27 +1,27 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
/**
* Generic class used to hold the displayed data of a manga in the library.
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to the single tap and long tap events.
*/
abstract class LibraryHolder(
view: View,
adapter: FlexibleAdapter<*>
) : BaseFlexibleViewHolder(view, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
abstract fun onSetValues(item: LibraryItem)
}
package eu.kanade.tachiyomi.ui.library
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
/**
* Generic class used to hold the displayed data of a manga in the library.
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to the single tap and long tap events.
*/
abstract class LibraryHolder(
view: View,
adapter: FlexibleAdapter<*>
) : BaseFlexibleViewHolder(view, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
abstract fun onSetValues(item: LibraryItem)
}

View file

@ -1,75 +1,75 @@
package eu.kanade.tachiyomi.ui.library
import android.view.Gravity
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.recyclerview.widget.RecyclerView
import com.f2prateek.rx.preferences.Preference
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.catalogue_grid_item.view.*
class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference<Boolean>) :
AbstractFlexibleItem<LibraryHolder>(), IFilterable<String> {
var downloadCount = -1
override fun getLayoutRes(): Int {
return if (libraryAsList.getOrDefault())
R.layout.catalogue_list_item
else
R.layout.catalogue_grid_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): LibraryHolder {
val parent = adapter.recyclerView
return if (parent is AutofitRecyclerView) {
view.apply {
val coverHeight = parent.itemWidth / 3 * 4
card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight)
gradient.layoutParams = FrameLayout.LayoutParams(
MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM)
}
LibraryGridHolder(view, adapter)
} else {
LibraryListHolder(view, adapter)
}
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
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)
}
override fun equals(other: Any?): Boolean {
if (other is LibraryItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id!!.hashCode()
}
}
package eu.kanade.tachiyomi.ui.library
import android.view.Gravity
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.recyclerview.widget.RecyclerView
import com.f2prateek.rx.preferences.Preference
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.catalogue_grid_item.view.*
class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference<Boolean>) :
AbstractFlexibleItem<LibraryHolder>(), IFilterable<String> {
var downloadCount = -1
override fun getLayoutRes(): Int {
return if (libraryAsList.getOrDefault())
R.layout.catalogue_list_item
else
R.layout.catalogue_grid_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): LibraryHolder {
val parent = adapter.recyclerView
return if (parent is AutofitRecyclerView) {
view.apply {
val coverHeight = parent.itemWidth / 3 * 4
card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight)
gradient.layoutParams = FrameLayout.LayoutParams(
MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM)
}
LibraryGridHolder(view, adapter)
} else {
LibraryListHolder(view, adapter)
}
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
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)
}
override fun equals(other: Any?): Boolean {
if (other is LibraryItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id!!.hashCode()
}
}

View file

@ -1,65 +1,65 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.source.LocalSource
import kotlinx.android.synthetic.main.catalogue_list_item.*
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "item_library_list" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryListHolder(
private val view: View,
private val adapter: FlexibleAdapter<*>
) : LibraryHolder(view, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
title.text = item.manga.title
// Update the unread count and its visibility.
with(unread_text) {
visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE
text = item.manga.unread.toString()
}
// Update the download count and its visibility.
with(download_text) {
visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE
text = "${item.downloadCount}"
}
//show local text badge if local manga
local_text.visibility = if (item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE
// Create thumbnail onclick to simulate long click
thumbnail.setOnClickListener {
// Simulate long click on this view to enter selection mode
onLongClick(itemView)
}
// Update the cover.
GlideApp.with(itemView.context).clear(thumbnail)
GlideApp.with(itemView.context)
.load(item.manga)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.circleCrop()
.dontAnimate()
.into(thumbnail)
}
}
package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.source.LocalSource
import kotlinx.android.synthetic.main.catalogue_list_item.*
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "item_library_list" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryListHolder(
private val view: View,
private val adapter: FlexibleAdapter<*>
) : LibraryHolder(view, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
title.text = item.manga.title
// Update the unread count and its visibility.
with(unread_text) {
visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE
text = item.manga.unread.toString()
}
// Update the download count and its visibility.
with(download_text) {
visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE
text = "${item.downloadCount}"
}
//show local text badge if local manga
local_text.visibility = if (item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE
// Create thumbnail onclick to simulate long click
thumbnail.setOnClickListener {
// Simulate long click on this view to enter selection mode
onLongClick(itemView)
}
// Update the cover.
GlideApp.with(itemView.context).clear(thumbnail)
GlideApp.with(itemView.context)
.load(item.manga)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.circleCrop()
.dontAnimate()
.into(thumbnail)
}
}

View file

@ -1,217 +1,217 @@
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.util.AttributeSet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE
import uy.kohesive.injekt.injectLazy
/**
* The navigation view shown in a drawer with the different options to show the library.
*/
class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: ExtendedNavigationView(context, attrs) {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* List of groups shown in the view.
*/
private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup())
/**
* Adapter instance.
*/
private val adapter = Adapter(groups.map { it.createItems() }.flatten())
/**
* Click listener to notify the parent fragment when an item from a group is clicked.
*/
var onGroupClicked: (Group) -> Unit = {}
init {
recycler.adapter = adapter
addView(recycler)
groups.forEach { it.initModels() }
}
/**
* Returns true if there's at least one filter from [FilterGroup] active.
*/
fun hasActiveFilters(): Boolean {
return (groups[0] as FilterGroup).items.any { it.checked }
}
/**
* Adapter of the recycler view.
*/
inner class Adapter(items: List<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()
}
override fun onItemClicked(item: Item) {
item as Item.CheckboxGroup
item.checked = !item.checked
when (item) {
downloaded -> preferences.filterDownloaded().set(item.checked)
unread -> preferences.filterUnread().set(item.checked)
completed -> preferences.filterCompleted().set(item.checked)
}
adapter.notifyItemChanged(item)
}
}
/**
* Sorting group (alphabetically, by last read, ...) and ascending or descending.
*/
inner class SortGroup : Group {
private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this)
private val total = Item.MultiSort(R.string.action_sort_total, this)
private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this)
private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this)
private val unread = Item.MultiSort(R.string.action_filter_unread, this)
private val source = Item.MultiSort(R.string.manga_info_source_label, this)
override val items = listOf(alphabetically, lastRead, lastUpdated, unread, total, source)
override val header = Item.Header(R.string.action_sort)
override val footer = Item.Separator()
override fun initModels() {
val sorting = preferences.librarySortingMode().getOrDefault()
val order = if (preferences.librarySortingAscending().getOrDefault())
SORT_ASC else SORT_DESC
alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE
lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE
lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE
unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE
total.state = if (sorting == LibrarySort.TOTAL) order else SORT_NONE
source.state = if (sorting == LibrarySort.SOURCE) order else SORT_NONE
}
override fun onItemClicked(item: Item) {
item as Item.MultiStateGroup
val prevState = item.state
item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE }
item.state = when (prevState) {
SORT_NONE -> SORT_ASC
SORT_ASC -> SORT_DESC
SORT_DESC -> SORT_ASC
else -> throw Exception("Unknown state")
}
preferences.librarySortingMode().set(when (item) {
alphabetically -> LibrarySort.ALPHA
lastRead -> LibrarySort.LAST_READ
lastUpdated -> LibrarySort.LAST_UPDATED
unread -> LibrarySort.UNREAD
total -> LibrarySort.TOTAL
source -> LibrarySort.SOURCE
else -> throw Exception("Unknown sorting")
})
preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false)
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
inner class BadgeGroup : Group {
private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this)
override val header = null
override val footer = null
override val items = listOf(downloadBadge)
override fun initModels() {
downloadBadge.checked = preferences.downloadBadge().getOrDefault()
}
override fun onItemClicked(item: Item) {
item as Item.CheckboxGroup
item.checked = !item.checked
preferences.downloadBadge().set((item.checked))
adapter.notifyItemChanged(item)
}
}
/**
* Display group, to show the library as a list or a grid.
*/
inner class DisplayGroup : Group {
private val grid = Item.Radio(R.string.action_display_grid, this)
private val list = Item.Radio(R.string.action_display_list, this)
override val items = listOf(grid, list)
override val header = Item.Header(R.string.action_display)
override val footer = null
override fun initModels() {
val asList = preferences.libraryAsList().getOrDefault()
grid.checked = !asList
list.checked = asList
}
override fun onItemClicked(item: Item) {
item as Item.Radio
if (item.checked) return
item.group.items.forEach { (it as Item.Radio).checked = false }
item.checked = true
preferences.libraryAsList().set(if (item == list) true else false)
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.util.AttributeSet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE
import uy.kohesive.injekt.injectLazy
/**
* The navigation view shown in a drawer with the different options to show the library.
*/
class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: ExtendedNavigationView(context, attrs) {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* List of groups shown in the view.
*/
private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup())
/**
* Adapter instance.
*/
private val adapter = Adapter(groups.map { it.createItems() }.flatten())
/**
* Click listener to notify the parent fragment when an item from a group is clicked.
*/
var onGroupClicked: (Group) -> Unit = {}
init {
recycler.adapter = adapter
addView(recycler)
groups.forEach { it.initModels() }
}
/**
* Returns true if there's at least one filter from [FilterGroup] active.
*/
fun hasActiveFilters(): Boolean {
return (groups[0] as FilterGroup).items.any { it.checked }
}
/**
* Adapter of the recycler view.
*/
inner class Adapter(items: List<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()
}
override fun onItemClicked(item: Item) {
item as Item.CheckboxGroup
item.checked = !item.checked
when (item) {
downloaded -> preferences.filterDownloaded().set(item.checked)
unread -> preferences.filterUnread().set(item.checked)
completed -> preferences.filterCompleted().set(item.checked)
}
adapter.notifyItemChanged(item)
}
}
/**
* Sorting group (alphabetically, by last read, ...) and ascending or descending.
*/
inner class SortGroup : Group {
private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this)
private val total = Item.MultiSort(R.string.action_sort_total, this)
private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this)
private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this)
private val unread = Item.MultiSort(R.string.action_filter_unread, this)
private val source = Item.MultiSort(R.string.manga_info_source_label, this)
override val items = listOf(alphabetically, lastRead, lastUpdated, unread, total, source)
override val header = Item.Header(R.string.action_sort)
override val footer = Item.Separator()
override fun initModels() {
val sorting = preferences.librarySortingMode().getOrDefault()
val order = if (preferences.librarySortingAscending().getOrDefault())
SORT_ASC else SORT_DESC
alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE
lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE
lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE
unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE
total.state = if (sorting == LibrarySort.TOTAL) order else SORT_NONE
source.state = if (sorting == LibrarySort.SOURCE) order else SORT_NONE
}
override fun onItemClicked(item: Item) {
item as Item.MultiStateGroup
val prevState = item.state
item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE }
item.state = when (prevState) {
SORT_NONE -> SORT_ASC
SORT_ASC -> SORT_DESC
SORT_DESC -> SORT_ASC
else -> throw Exception("Unknown state")
}
preferences.librarySortingMode().set(when (item) {
alphabetically -> LibrarySort.ALPHA
lastRead -> LibrarySort.LAST_READ
lastUpdated -> LibrarySort.LAST_UPDATED
unread -> LibrarySort.UNREAD
total -> LibrarySort.TOTAL
source -> LibrarySort.SOURCE
else -> throw Exception("Unknown sorting")
})
preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false)
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
inner class BadgeGroup : Group {
private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this)
override val header = null
override val footer = null
override val items = listOf(downloadBadge)
override fun initModels() {
downloadBadge.checked = preferences.downloadBadge().getOrDefault()
}
override fun onItemClicked(item: Item) {
item as Item.CheckboxGroup
item.checked = !item.checked
preferences.downloadBadge().set((item.checked))
adapter.notifyItemChanged(item)
}
}
/**
* Display group, to show the library as a list or a grid.
*/
inner class DisplayGroup : Group {
private val grid = Item.Radio(R.string.action_display_grid, this)
private val list = Item.Radio(R.string.action_display_list, this)
override val items = listOf(grid, list)
override val header = Item.Header(R.string.action_display)
override val footer = null
override fun initModels() {
val asList = preferences.libraryAsList().getOrDefault()
grid.checked = !asList
list.checked = asList
}
override fun onItemClicked(item: Item) {
item as Item.Radio
if (item.checked) return
item.group.items.forEach { (it as Item.Radio).checked = false }
item.checked = true
preferences.libraryAsList().set(if (item == list) true else false)
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
}

View file

@ -1,371 +1,371 @@
package eu.kanade.tachiyomi.ui.library
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.combineLatest
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.io.InputStream
import java.util.ArrayList
import java.util.Collections
import java.util.Comparator
/**
* Class containing library information.
*/
private data class Library(val categories: List<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.
*/
var categories: List<Category> = emptyList()
private set
/**
* Relay used to apply the UI filters to the last emission of the library.
*/
private val filterTriggerRelay = BehaviorRelay.create(Unit)
/**
* Relay used to apply the UI update to the last emission of the library.
*/
private val downloadTriggerRelay = BehaviorRelay.create(Unit)
/**
* Relay used to apply the selected sorting method to the last emission of the library.
*/
private val sortTriggerRelay = BehaviorRelay.create(Unit)
/**
* Library subscription.
*/
private var librarySubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
subscribeLibrary()
}
/**
* Subscribes to library if needed.
*/
fun subscribeLibrary() {
if (librarySubscription.isNullOrUnsubscribed()) {
librarySubscription = getLibraryObservable()
.combineLatest(downloadTriggerRelay.observeOn(Schedulers.io()),
{ lib, _ -> lib.apply { setDownloadCount(mangaMap) } })
.combineLatest(filterTriggerRelay.observeOn(Schedulers.io()),
{ lib, _ -> lib.copy(mangaMap = applyFilters(lib.mangaMap)) })
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io()),
{ lib, _ -> lib.copy(mangaMap = applySort(lib.mangaMap)) })
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, (categories, mangaMap) ->
view.onNextLibraryUpdate(categories, mangaMap)
})
}
}
/**
* Applies library filters to the given map of manga.
*
* @param map the map to filter.
*/
private fun applyFilters(map: LibraryMap): LibraryMap {
val filterDownloaded = preferences.filterDownloaded().getOrDefault()
val filterUnread = preferences.filterUnread().getOrDefault()
val filterCompleted = preferences.filterCompleted().getOrDefault()
val filterFn: (LibraryItem) -> Boolean = f@ { item ->
// Filter when there isn't unread chapters.
if (filterUnread && item.manga.unread == 0) {
return@f false
}
if (filterCompleted && item.manga.status != SManga.COMPLETED) {
return@f false
}
// Filter when there are no downloads.
if (filterDownloaded) {
// Local manga are always downloaded
if (item.manga.source == LocalSource.ID) {
return@f true
}
// Don't bother with directory checking if download count has been set.
if (item.downloadCount != -1) {
return@f item.downloadCount > 0
}
return@f downloadManager.getDownloadCount(item.manga) > 0
}
true
}
return map.mapValues { entry -> entry.value.filter(filterFn) }
}
/**
* Sets downloaded chapter count to each manga.
*
* @param map the map of manga.
*/
private fun setDownloadCount(map: LibraryMap) {
if (!preferences.downloadBadge().getOrDefault()) {
// Unset download count if the preference is not enabled.
for ((_, itemList) in map) {
for (item in itemList) {
item.downloadCount = -1
}
}
return
}
for ((_, itemList) in map) {
for (item in itemList) {
item.downloadCount = downloadManager.getDownloadCount(item.manga)
}
}
}
/**
* Applies library sorting to the given map of manga.
*
* @param map the map to sort.
*/
private fun applySort(map: LibraryMap): LibraryMap {
val sortingMode = preferences.librarySortingMode().getOrDefault()
val lastReadManga by lazy {
var counter = 0
db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ }
}
val totalChapterManga by lazy {
var counter = 0
db.getTotalChapterManga().executeAsBlocking().associate { it.id!! to counter++ }
}
val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 ->
when (sortingMode) {
LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true)
LibrarySort.LAST_READ -> {
// Get index of manga, set equal to list if size unknown.
val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size
val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size
manga1LastRead.compareTo(manga2LastRead)
}
LibrarySort.LAST_UPDATED -> i2.manga.last_update.compareTo(i1.manga.last_update)
LibrarySort.UNREAD -> i1.manga.unread.compareTo(i2.manga.unread)
LibrarySort.TOTAL -> {
val manga1TotalChapter = totalChapterManga[i1.manga.id!!] ?: 0
val mange2TotalChapter = totalChapterManga[i2.manga.id!!] ?: 0
manga1TotalChapter.compareTo(mange2TotalChapter)
}
LibrarySort.SOURCE -> {
val source1Name = sourceManager.getOrStub(i1.manga.source).name
val source2Name = sourceManager.getOrStub(i2.manga.source).name
source1Name.compareTo(source2Name)
}
else -> throw Exception("Unknown sorting mode")
}
}
val comparator = if (preferences.librarySortingAscending().getOrDefault())
Comparator(sortFn)
else
Collections.reverseOrder(sortFn)
return map.mapValues { entry -> entry.value.sortedWith(comparator) }
}
/**
* Get the categories and all its manga from the database.
*
* @return an observable of the categories and its manga.
*/
private fun getLibraryObservable(): Observable<Library> {
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
{ dbCategories, libraryManga ->
val categories = if (libraryManga.containsKey(0))
arrayListOf(Category.createDefault()) + dbCategories
else
dbCategories
this.categories = categories
Library(categories, libraryManga)
})
}
/**
* Get the categories from the database.
*
* @return an observable of the categories.
*/
private fun getCategoriesObservable(): Observable<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)
}
}
}
}
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Move the given list of manga to categories.
*
* @param categories the selected categories.
* @param mangas the list of manga to move.
*/
fun moveMangasToCategories(categories: List<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
*/
@Throws(IOException::class)
fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
if (manga.source == LocalSource.ID) {
LocalSource.updateCover(context, manga, inputStream)
return true
}
if (manga.thumbnail_url != null && manga.favorite) {
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
return true
}
return false
}
}
package eu.kanade.tachiyomi.ui.library
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.combineLatest
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.io.InputStream
import java.util.ArrayList
import java.util.Collections
import java.util.Comparator
/**
* Class containing library information.
*/
private data class Library(val categories: List<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.
*/
var categories: List<Category> = emptyList()
private set
/**
* Relay used to apply the UI filters to the last emission of the library.
*/
private val filterTriggerRelay = BehaviorRelay.create(Unit)
/**
* Relay used to apply the UI update to the last emission of the library.
*/
private val downloadTriggerRelay = BehaviorRelay.create(Unit)
/**
* Relay used to apply the selected sorting method to the last emission of the library.
*/
private val sortTriggerRelay = BehaviorRelay.create(Unit)
/**
* Library subscription.
*/
private var librarySubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
subscribeLibrary()
}
/**
* Subscribes to library if needed.
*/
fun subscribeLibrary() {
if (librarySubscription.isNullOrUnsubscribed()) {
librarySubscription = getLibraryObservable()
.combineLatest(downloadTriggerRelay.observeOn(Schedulers.io()),
{ lib, _ -> lib.apply { setDownloadCount(mangaMap) } })
.combineLatest(filterTriggerRelay.observeOn(Schedulers.io()),
{ lib, _ -> lib.copy(mangaMap = applyFilters(lib.mangaMap)) })
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io()),
{ lib, _ -> lib.copy(mangaMap = applySort(lib.mangaMap)) })
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, (categories, mangaMap) ->
view.onNextLibraryUpdate(categories, mangaMap)
})
}
}
/**
* Applies library filters to the given map of manga.
*
* @param map the map to filter.
*/
private fun applyFilters(map: LibraryMap): LibraryMap {
val filterDownloaded = preferences.filterDownloaded().getOrDefault()
val filterUnread = preferences.filterUnread().getOrDefault()
val filterCompleted = preferences.filterCompleted().getOrDefault()
val filterFn: (LibraryItem) -> Boolean = f@ { item ->
// Filter when there isn't unread chapters.
if (filterUnread && item.manga.unread == 0) {
return@f false
}
if (filterCompleted && item.manga.status != SManga.COMPLETED) {
return@f false
}
// Filter when there are no downloads.
if (filterDownloaded) {
// Local manga are always downloaded
if (item.manga.source == LocalSource.ID) {
return@f true
}
// Don't bother with directory checking if download count has been set.
if (item.downloadCount != -1) {
return@f item.downloadCount > 0
}
return@f downloadManager.getDownloadCount(item.manga) > 0
}
true
}
return map.mapValues { entry -> entry.value.filter(filterFn) }
}
/**
* Sets downloaded chapter count to each manga.
*
* @param map the map of manga.
*/
private fun setDownloadCount(map: LibraryMap) {
if (!preferences.downloadBadge().getOrDefault()) {
// Unset download count if the preference is not enabled.
for ((_, itemList) in map) {
for (item in itemList) {
item.downloadCount = -1
}
}
return
}
for ((_, itemList) in map) {
for (item in itemList) {
item.downloadCount = downloadManager.getDownloadCount(item.manga)
}
}
}
/**
* Applies library sorting to the given map of manga.
*
* @param map the map to sort.
*/
private fun applySort(map: LibraryMap): LibraryMap {
val sortingMode = preferences.librarySortingMode().getOrDefault()
val lastReadManga by lazy {
var counter = 0
db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ }
}
val totalChapterManga by lazy {
var counter = 0
db.getTotalChapterManga().executeAsBlocking().associate { it.id!! to counter++ }
}
val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 ->
when (sortingMode) {
LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true)
LibrarySort.LAST_READ -> {
// Get index of manga, set equal to list if size unknown.
val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size
val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size
manga1LastRead.compareTo(manga2LastRead)
}
LibrarySort.LAST_UPDATED -> i2.manga.last_update.compareTo(i1.manga.last_update)
LibrarySort.UNREAD -> i1.manga.unread.compareTo(i2.manga.unread)
LibrarySort.TOTAL -> {
val manga1TotalChapter = totalChapterManga[i1.manga.id!!] ?: 0
val mange2TotalChapter = totalChapterManga[i2.manga.id!!] ?: 0
manga1TotalChapter.compareTo(mange2TotalChapter)
}
LibrarySort.SOURCE -> {
val source1Name = sourceManager.getOrStub(i1.manga.source).name
val source2Name = sourceManager.getOrStub(i2.manga.source).name
source1Name.compareTo(source2Name)
}
else -> throw Exception("Unknown sorting mode")
}
}
val comparator = if (preferences.librarySortingAscending().getOrDefault())
Comparator(sortFn)
else
Collections.reverseOrder(sortFn)
return map.mapValues { entry -> entry.value.sortedWith(comparator) }
}
/**
* Get the categories and all its manga from the database.
*
* @return an observable of the categories and its manga.
*/
private fun getLibraryObservable(): Observable<Library> {
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
{ dbCategories, libraryManga ->
val categories = if (libraryManga.containsKey(0))
arrayListOf(Category.createDefault()) + dbCategories
else
dbCategories
this.categories = categories
Library(categories, libraryManga)
})
}
/**
* Get the categories from the database.
*
* @return an observable of the categories.
*/
private fun getCategoriesObservable(): Observable<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)
}
}
}
}
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Move the given list of manga to categories.
*
* @param categories the selected categories.
* @param mangas the list of manga to move.
*/
fun moveMangasToCategories(categories: List<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
*/
@Throws(IOException::class)
fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
if (manga.source == LocalSource.ID) {
LocalSource.updateCover(context, manga, inputStream)
return true
}
if (manga.thumbnail_url != null && manga.favorite) {
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
return true
}
return false
}
}

View file

@ -1,11 +1,11 @@
package eu.kanade.tachiyomi.ui.library
object LibrarySort {
const val ALPHA = 0
const val LAST_READ = 1
const val LAST_UPDATED = 2
const val UNREAD = 3
const val TOTAL = 4
const val SOURCE = 5
package eu.kanade.tachiyomi.ui.library
object LibrarySort {
const val ALPHA = 0
const val LAST_READ = 1
const val LAST_UPDATED = 2
const val UNREAD = 3
const val TOTAL = 4
const val SOURCE = 5
}

View file

@ -1,32 +1,32 @@
package eu.kanade.tachiyomi.ui.main
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView
class ChangelogDialogController : DialogController() {
override fun onCreateDialog(savedState: Bundle?): Dialog {
val activity = activity!!
val view = WhatsNewRecyclerView(activity)
return MaterialDialog.Builder(activity)
.title(if (BuildConfig.DEBUG) "Notices" else "Changelog")
.customView(view, false)
.positiveText(android.R.string.yes)
.build()
}
class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) {
override fun initAttrs(attrs: AttributeSet?, defStyle: Int) {
mRowLayoutId = R.layout.changelog_row_layout
mRowHeaderLayoutId = R.layout.changelog_header_layout
mChangeLogFileResourceId = if (BuildConfig.DEBUG) R.raw.changelog_debug else R.raw.changelog_release
}
}
package eu.kanade.tachiyomi.ui.main
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView
class ChangelogDialogController : DialogController() {
override fun onCreateDialog(savedState: Bundle?): Dialog {
val activity = activity!!
val view = WhatsNewRecyclerView(activity)
return MaterialDialog.Builder(activity)
.title(if (BuildConfig.DEBUG) "Notices" else "Changelog")
.customView(view, false)
.positiveText(android.R.string.yes)
.build()
}
class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) {
override fun initAttrs(attrs: AttributeSet?, defStyle: Int) {
mRowLayoutId = R.layout.changelog_row_layout
mRowHeaderLayoutId = R.layout.changelog_header_layout
mChangeLogFileResourceId = if (BuildConfig.DEBUG) R.raw.changelog_debug else R.raw.changelog_release
}
}
}

View file

@ -1,282 +1,282 @@
package eu.kanade.tachiyomi.ui.main
import android.animation.ObjectAnimator
import android.app.SearchManager
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
import android.view.ViewGroup
import com.bluelinelabs.conductor.*
import eu.kanade.tachiyomi.Migrations
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.base.controller.*
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.download.DownloadController
import eu.kanade.tachiyomi.ui.extension.ExtensionController
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController
import eu.kanade.tachiyomi.ui.setting.SettingsMainController
import eu.kanade.tachiyomi.util.openInBrowser
import kotlinx.android.synthetic.main.main_activity.*
import uy.kohesive.injekt.injectLazy
class MainActivity : BaseActivity() {
private lateinit var router: Router
val preferences: PreferencesHelper by injectLazy()
private var drawerArrow: DrawerArrowDrawable? = null
private var secondaryDrawer: ViewGroup? = null
private val startScreenId by lazy {
when (preferences.startScreen()) {
2 -> R.id.nav_drawer_recently_read
3 -> R.id.nav_drawer_recent_updates
else -> R.id.nav_drawer_library
}
}
lateinit var tabAnimator: TabsAnimator
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(when (preferences.theme()) {
2 -> R.style.Theme_Tachiyomi_Dark
3 -> R.style.Theme_Tachiyomi_Amoled
4 -> R.style.Theme_Tachiyomi_DarkBlue
else -> R.style.Theme_Tachiyomi
})
super.onCreate(savedInstanceState)
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
finish()
return
}
setContentView(R.layout.main_activity)
setSupportActionBar(toolbar)
drawerArrow = DrawerArrowDrawable(this)
drawerArrow?.color = Color.WHITE
toolbar.navigationIcon = drawerArrow
tabAnimator = TabsAnimator(tabs)
// Set behavior of Navigation drawer
nav_view.setNavigationItemSelectedListener { item ->
val id = item.itemId
val currentRoot = router.backstack.firstOrNull()
if (currentRoot?.tag()?.toIntOrNull() != id) {
when (id) {
R.id.nav_drawer_library -> setRoot(LibraryController(), id)
R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id)
R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id)
R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id)
R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id)
R.id.nav_drawer_downloads -> {
router.pushController(DownloadController().withFadeTransaction())
}
R.id.nav_drawer_settings -> {
router.pushController(SettingsMainController().withFadeTransaction())
}
R.id.nav_drawer_help -> {
openInBrowser(URL_HELP)
}
}
}
drawer.closeDrawer(GravityCompat.START)
true
}
val container: ViewGroup = findViewById(R.id.controller_container)
router = Conductor.attachRouter(this, container, savedInstanceState)
if (!router.hasRootController()) {
// Set start screen
if (!handleIntentAction(intent)) {
setSelectedDrawerItem(startScreenId)
}
}
toolbar.setNavigationOnClickListener {
if (router.backstackSize == 1) {
drawer.openDrawer(GravityCompat.START)
} else {
onBackPressed()
}
}
router.addChangeListener(object : ControllerChangeHandler.ControllerChangeListener {
override fun onChangeStarted(to: Controller?, from: Controller?, isPush: Boolean,
container: ViewGroup, handler: ControllerChangeHandler) {
syncActivityViewWithController(to, from)
}
override fun onChangeCompleted(to: Controller?, from: Controller?, isPush: Boolean,
container: ViewGroup, handler: ControllerChangeHandler) {
}
})
syncActivityViewWithController(router.backstack.lastOrNull()?.controller())
if (savedInstanceState == null) {
// Show changelog if needed
if (Migrations.upgrade(preferences)) {
ChangelogDialogController().showDialog(router)
}
}
}
override fun onNewIntent(intent: Intent) {
if (!handleIntentAction(intent)) {
super.onNewIntent(intent)
}
}
private fun handleIntentAction(intent: Intent): Boolean {
when (intent.action) {
SHORTCUT_LIBRARY -> setSelectedDrawerItem(R.id.nav_drawer_library)
SHORTCUT_RECENTLY_UPDATED -> setSelectedDrawerItem(R.id.nav_drawer_recent_updates)
SHORTCUT_RECENTLY_READ -> setSelectedDrawerItem(R.id.nav_drawer_recently_read)
SHORTCUT_CATALOGUES -> setSelectedDrawerItem(R.id.nav_drawer_catalogues)
SHORTCUT_MANGA -> {
val extras = intent.extras ?: return false
router.setRoot(RouterTransaction.with(MangaController(extras)))
}
SHORTCUT_DOWNLOADS -> {
if (router.backstack.none { it.controller() is DownloadController }) {
setSelectedDrawerItem(R.id.nav_drawer_downloads)
}
}
Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> {
//If the intent match the "standard" Android search intent
// or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant)
//Get the search query provided in extras, and if not null, perform a global search with it.
val query = intent.getStringExtra(SearchManager.QUERY)
if (query != null && !query.isEmpty()) {
if (router.backstackSize > 1) {
router.popToRoot()
}
router.pushController(CatalogueSearchController(query).withFadeTransaction())
}
}
INTENT_SEARCH -> {
val query = intent.getStringExtra(INTENT_SEARCH_QUERY)
val filter = intent.getStringExtra(INTENT_SEARCH_FILTER)
if (query != null && !query.isEmpty()) {
if (router.backstackSize > 1) {
router.popToRoot()
}
router.pushController(CatalogueSearchController(query, filter).withFadeTransaction())
}
}
else -> return false
}
return true
}
override fun onDestroy() {
super.onDestroy()
nav_view?.setNavigationItemSelectedListener(null)
toolbar?.setNavigationOnClickListener(null)
}
override fun onBackPressed() {
val backstackSize = router.backstackSize
if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) {
drawer.closeDrawers()
} else if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) {
setSelectedDrawerItem(startScreenId)
} else if (backstackSize == 1 || !router.handleBack()) {
super.onBackPressed()
}
}
private fun setSelectedDrawerItem(itemId: Int) {
if (!isFinishing) {
nav_view.setCheckedItem(itemId)
nav_view.menu.performIdentifierAction(itemId, 0)
}
}
private fun setRoot(controller: Controller, id: Int) {
router.setRoot(controller.withFadeTransaction().tag(id.toString()))
}
private fun syncActivityViewWithController(to: Controller?, from: Controller? = null) {
if (from is DialogController || to is DialogController) {
return
}
val showHamburger = router.backstackSize == 1
if (showHamburger) {
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
} else {
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
}
ObjectAnimator.ofFloat(drawerArrow, "progress", if (showHamburger) 0f else 1f).start()
if (from is TabbedController) {
from.cleanupTabs(tabs)
}
if (to is TabbedController) {
tabAnimator.expand()
to.configureTabs(tabs)
} else {
tabAnimator.collapse()
tabs.setupWithViewPager(null)
}
if (from is SecondaryDrawerController) {
if (secondaryDrawer != null) {
from.cleanupSecondaryDrawer(drawer)
drawer.removeView(secondaryDrawer)
secondaryDrawer = null
}
}
if (to is SecondaryDrawerController) {
secondaryDrawer = to.createSecondaryDrawer(drawer)?.also { drawer.addView(it) }
}
if (to is NoToolbarElevationController) {
appbar.disableElevation()
} else {
appbar.enableElevation()
}
}
companion object {
// Shortcut actions
const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY"
const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS"
const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA"
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_SEARCH_QUERY = "query"
const val INTENT_SEARCH_FILTER = "filter"
private const val URL_HELP = "https://tachiyomi.org/help/"
}
}
package eu.kanade.tachiyomi.ui.main
import android.animation.ObjectAnimator
import android.app.SearchManager
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
import android.view.ViewGroup
import com.bluelinelabs.conductor.*
import eu.kanade.tachiyomi.Migrations
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.base.controller.*
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.download.DownloadController
import eu.kanade.tachiyomi.ui.extension.ExtensionController
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController
import eu.kanade.tachiyomi.ui.setting.SettingsMainController
import eu.kanade.tachiyomi.util.openInBrowser
import kotlinx.android.synthetic.main.main_activity.*
import uy.kohesive.injekt.injectLazy
class MainActivity : BaseActivity() {
private lateinit var router: Router
val preferences: PreferencesHelper by injectLazy()
private var drawerArrow: DrawerArrowDrawable? = null
private var secondaryDrawer: ViewGroup? = null
private val startScreenId by lazy {
when (preferences.startScreen()) {
2 -> R.id.nav_drawer_recently_read
3 -> R.id.nav_drawer_recent_updates
else -> R.id.nav_drawer_library
}
}
lateinit var tabAnimator: TabsAnimator
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(when (preferences.theme()) {
2 -> R.style.Theme_Tachiyomi_Dark
3 -> R.style.Theme_Tachiyomi_Amoled
4 -> R.style.Theme_Tachiyomi_DarkBlue
else -> R.style.Theme_Tachiyomi
})
super.onCreate(savedInstanceState)
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
finish()
return
}
setContentView(R.layout.main_activity)
setSupportActionBar(toolbar)
drawerArrow = DrawerArrowDrawable(this)
drawerArrow?.color = Color.WHITE
toolbar.navigationIcon = drawerArrow
tabAnimator = TabsAnimator(tabs)
// Set behavior of Navigation drawer
nav_view.setNavigationItemSelectedListener { item ->
val id = item.itemId
val currentRoot = router.backstack.firstOrNull()
if (currentRoot?.tag()?.toIntOrNull() != id) {
when (id) {
R.id.nav_drawer_library -> setRoot(LibraryController(), id)
R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id)
R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id)
R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id)
R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id)
R.id.nav_drawer_downloads -> {
router.pushController(DownloadController().withFadeTransaction())
}
R.id.nav_drawer_settings -> {
router.pushController(SettingsMainController().withFadeTransaction())
}
R.id.nav_drawer_help -> {
openInBrowser(URL_HELP)
}
}
}
drawer.closeDrawer(GravityCompat.START)
true
}
val container: ViewGroup = findViewById(R.id.controller_container)
router = Conductor.attachRouter(this, container, savedInstanceState)
if (!router.hasRootController()) {
// Set start screen
if (!handleIntentAction(intent)) {
setSelectedDrawerItem(startScreenId)
}
}
toolbar.setNavigationOnClickListener {
if (router.backstackSize == 1) {
drawer.openDrawer(GravityCompat.START)
} else {
onBackPressed()
}
}
router.addChangeListener(object : ControllerChangeHandler.ControllerChangeListener {
override fun onChangeStarted(to: Controller?, from: Controller?, isPush: Boolean,
container: ViewGroup, handler: ControllerChangeHandler) {
syncActivityViewWithController(to, from)
}
override fun onChangeCompleted(to: Controller?, from: Controller?, isPush: Boolean,
container: ViewGroup, handler: ControllerChangeHandler) {
}
})
syncActivityViewWithController(router.backstack.lastOrNull()?.controller())
if (savedInstanceState == null) {
// Show changelog if needed
if (Migrations.upgrade(preferences)) {
ChangelogDialogController().showDialog(router)
}
}
}
override fun onNewIntent(intent: Intent) {
if (!handleIntentAction(intent)) {
super.onNewIntent(intent)
}
}
private fun handleIntentAction(intent: Intent): Boolean {
when (intent.action) {
SHORTCUT_LIBRARY -> setSelectedDrawerItem(R.id.nav_drawer_library)
SHORTCUT_RECENTLY_UPDATED -> setSelectedDrawerItem(R.id.nav_drawer_recent_updates)
SHORTCUT_RECENTLY_READ -> setSelectedDrawerItem(R.id.nav_drawer_recently_read)
SHORTCUT_CATALOGUES -> setSelectedDrawerItem(R.id.nav_drawer_catalogues)
SHORTCUT_MANGA -> {
val extras = intent.extras ?: return false
router.setRoot(RouterTransaction.with(MangaController(extras)))
}
SHORTCUT_DOWNLOADS -> {
if (router.backstack.none { it.controller() is DownloadController }) {
setSelectedDrawerItem(R.id.nav_drawer_downloads)
}
}
Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> {
//If the intent match the "standard" Android search intent
// or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant)
//Get the search query provided in extras, and if not null, perform a global search with it.
val query = intent.getStringExtra(SearchManager.QUERY)
if (query != null && !query.isEmpty()) {
if (router.backstackSize > 1) {
router.popToRoot()
}
router.pushController(CatalogueSearchController(query).withFadeTransaction())
}
}
INTENT_SEARCH -> {
val query = intent.getStringExtra(INTENT_SEARCH_QUERY)
val filter = intent.getStringExtra(INTENT_SEARCH_FILTER)
if (query != null && !query.isEmpty()) {
if (router.backstackSize > 1) {
router.popToRoot()
}
router.pushController(CatalogueSearchController(query, filter).withFadeTransaction())
}
}
else -> return false
}
return true
}
override fun onDestroy() {
super.onDestroy()
nav_view?.setNavigationItemSelectedListener(null)
toolbar?.setNavigationOnClickListener(null)
}
override fun onBackPressed() {
val backstackSize = router.backstackSize
if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) {
drawer.closeDrawers()
} else if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) {
setSelectedDrawerItem(startScreenId)
} else if (backstackSize == 1 || !router.handleBack()) {
super.onBackPressed()
}
}
private fun setSelectedDrawerItem(itemId: Int) {
if (!isFinishing) {
nav_view.setCheckedItem(itemId)
nav_view.menu.performIdentifierAction(itemId, 0)
}
}
private fun setRoot(controller: Controller, id: Int) {
router.setRoot(controller.withFadeTransaction().tag(id.toString()))
}
private fun syncActivityViewWithController(to: Controller?, from: Controller? = null) {
if (from is DialogController || to is DialogController) {
return
}
val showHamburger = router.backstackSize == 1
if (showHamburger) {
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
} else {
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
}
ObjectAnimator.ofFloat(drawerArrow, "progress", if (showHamburger) 0f else 1f).start()
if (from is TabbedController) {
from.cleanupTabs(tabs)
}
if (to is TabbedController) {
tabAnimator.expand()
to.configureTabs(tabs)
} else {
tabAnimator.collapse()
tabs.setupWithViewPager(null)
}
if (from is SecondaryDrawerController) {
if (secondaryDrawer != null) {
from.cleanupSecondaryDrawer(drawer)
drawer.removeView(secondaryDrawer)
secondaryDrawer = null
}
}
if (to is SecondaryDrawerController) {
secondaryDrawer = to.createSecondaryDrawer(drawer)?.also { drawer.addView(it) }
}
if (to is NoToolbarElevationController) {
appbar.disableElevation()
} else {
appbar.enableElevation()
}
}
companion object {
// Shortcut actions
const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY"
const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS"
const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA"
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_SEARCH_QUERY = "query"
const val INTENT_SEARCH_FILTER = "filter"
private const val URL_HELP = "https://tachiyomi.org/help/"
}
}

View file

@ -1,193 +1,193 @@
package eu.kanade.tachiyomi.ui.manga
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.os.Bundle
import com.google.android.material.tabs.TabLayout
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.bluelinelabs.conductor.Router
import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.support.RouterPagerAdapter
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.RxController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController
import eu.kanade.tachiyomi.ui.manga.track.TrackController
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.main_activity.*
import kotlinx.android.synthetic.main.manga_controller.*
import rx.Subscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
class MangaController : RxController, TabbedController {
constructor(manga: Manga?, fromCatalogue: Boolean = false) : super(Bundle().apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue)
}) {
this.manga = manga
if (manga != null) {
source = Injekt.get<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
private set
var source: Source? = null
private set
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
override fun getTitle(): String? {
return manga?.title
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.manga_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
if (manga == null || source == null) return
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
adapter = MangaDetailAdapter()
manga_pager.offscreenPageLimit = 3
manga_pager.adapter = adapter
if (!fromCatalogue)
manga_pager.currentItem = CHAPTERS_CONTROLLER
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
adapter = null
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
activity?.tabs?.setupWithViewPager(manga_pager)
trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) }
}
}
override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeEnded(handler, type)
if (manga == null || source == null) {
activity?.toast(R.string.manga_not_in_db)
router.popController(this)
}
}
override fun configureTabs(tabs: TabLayout) {
with(tabs) {
tabGravity = TabLayout.GRAVITY_FILL
tabMode = TabLayout.MODE_FIXED
}
}
override fun cleanupTabs(tabs: TabLayout) {
trackingIconSubscription?.unsubscribe()
setTrackingIconInternal(false)
}
fun setTrackingIcon(visible: Boolean) {
trackingIconRelay.call(visible)
}
private fun setTrackingIconInternal(visible: Boolean) {
val tab = activity?.tabs?.getTabAt(TRACK_CONTROLLER) ?: return
val drawable = if (visible)
VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null)
else null
val view = tabField.get(tab) as LinearLayout
val textView = view.getChildAt(1) as TextView
textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null)
textView.compoundDrawablePadding = if (visible) 4 else 0
}
private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) {
private val tabCount = if (Injekt.get<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) }
override fun getCount(): Int {
return tabCount
}
override fun configureRouter(router: Router, position: Int) {
if (!router.hasRootController()) {
val controller = when (position) {
INFO_CONTROLLER -> MangaInfoController()
CHAPTERS_CONTROLLER -> ChaptersController()
TRACK_CONTROLLER -> TrackController()
else -> error("Wrong position $position")
}
router.setRoot(RouterTransaction.with(controller))
}
}
override fun getPageTitle(position: Int): CharSequence {
return tabTitles[position]
}
}
companion object {
const val FROM_CATALOGUE_EXTRA = "from_catalogue"
const val MANGA_EXTRA = "manga"
const val INFO_CONTROLLER = 0
const val CHAPTERS_CONTROLLER = 1
const val TRACK_CONTROLLER = 2
private val tabField = TabLayout.Tab::class.java.getDeclaredField("view")
.apply { isAccessible = true }
}
}
package eu.kanade.tachiyomi.ui.manga
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.os.Bundle
import com.google.android.material.tabs.TabLayout
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.bluelinelabs.conductor.Router
import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.support.RouterPagerAdapter
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.RxController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController
import eu.kanade.tachiyomi.ui.manga.track.TrackController
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.main_activity.*
import kotlinx.android.synthetic.main.manga_controller.*
import rx.Subscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
class MangaController : RxController, TabbedController {
constructor(manga: Manga?, fromCatalogue: Boolean = false) : super(Bundle().apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue)
}) {
this.manga = manga
if (manga != null) {
source = Injekt.get<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
private set
var source: Source? = null
private set
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
override fun getTitle(): String? {
return manga?.title
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.manga_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
if (manga == null || source == null) return
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
adapter = MangaDetailAdapter()
manga_pager.offscreenPageLimit = 3
manga_pager.adapter = adapter
if (!fromCatalogue)
manga_pager.currentItem = CHAPTERS_CONTROLLER
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
adapter = null
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
activity?.tabs?.setupWithViewPager(manga_pager)
trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) }
}
}
override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeEnded(handler, type)
if (manga == null || source == null) {
activity?.toast(R.string.manga_not_in_db)
router.popController(this)
}
}
override fun configureTabs(tabs: TabLayout) {
with(tabs) {
tabGravity = TabLayout.GRAVITY_FILL
tabMode = TabLayout.MODE_FIXED
}
}
override fun cleanupTabs(tabs: TabLayout) {
trackingIconSubscription?.unsubscribe()
setTrackingIconInternal(false)
}
fun setTrackingIcon(visible: Boolean) {
trackingIconRelay.call(visible)
}
private fun setTrackingIconInternal(visible: Boolean) {
val tab = activity?.tabs?.getTabAt(TRACK_CONTROLLER) ?: return
val drawable = if (visible)
VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null)
else null
val view = tabField.get(tab) as LinearLayout
val textView = view.getChildAt(1) as TextView
textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null)
textView.compoundDrawablePadding = if (visible) 4 else 0
}
private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) {
private val tabCount = if (Injekt.get<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) }
override fun getCount(): Int {
return tabCount
}
override fun configureRouter(router: Router, position: Int) {
if (!router.hasRootController()) {
val controller = when (position) {
INFO_CONTROLLER -> MangaInfoController()
CHAPTERS_CONTROLLER -> ChaptersController()
TRACK_CONTROLLER -> TrackController()
else -> error("Wrong position $position")
}
router.setRoot(RouterTransaction.with(controller))
}
}
override fun getPageTitle(position: Int): CharSequence {
return tabTitles[position]
}
}
companion object {
const val FROM_CATALOGUE_EXTRA = "from_catalogue"
const val MANGA_EXTRA = "manga"
const val INFO_CONTROLLER = 0
const val CHAPTERS_CONTROLLER = 1
const val TRACK_CONTROLLER = 2
private val tabField = TabLayout.Tab::class.java.getDeclaredField("view")
.apply { isAccessible = true }
}
}

View file

@ -1,122 +1,122 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import android.widget.PopupMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.util.getResourceColor
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.setVectorCompat
import kotlinx.android.synthetic.main.chapters_item.*
import java.util.*
class ChapterHolder(
private val view: View,
private val adapter: ChaptersAdapter
) : BaseFlexibleViewHolder(view, adapter) {
init {
// We need to post a Runnable to show the popup to make sure that the PopupMenu is
// correctly positioned. The reason being that the view may change position before the
// PopupMenu is shown.
chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } }
}
fun bind(item: ChapterItem, manga: Manga) {
val chapter = item.chapter
chapter_title.text = when (manga.displayMode) {
Manga.DISPLAY_NUMBER -> {
val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
itemView.context.getString(R.string.display_mode_chapter, number)
}
else -> chapter.name
}
// Set the correct drawable for dropdown and update the tint to match theme.
chapter_menu.setVectorCompat(R.drawable.ic_more_vert_black_24dp, view.context.getResourceColor(R.attr.icon_color))
// Set correct text color
chapter_title.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor)
if (chapter.bookmark) chapter_title.setTextColor(adapter.bookmarkedColor)
if (chapter.date_upload > 0) {
chapter_date.text = adapter.dateFormat.format(Date(chapter.date_upload))
chapter_date.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor)
} else {
chapter_date.text = ""
}
//add scanlator if exists
chapter_scanlator.text = chapter.scanlator
//allow longer titles if there is no scanlator (most sources)
if (chapter_scanlator.text.isNullOrBlank()) {
chapter_title.maxLines = 2
chapter_scanlator.gone()
} else {
chapter_title.maxLines = 1
}
chapter_pages.text = if (!chapter.read && chapter.last_page_read > 0) {
itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1)
} else {
""
}
notifyStatus(item.status)
}
fun notifyStatus(status: Int) = with(download_text) {
when (status) {
Download.QUEUE -> setText(R.string.chapter_queued)
Download.DOWNLOADING -> setText(R.string.chapter_downloading)
Download.DOWNLOADED -> setText(R.string.chapter_downloaded)
Download.ERROR -> setText(R.string.chapter_error)
else -> text = ""
}
}
private fun showPopupMenu(view: View) {
val item = adapter.getItem(adapterPosition) ?: return
// Create a PopupMenu, giving it the clicked view for an anchor
val popup = PopupMenu(view.context, view)
// Inflate our menu resource into the PopupMenu's Menu
popup.menuInflater.inflate(R.menu.chapter_single, popup.menu)
val chapter = item.chapter
// Hide download and show delete if the chapter is downloaded
if (item.isDownloaded) {
popup.menu.findItem(R.id.action_download).isVisible = false
popup.menu.findItem(R.id.action_delete).isVisible = true
}
// Hide bookmark if bookmark
popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark
popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark
// Hide mark as unread when the chapter is unread
if (!chapter.read && chapter.last_page_read == 0) {
popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false
}
// Hide mark as read when the chapter is read
if (chapter.read) {
popup.menu.findItem(R.id.action_mark_as_read).isVisible = false
}
// Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem ->
adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem)
true
}
// Finally show the PopupMenu
popup.show()
}
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import android.widget.PopupMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.util.getResourceColor
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.setVectorCompat
import kotlinx.android.synthetic.main.chapters_item.*
import java.util.*
class ChapterHolder(
private val view: View,
private val adapter: ChaptersAdapter
) : BaseFlexibleViewHolder(view, adapter) {
init {
// We need to post a Runnable to show the popup to make sure that the PopupMenu is
// correctly positioned. The reason being that the view may change position before the
// PopupMenu is shown.
chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } }
}
fun bind(item: ChapterItem, manga: Manga) {
val chapter = item.chapter
chapter_title.text = when (manga.displayMode) {
Manga.DISPLAY_NUMBER -> {
val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
itemView.context.getString(R.string.display_mode_chapter, number)
}
else -> chapter.name
}
// Set the correct drawable for dropdown and update the tint to match theme.
chapter_menu.setVectorCompat(R.drawable.ic_more_vert_black_24dp, view.context.getResourceColor(R.attr.icon_color))
// Set correct text color
chapter_title.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor)
if (chapter.bookmark) chapter_title.setTextColor(adapter.bookmarkedColor)
if (chapter.date_upload > 0) {
chapter_date.text = adapter.dateFormat.format(Date(chapter.date_upload))
chapter_date.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor)
} else {
chapter_date.text = ""
}
//add scanlator if exists
chapter_scanlator.text = chapter.scanlator
//allow longer titles if there is no scanlator (most sources)
if (chapter_scanlator.text.isNullOrBlank()) {
chapter_title.maxLines = 2
chapter_scanlator.gone()
} else {
chapter_title.maxLines = 1
}
chapter_pages.text = if (!chapter.read && chapter.last_page_read > 0) {
itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1)
} else {
""
}
notifyStatus(item.status)
}
fun notifyStatus(status: Int) = with(download_text) {
when (status) {
Download.QUEUE -> setText(R.string.chapter_queued)
Download.DOWNLOADING -> setText(R.string.chapter_downloading)
Download.DOWNLOADED -> setText(R.string.chapter_downloaded)
Download.ERROR -> setText(R.string.chapter_error)
else -> text = ""
}
}
private fun showPopupMenu(view: View) {
val item = adapter.getItem(adapterPosition) ?: return
// Create a PopupMenu, giving it the clicked view for an anchor
val popup = PopupMenu(view.context, view)
// Inflate our menu resource into the PopupMenu's Menu
popup.menuInflater.inflate(R.menu.chapter_single, popup.menu)
val chapter = item.chapter
// Hide download and show delete if the chapter is downloaded
if (item.isDownloaded) {
popup.menu.findItem(R.id.action_download).isVisible = false
popup.menu.findItem(R.id.action_delete).isVisible = true
}
// Hide bookmark if bookmark
popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark
popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark
// Hide mark as unread when the chapter is unread
if (!chapter.read && chapter.last_page_read == 0) {
popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false
}
// Hide mark as read when the chapter is read
if (chapter.read) {
popup.menu.findItem(R.id.action_mark_as_read).isVisible = false
}
// Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem ->
adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem)
true
}
// Finally show the PopupMenu
popup.show()
}
}

View file

@ -1,55 +1,55 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(),
Chapter by chapter {
private var _status: Int = 0
var status: Int
get() = download?.status ?: _status
set(value) { _status = value }
@Transient var download: Download? = null
val isDownloaded: Boolean
get() = status == Download.DOWNLOADED
override fun getLayoutRes(): Int {
return R.layout.chapters_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ChapterHolder {
return ChapterHolder(view, adapter as ChaptersAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ChapterHolder,
position: Int,
payloads: List<Any?>?) {
holder.bind(this, manga)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is ChapterItem) {
return chapter.id!! == other.chapter.id!!
}
return false
}
override fun hashCode(): Int {
return chapter.id!!.hashCode()
}
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(),
Chapter by chapter {
private var _status: Int = 0
var status: Int
get() = download?.status ?: _status
set(value) { _status = value }
@Transient var download: Download? = null
val isDownloaded: Boolean
get() = status == Download.DOWNLOADED
override fun getLayoutRes(): Int {
return R.layout.chapters_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ChapterHolder {
return ChapterHolder(view, adapter as ChaptersAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ChapterHolder,
position: Int,
payloads: List<Any?>?) {
holder.bind(this, manga)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is ChapterItem) {
return chapter.id!! == other.chapter.id!!
}
return false
}
override fun hashCode(): Int {
return chapter.id!!.hashCode()
}
}

View file

@ -1,45 +1,45 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.content.Context
import android.view.MenuItem
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceColor
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
class ChaptersAdapter(
controller: ChaptersController,
context: Context
) : FlexibleAdapter<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)
}
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.content.Context
import android.view.MenuItem
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceColor
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
class ChaptersAdapter(
controller: ChaptersController,
context: Context
) : FlexibleAdapter<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)
}
}

View file

@ -1,486 +1,486 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import com.google.android.material.snackbar.Snackbar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.*
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.view.clicks
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.getCoordinates
import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.chapters_controller.*
import timber.log.Timber
class ChaptersController : NucleusController<ChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ChaptersAdapter.OnMenuItemClickListener,
SetDisplayModeDialog.Listener,
SetSortingDialog.Listener,
DownloadChaptersDialog.Listener,
DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener {
/**
* Adapter containing a list of chapters.
*/
private var adapter: ChaptersAdapter? = null
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
/**
* Selected items. Used to restore selections after a rotation.
*/
private val selectedItems = mutableSetOf<ChapterItem>()
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
override fun createPresenter(): ChaptersPresenter {
val ctrl = parentController as MangaController
return ChaptersPresenter(ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.chapters_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Init RecyclerView and adapter
adapter = ChaptersAdapter(this, view.context)
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(view.context)
recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
adapter?.fastScroller = fast_scroller
swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() }
fab.clicks().subscribeUntilDestroy {
val item = presenter.getNextUnreadChapter()
if (item != null) {
// Create animation listener
val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
openChapter(item.chapter, true)
}
}
// Get coordinates and start animation
val coordinates = fab.getCoordinates()
if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) {
openChapter(item.chapter)
}
} else {
view.context.toast(R.string.no_next_chapter)
}
}
}
override fun onDestroyView(view: View) {
adapter = null
actionMode = null
super.onDestroyView(view)
}
override fun onActivityResumed(activity: Activity) {
if (view == null) return
// Check if animation view is visible
if (reveal_view.visibility == View.VISIBLE) {
// Show the unReveal effect
val coordinates = fab.getCoordinates()
reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920)
}
super.onActivityResumed(activity)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.chapters, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items.
val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
// Set correct checkbox values.
menuFilterRead.isChecked = presenter.onlyRead()
menuFilterUnread.isChecked = presenter.onlyUnread()
menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
if (presenter.onlyRead())
//Disable unread filter option if read filter is enabled.
menuFilterUnread.isEnabled = false
if (presenter.onlyUnread())
//Disable read filter option if unread filter is enabled.
menuFilterRead.isEnabled = false
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_display_mode -> showDisplayModeDialog()
R.id.manga_download -> showDownloadDialog()
R.id.action_sorting_mode -> showSortingDialog()
R.id.action_filter_unread -> {
item.isChecked = !item.isChecked
presenter.setUnreadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_read -> {
item.isChecked = !item.isChecked
presenter.setReadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_downloaded -> {
item.isChecked = !item.isChecked
presenter.setDownloadedFilter(item.isChecked)
}
R.id.action_filter_bookmarked -> {
item.isChecked = !item.isChecked
presenter.setBookmarkedFilter(item.isChecked)
}
R.id.action_filter_empty -> {
presenter.removeFilters()
activity?.invalidateOptionsMenu()
}
R.id.action_sort -> presenter.revertSortOrder()
else -> return super.onOptionsItemSelected(item)
}
return true
}
fun onNextChapters(chapters: List<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()
val adapter = adapter ?: return
adapter.updateDataSet(chapters)
if (selectedItems.isNotEmpty()) {
adapter.clearSelection() // we need to start from a clean state, index may have changed
createActionModeIfNeeded()
selectedItems.forEach { item ->
val position = adapter.indexOf(item)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
}
}
actionMode?.invalidate()
}
}
private fun initialFetchChapters() {
// Only fetch if this view is from the catalog and it hasn't requested previously
if ((parentController as MangaController).fromCatalogue && !presenter.hasRequested) {
fetchChaptersFromSource()
}
}
private fun fetchChaptersFromSource() {
swipe_refresh?.isRefreshing = true
presenter.fetchChaptersFromSource()
}
fun onFetchChaptersDone() {
swipe_refresh?.isRefreshing = false
}
fun onFetchChaptersError(error: Throwable) {
swipe_refresh?.isRefreshing = false
activity?.toast(error.message)
}
fun onChapterStatusChange(download: Download) {
getHolder(download.chapter)?.notifyStatus(download.status)
}
private fun getHolder(chapter: Chapter): ChapterHolder? {
return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
if (hasAnimation) {
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
startActivity(intent)
}
override fun onItemClick(view: View, position: Int): Boolean {
val adapter = adapter ?: return false
val item = adapter.getItem(position) ?: return false
if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position)
return true
} else {
openChapter(item.chapter)
return false
}
}
override fun onItemLongClick(position: Int) {
createActionModeIfNeeded()
toggleSelection(position)
}
// SELECTIONS & ACTION MODE
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
val item = adapter.getItem(position) ?: return
adapter.toggleSelection(position)
if (adapter.isSelected(position)) {
selectedItems.add(item)
} else {
selectedItems.remove(item)
}
actionMode?.invalidate()
}
private fun getSelectedChapters(): List<ChapterItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
}
private fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
}
private fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.chapter_selection, menu)
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
@SuppressLint("StringFormatInvalid")
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = resources?.getString(R.string.label_selected, count)
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_select_all -> selectAll()
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete -> showDeleteChaptersConfirmationDialog()
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
adapter?.mode = SelectableAdapter.Mode.SINGLE
adapter?.clearSelection()
selectedItems.clear()
actionMode = null
}
override fun onMenuItemClick(position: Int, item: MenuItem) {
val chapter = adapter?.getItem(position) ?: return
val chapters = listOf(chapter)
when (item.itemId) {
R.id.action_download -> downloadChapters(chapters)
R.id.action_bookmark -> bookmarkChapters(chapters, true)
R.id.action_remove_bookmark -> bookmarkChapters(chapters, false)
R.id.action_delete -> deleteChapters(chapters)
R.id.action_mark_as_read -> markAsRead(chapters)
R.id.action_mark_as_unread -> markAsUnread(chapters)
R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter)
}
}
// SELECTION MODE ACTIONS
private fun selectAll() {
val adapter = adapter ?: return
adapter.selectAll()
selectedItems.addAll(adapter.items)
actionMode?.invalidate()
}
private fun markAsRead(chapters: List<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
destroyActionModeIfNeeded()
presenter.downloadChapters(chapters)
if (view != null && !presenter.manga.favorite) {
recycler?.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_add) {
presenter.addToLibrary()
}
}
}
}
private fun showDeleteChaptersConfirmationDialog() {
DeleteChaptersDialog(this).showDialog(router)
}
override fun deleteChapters() {
deleteChapters(getSelectedChapters())
}
private fun markPreviousAsRead(chapter: ChapterItem) {
val adapter = adapter ?: return
val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = chapters.indexOf(chapter)
if (chapterPos != -1) {
markAsRead(chapters.take(chapterPos))
}
}
private fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) {
destroyActionModeIfNeeded()
presenter.bookmarkChapters(chapters, bookmarked)
}
fun deleteChapters(chapters: List<ChapterItem>) {
destroyActionModeIfNeeded()
if (chapters.isEmpty()) return
DeletingChaptersDialog().showDialog(router)
presenter.deleteChapters(chapters)
}
fun onChaptersDeleted() {
dismissDeletingDialog()
adapter?.notifyDataSetChanged()
}
fun onChaptersDeletedError(error: Throwable) {
dismissDeletingDialog()
Timber.e(error)
}
private fun dismissDeletingDialog() {
router.popControllerWithTag(DeletingChaptersDialog.TAG)
}
// OVERFLOW MENU DIALOGS
private fun showDisplayModeDialog() {
val preselected = if (presenter.manga.displayMode == Manga.DISPLAY_NAME) 0 else 1
SetDisplayModeDialog(this, preselected).showDialog(router)
}
override fun setDisplayMode(id: Int) {
presenter.setDisplayMode(id)
adapter?.notifyDataSetChanged()
}
private fun showSortingDialog() {
val preselected = if (presenter.manga.sorting == Manga.SORTING_SOURCE) 0 else 1
SetSortingDialog(this, preselected).showDialog(router)
}
override fun setSorting(id: Int) {
presenter.setSorting(id)
}
private fun showDownloadDialog() {
DownloadChaptersDialog(this).showDialog(router)
}
private fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
private fun showCustomDownloadDialog() {
DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router)
}
override fun downloadChapters(choice: Int) {
// i = 0: Download 1
// i = 1: Download 5
// i = 2: Download 10
// i = 3: Download x
// i = 4: Download unread
// i = 5: Download all
val chaptersToDownload = when (choice) {
0 -> getUnreadChaptersSorted().take(1)
1 -> getUnreadChaptersSorted().take(5)
2 -> getUnreadChaptersSorted().take(10)
3 -> {
showCustomDownloadDialog()
return
}
4 -> presenter.chapters.filter { !it.read }
5 -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import com.google.android.material.snackbar.Snackbar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.*
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.view.clicks
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.getCoordinates
import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.chapters_controller.*
import timber.log.Timber
class ChaptersController : NucleusController<ChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ChaptersAdapter.OnMenuItemClickListener,
SetDisplayModeDialog.Listener,
SetSortingDialog.Listener,
DownloadChaptersDialog.Listener,
DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener {
/**
* Adapter containing a list of chapters.
*/
private var adapter: ChaptersAdapter? = null
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
/**
* Selected items. Used to restore selections after a rotation.
*/
private val selectedItems = mutableSetOf<ChapterItem>()
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
override fun createPresenter(): ChaptersPresenter {
val ctrl = parentController as MangaController
return ChaptersPresenter(ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.chapters_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Init RecyclerView and adapter
adapter = ChaptersAdapter(this, view.context)
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(view.context)
recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
adapter?.fastScroller = fast_scroller
swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() }
fab.clicks().subscribeUntilDestroy {
val item = presenter.getNextUnreadChapter()
if (item != null) {
// Create animation listener
val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
openChapter(item.chapter, true)
}
}
// Get coordinates and start animation
val coordinates = fab.getCoordinates()
if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) {
openChapter(item.chapter)
}
} else {
view.context.toast(R.string.no_next_chapter)
}
}
}
override fun onDestroyView(view: View) {
adapter = null
actionMode = null
super.onDestroyView(view)
}
override fun onActivityResumed(activity: Activity) {
if (view == null) return
// Check if animation view is visible
if (reveal_view.visibility == View.VISIBLE) {
// Show the unReveal effect
val coordinates = fab.getCoordinates()
reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920)
}
super.onActivityResumed(activity)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.chapters, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items.
val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
// Set correct checkbox values.
menuFilterRead.isChecked = presenter.onlyRead()
menuFilterUnread.isChecked = presenter.onlyUnread()
menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
if (presenter.onlyRead())
//Disable unread filter option if read filter is enabled.
menuFilterUnread.isEnabled = false
if (presenter.onlyUnread())
//Disable read filter option if unread filter is enabled.
menuFilterRead.isEnabled = false
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_display_mode -> showDisplayModeDialog()
R.id.manga_download -> showDownloadDialog()
R.id.action_sorting_mode -> showSortingDialog()
R.id.action_filter_unread -> {
item.isChecked = !item.isChecked
presenter.setUnreadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_read -> {
item.isChecked = !item.isChecked
presenter.setReadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_downloaded -> {
item.isChecked = !item.isChecked
presenter.setDownloadedFilter(item.isChecked)
}
R.id.action_filter_bookmarked -> {
item.isChecked = !item.isChecked
presenter.setBookmarkedFilter(item.isChecked)
}
R.id.action_filter_empty -> {
presenter.removeFilters()
activity?.invalidateOptionsMenu()
}
R.id.action_sort -> presenter.revertSortOrder()
else -> return super.onOptionsItemSelected(item)
}
return true
}
fun onNextChapters(chapters: List<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()
val adapter = adapter ?: return
adapter.updateDataSet(chapters)
if (selectedItems.isNotEmpty()) {
adapter.clearSelection() // we need to start from a clean state, index may have changed
createActionModeIfNeeded()
selectedItems.forEach { item ->
val position = adapter.indexOf(item)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
}
}
actionMode?.invalidate()
}
}
private fun initialFetchChapters() {
// Only fetch if this view is from the catalog and it hasn't requested previously
if ((parentController as MangaController).fromCatalogue && !presenter.hasRequested) {
fetchChaptersFromSource()
}
}
private fun fetchChaptersFromSource() {
swipe_refresh?.isRefreshing = true
presenter.fetchChaptersFromSource()
}
fun onFetchChaptersDone() {
swipe_refresh?.isRefreshing = false
}
fun onFetchChaptersError(error: Throwable) {
swipe_refresh?.isRefreshing = false
activity?.toast(error.message)
}
fun onChapterStatusChange(download: Download) {
getHolder(download.chapter)?.notifyStatus(download.status)
}
private fun getHolder(chapter: Chapter): ChapterHolder? {
return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
if (hasAnimation) {
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
startActivity(intent)
}
override fun onItemClick(view: View, position: Int): Boolean {
val adapter = adapter ?: return false
val item = adapter.getItem(position) ?: return false
if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position)
return true
} else {
openChapter(item.chapter)
return false
}
}
override fun onItemLongClick(position: Int) {
createActionModeIfNeeded()
toggleSelection(position)
}
// SELECTIONS & ACTION MODE
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
val item = adapter.getItem(position) ?: return
adapter.toggleSelection(position)
if (adapter.isSelected(position)) {
selectedItems.add(item)
} else {
selectedItems.remove(item)
}
actionMode?.invalidate()
}
private fun getSelectedChapters(): List<ChapterItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
}
private fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
}
private fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.chapter_selection, menu)
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
@SuppressLint("StringFormatInvalid")
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = resources?.getString(R.string.label_selected, count)
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_select_all -> selectAll()
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete -> showDeleteChaptersConfirmationDialog()
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
adapter?.mode = SelectableAdapter.Mode.SINGLE
adapter?.clearSelection()
selectedItems.clear()
actionMode = null
}
override fun onMenuItemClick(position: Int, item: MenuItem) {
val chapter = adapter?.getItem(position) ?: return
val chapters = listOf(chapter)
when (item.itemId) {
R.id.action_download -> downloadChapters(chapters)
R.id.action_bookmark -> bookmarkChapters(chapters, true)
R.id.action_remove_bookmark -> bookmarkChapters(chapters, false)
R.id.action_delete -> deleteChapters(chapters)
R.id.action_mark_as_read -> markAsRead(chapters)
R.id.action_mark_as_unread -> markAsUnread(chapters)
R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter)
}
}
// SELECTION MODE ACTIONS
private fun selectAll() {
val adapter = adapter ?: return
adapter.selectAll()
selectedItems.addAll(adapter.items)
actionMode?.invalidate()
}
private fun markAsRead(chapters: List<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
destroyActionModeIfNeeded()
presenter.downloadChapters(chapters)
if (view != null && !presenter.manga.favorite) {
recycler?.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_add) {
presenter.addToLibrary()
}
}
}
}
private fun showDeleteChaptersConfirmationDialog() {
DeleteChaptersDialog(this).showDialog(router)
}
override fun deleteChapters() {
deleteChapters(getSelectedChapters())
}
private fun markPreviousAsRead(chapter: ChapterItem) {
val adapter = adapter ?: return
val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = chapters.indexOf(chapter)
if (chapterPos != -1) {
markAsRead(chapters.take(chapterPos))
}
}
private fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) {
destroyActionModeIfNeeded()
presenter.bookmarkChapters(chapters, bookmarked)
}
fun deleteChapters(chapters: List<ChapterItem>) {
destroyActionModeIfNeeded()
if (chapters.isEmpty()) return
DeletingChaptersDialog().showDialog(router)
presenter.deleteChapters(chapters)
}
fun onChaptersDeleted() {
dismissDeletingDialog()
adapter?.notifyDataSetChanged()
}
fun onChaptersDeletedError(error: Throwable) {
dismissDeletingDialog()
Timber.e(error)
}
private fun dismissDeletingDialog() {
router.popControllerWithTag(DeletingChaptersDialog.TAG)
}
// OVERFLOW MENU DIALOGS
private fun showDisplayModeDialog() {
val preselected = if (presenter.manga.displayMode == Manga.DISPLAY_NAME) 0 else 1
SetDisplayModeDialog(this, preselected).showDialog(router)
}
override fun setDisplayMode(id: Int) {
presenter.setDisplayMode(id)
adapter?.notifyDataSetChanged()
}
private fun showSortingDialog() {
val preselected = if (presenter.manga.sorting == Manga.SORTING_SOURCE) 0 else 1
SetSortingDialog(this, preselected).showDialog(router)
}
override fun setSorting(id: Int) {
presenter.setSorting(id)
}
private fun showDownloadDialog() {
DownloadChaptersDialog(this).showDialog(router)
}
private fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
private fun showCustomDownloadDialog() {
DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router)
}
override fun downloadChapters(choice: Int) {
// i = 0: Download 1
// i = 1: Download 5
// i = 2: Download 10
// i = 3: Download x
// i = 4: Download unread
// i = 5: Download all
val chaptersToDownload = when (choice) {
0 -> getUnreadChaptersSorted().take(1)
1 -> getUnreadChaptersSorted().take(5)
2 -> getUnreadChaptersSorted().take(10)
3 -> {
showCustomDownloadDialog()
return
}
4 -> presenter.chapters.filter { !it.read }
5 -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
}

View file

@ -1,418 +1,418 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
/**
* Presenter of [ChaptersController].
*/
class ChaptersPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get()
) : BasePresenter<ChaptersController>() {
/**
* List of chapters of the manga. It's always unfiltered and unsorted.
*/
var chapters: List<ChapterItem> = emptyList()
private set
/**
* 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
private set
/**
* Subscription to retrieve the new list of chapters from the source.
*/
private var fetchChaptersSubscription: Subscription? = null
/**
* Subscription to observe download status changes.
*/
private var observeDownloadsSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
// Prepare the relay.
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(ChaptersController::onNextChapters,
{ _, error -> Timber.e(error) })
// Add the subscription that retrieves the chapters from the database, keeps subscribed to
// changes, and sends the list of chapters to the relay.
add(db.getChapters(manga).asRxObservable()
.map { chapters ->
// Convert every chapter to a model.
chapters.map { it.toModel() }
}
.doOnNext { chapters ->
// Find downloaded chapters
setDownloadedChapters(chapters)
// Store the last emission
this.chapters = chapters
// Listen for download status changes
observeDownloads()
// Emit the number of chapters to the info tab.
chapterCountRelay.call(chapters.maxBy { it.chapter_number }?.chapter_number
?: 0f)
// Emit the upload date of the most recent chapter
lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload
?: 0))
}
.subscribe { chaptersRelay.call(it) })
}
private fun observeDownloads() {
observeDownloadsSubscription?.let { remove(it) }
observeDownloadsSubscription = downloadManager.queue.getStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.filter { download -> download.manga.id == manga.id }
.doOnNext { onDownloadStatusChange(it) }
.subscribeLatestCache(ChaptersController::onChapterStatusChange,
{ _, error -> Timber.e(error) })
}
/**
* Converts a chapter from the database to an extended model, allowing to store new fields.
*/
private fun Chapter.toModel(): ChapterItem {
// Create the model object.
val model = ChapterItem(this, manga)
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == id }
if (download != null) {
// If there's an active download, assign it.
model.download = download
}
return model
}
/**
* Finds and assigns the list of downloaded chapters.
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<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) }
.subscribeOn(Schedulers.io())
.map { syncChaptersWithSource(db, it, manga, source) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onFetchChaptersDone()
}, ChaptersController::onFetchChaptersError)
}
/**
* Updates the UI after applying the filters.
*/
private fun refreshChapters() {
chaptersRelay.call(chapters)
}
/**
* Applies the view filters to the list of chapters obtained from the database.
* @param chapters the list of chapters from the database
* @return an observable of the list of chapters filtered and sorted.
*/
private fun applyChapterFilters(chapters: List<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() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Downloads the given list of chapters with the manager.
* @param chapters the list of chapters to download.
*/
fun downloadChapters(chapters: List<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) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.bookmark = bookmarked
}
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Deletes the given list of chapter.
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<ChapterItem>) {
Observable.just(chapters)
.doOnNext { deleteChaptersInternal(chapters) }
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onChaptersDeleted()
}, ChaptersController::onChaptersDeletedError)
}
/**
* Deletes a list of chapters from disk. This method is called in a background thread.
* @param chapters the chapters to delete.
*/
private fun deleteChaptersInternal(chapters: List<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()
refreshChapters()
}
/**
* Sets the read filter and requests an UI update.
* @param onlyUnread whether to display only unread chapters or all chapters.
*/
fun setUnreadFilter(onlyUnread: Boolean) {
manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the read filter and requests an UI update.
* @param onlyRead whether to display only read chapters or all chapters.
*/
fun setReadFilter(onlyRead: Boolean) {
manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the download filter and requests an UI update.
* @param onlyDownloaded whether to display only downloaded chapters or all chapters.
*/
fun setDownloadedFilter(onlyDownloaded: Boolean) {
manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the bookmark filter and requests an UI update.
* @param onlyBookmarked whether to display only bookmarked chapters or all chapters.
*/
fun setBookmarkedFilter(onlyBookmarked: Boolean) {
manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Removes all filters and requests an UI update.
*/
fun removeFilters() {
manga.readFilter = Manga.SHOW_ALL
manga.downloadedFilter = Manga.SHOW_ALL
manga.bookmarkedFilter = Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Adds manga to library
*/
fun addToLibrary() {
mangaFavoriteRelay.call(true)
}
/**
* Sets the active display mode.
* @param mode the mode to set.
*/
fun setDisplayMode(mode: Int) {
manga.displayMode = mode
db.updateFlags(manga).executeAsBlocking()
}
/**
* Sets the sorting method and requests an UI update.
* @param sort the sorting mode.
*/
fun setSorting(sort: Int) {
manga.sorting = sort
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyDownloaded(): Boolean {
return manga.downloadedFilter == Manga.SHOW_DOWNLOADED
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyBookmarked(): Boolean {
return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED
}
/**
* Whether the display only unread filter is enabled.
*/
fun onlyUnread(): Boolean {
return manga.readFilter == Manga.SHOW_UNREAD
}
/**
* Whether the display only read filter is enabled.
*/
fun onlyRead(): Boolean {
return manga.readFilter == Manga.SHOW_READ
}
/**
* Whether the sorting method is descending or ascending.
*/
fun sortDescending(): Boolean {
return manga.sortDescending()
}
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
/**
* Presenter of [ChaptersController].
*/
class ChaptersPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get()
) : BasePresenter<ChaptersController>() {
/**
* List of chapters of the manga. It's always unfiltered and unsorted.
*/
var chapters: List<ChapterItem> = emptyList()
private set
/**
* 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
private set
/**
* Subscription to retrieve the new list of chapters from the source.
*/
private var fetchChaptersSubscription: Subscription? = null
/**
* Subscription to observe download status changes.
*/
private var observeDownloadsSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
// Prepare the relay.
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(ChaptersController::onNextChapters,
{ _, error -> Timber.e(error) })
// Add the subscription that retrieves the chapters from the database, keeps subscribed to
// changes, and sends the list of chapters to the relay.
add(db.getChapters(manga).asRxObservable()
.map { chapters ->
// Convert every chapter to a model.
chapters.map { it.toModel() }
}
.doOnNext { chapters ->
// Find downloaded chapters
setDownloadedChapters(chapters)
// Store the last emission
this.chapters = chapters
// Listen for download status changes
observeDownloads()
// Emit the number of chapters to the info tab.
chapterCountRelay.call(chapters.maxBy { it.chapter_number }?.chapter_number
?: 0f)
// Emit the upload date of the most recent chapter
lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload
?: 0))
}
.subscribe { chaptersRelay.call(it) })
}
private fun observeDownloads() {
observeDownloadsSubscription?.let { remove(it) }
observeDownloadsSubscription = downloadManager.queue.getStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.filter { download -> download.manga.id == manga.id }
.doOnNext { onDownloadStatusChange(it) }
.subscribeLatestCache(ChaptersController::onChapterStatusChange,
{ _, error -> Timber.e(error) })
}
/**
* Converts a chapter from the database to an extended model, allowing to store new fields.
*/
private fun Chapter.toModel(): ChapterItem {
// Create the model object.
val model = ChapterItem(this, manga)
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == id }
if (download != null) {
// If there's an active download, assign it.
model.download = download
}
return model
}
/**
* Finds and assigns the list of downloaded chapters.
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<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) }
.subscribeOn(Schedulers.io())
.map { syncChaptersWithSource(db, it, manga, source) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onFetchChaptersDone()
}, ChaptersController::onFetchChaptersError)
}
/**
* Updates the UI after applying the filters.
*/
private fun refreshChapters() {
chaptersRelay.call(chapters)
}
/**
* Applies the view filters to the list of chapters obtained from the database.
* @param chapters the list of chapters from the database
* @return an observable of the list of chapters filtered and sorted.
*/
private fun applyChapterFilters(chapters: List<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() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Downloads the given list of chapters with the manager.
* @param chapters the list of chapters to download.
*/
fun downloadChapters(chapters: List<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) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.bookmark = bookmarked
}
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Deletes the given list of chapter.
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<ChapterItem>) {
Observable.just(chapters)
.doOnNext { deleteChaptersInternal(chapters) }
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onChaptersDeleted()
}, ChaptersController::onChaptersDeletedError)
}
/**
* Deletes a list of chapters from disk. This method is called in a background thread.
* @param chapters the chapters to delete.
*/
private fun deleteChaptersInternal(chapters: List<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()
refreshChapters()
}
/**
* Sets the read filter and requests an UI update.
* @param onlyUnread whether to display only unread chapters or all chapters.
*/
fun setUnreadFilter(onlyUnread: Boolean) {
manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the read filter and requests an UI update.
* @param onlyRead whether to display only read chapters or all chapters.
*/
fun setReadFilter(onlyRead: Boolean) {
manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the download filter and requests an UI update.
* @param onlyDownloaded whether to display only downloaded chapters or all chapters.
*/
fun setDownloadedFilter(onlyDownloaded: Boolean) {
manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the bookmark filter and requests an UI update.
* @param onlyBookmarked whether to display only bookmarked chapters or all chapters.
*/
fun setBookmarkedFilter(onlyBookmarked: Boolean) {
manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Removes all filters and requests an UI update.
*/
fun removeFilters() {
manga.readFilter = Manga.SHOW_ALL
manga.downloadedFilter = Manga.SHOW_ALL
manga.bookmarkedFilter = Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Adds manga to library
*/
fun addToLibrary() {
mangaFavoriteRelay.call(true)
}
/**
* Sets the active display mode.
* @param mode the mode to set.
*/
fun setDisplayMode(mode: Int) {
manga.displayMode = mode
db.updateFlags(manga).executeAsBlocking()
}
/**
* Sets the sorting method and requests an UI update.
* @param sort the sorting mode.
*/
fun setSorting(sort: Int) {
manga.sorting = sort
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyDownloaded(): Boolean {
return manga.downloadedFilter == Manga.SHOW_DOWNLOADED
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyBookmarked(): Boolean {
return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED
}
/**
* Whether the display only unread filter is enabled.
*/
fun onlyUnread(): Boolean {
return manga.readFilter == Manga.SHOW_UNREAD
}
/**
* Whether the display only read filter is enabled.
*/
fun onlyRead(): Boolean {
return manga.readFilter == Manga.SHOW_READ
}
/**
* Whether the sorting method is descending or ascending.
*/
fun sortDescending(): Boolean {
return manga.sortDescending()
}
}

View file

@ -1,32 +1,32 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : DeleteChaptersDialog.Listener {
constructor(target: T) : this() {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog.Builder(activity!!)
.content(R.string.confirm_delete_chapters)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { _, _ ->
(targetController as? Listener)?.deleteChapters()
}
.show()
}
interface Listener {
fun deleteChapters()
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : DeleteChaptersDialog.Listener {
constructor(target: T) : this() {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog.Builder(activity!!)
.content(R.string.confirm_delete_chapters)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { _, _ ->
(targetController as? Listener)?.deleteChapters()
}
.show()
}
interface Listener {
fun deleteChapters()
}
}

View file

@ -1,27 +1,27 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Router
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) {
companion object {
const val TAG = "deleting_dialog"
}
override fun onCreateDialog(savedState: Bundle?): Dialog {
return MaterialDialog.Builder(activity!!)
.progress(true, 0)
.content(R.string.deleting)
.build()
}
override fun showDialog(router: Router) {
showDialog(router, TAG)
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Router
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) {
companion object {
const val TAG = "deleting_dialog"
}
override fun onCreateDialog(savedState: Bundle?): Dialog {
return MaterialDialog.Builder(activity!!)
.progress(true, 0)
.content(R.string.deleting)
.build()
}
override fun showDialog(router: Router) {
showDialog(router, TAG)
}
}

View file

@ -1,42 +1,42 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DownloadChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : DownloadChaptersDialog.Listener {
constructor(target: T) : this() {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
val choices = intArrayOf(
R.string.download_1,
R.string.download_5,
R.string.download_10,
R.string.download_custom,
R.string.download_unread,
R.string.download_all
).map { activity.getString(it) }
return MaterialDialog.Builder(activity)
.negativeText(android.R.string.cancel)
.items(choices)
.itemsCallback { _, _, position, _ ->
(targetController as? Listener)?.downloadChapters(position)
}
.build()
}
interface Listener {
fun downloadChapters(choice: Int)
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DownloadChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : DownloadChaptersDialog.Listener {
constructor(target: T) : this() {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
val choices = intArrayOf(
R.string.download_1,
R.string.download_5,
R.string.download_10,
R.string.download_custom,
R.string.download_unread,
R.string.download_all
).map { activity.getString(it) }
return MaterialDialog.Builder(activity)
.negativeText(android.R.string.cancel)
.items(choices)
.itemsCallback { _, _, position, _ ->
(targetController as? Listener)?.downloadChapters(position)
}
.build()
}
interface Listener {
fun downloadChapters(choice: Int)
}
}

View file

@ -1,43 +1,43 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class SetDisplayModeDialog<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)
}) {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
val ids = intArrayOf(Manga.DISPLAY_NAME, Manga.DISPLAY_NUMBER)
val choices = intArrayOf(R.string.show_title, R.string.show_chapter_number)
.map { activity.getString(it) }
return MaterialDialog.Builder(activity)
.title(R.string.action_display_mode)
.items(choices)
.itemsIds(ids)
.itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
(targetController as? Listener)?.setDisplayMode(itemView.id)
true
}
.build()
}
interface Listener {
fun setDisplayMode(id: Int)
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class SetDisplayModeDialog<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)
}) {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
val ids = intArrayOf(Manga.DISPLAY_NAME, Manga.DISPLAY_NUMBER)
val choices = intArrayOf(R.string.show_title, R.string.show_chapter_number)
.map { activity.getString(it) }
return MaterialDialog.Builder(activity)
.title(R.string.action_display_mode)
.items(choices)
.itemsIds(ids)
.itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
(targetController as? Listener)?.setDisplayMode(itemView.id)
true
}
.build()
}
interface Listener {
fun setDisplayMode(id: Int)
}
}

View file

@ -1,43 +1,43 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class SetSortingDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : SetSortingDialog.Listener {
private val selectedIndex = args.getInt("selected", -1)
constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply {
putInt("selected", selectedIndex)
}) {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER)
val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number)
.map { activity.getString(it) }
return MaterialDialog.Builder(activity)
.title(R.string.sorting_mode)
.items(choices)
.itemsIds(ids)
.itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
(targetController as? Listener)?.setSorting(itemView.id)
true
}
.build()
}
interface Listener {
fun setSorting(id: Int)
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class SetSortingDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : SetSortingDialog.Listener {
private val selectedIndex = args.getInt("selected", -1)
constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply {
putInt("selected", selectedIndex)
}) {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER)
val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number)
.map { activity.getString(it) }
return MaterialDialog.Builder(activity)
.title(R.string.sorting_mode)
.items(choices)
.itemsIds(ids)
.itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
(targetController as? Listener)?.setSorting(itemView.id)
true
}
.build()
}
interface Listener {
fun setSorting(id: Int)
}
}

View file

@ -1,173 +1,173 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.*
/**
* Presenter of MangaInfoFragment.
* Contains information and data for fragment.
* Observable updates should be called from here.
*/
class MangaInfoPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
private val db: DatabaseHelper = Injekt.get(),
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
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
sendMangaToView()
// Update chapter count
chapterCountRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setChapterCount)
// Update favorite status
mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
//update last update date
lastUpdateRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setLastUpdateDate)
}
/**
* Sends the active manga to the view.
*/
fun sendMangaToView() {
viewMangaSubscription?.let { remove(it) }
viewMangaSubscription = Observable.just(manga)
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
}
/**
* Fetch manga information from source.
*/
fun fetchMangaFromSource() {
if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
.map { networkManga ->
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
manga
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { sendMangaToView() }
.subscribeFirst({ view, _ ->
view.onFetchMangaDone()
}, MangaInfoController::onFetchMangaError)
}
/**
* Update favorite status of manga, (removes / adds) manga (to / from) library.
*
* @return the new status of the manga.
*/
fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite
if (!manga.favorite) {
coverCache.deleteFromCache(manga.thumbnail_url)
}
db.insertManga(manga).executeAsBlocking()
sendMangaToView()
return manga.favorite
}
private fun setFavorite(favorite: Boolean) {
if (manga.favorite == favorite) {
return
}
toggleFavorite()
}
/**
* Returns true if the manga has any downloads.
*/
fun hasDownloads(): Boolean {
return downloadManager.getDownloadCount(manga) > 0
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.deleteManga(manga, source)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<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.
* @param categories the selected categories.
*/
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 manga the manga to move.
* @param category the selected category, or null for default category.
*/
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
}
package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.*
/**
* Presenter of MangaInfoFragment.
* Contains information and data for fragment.
* Observable updates should be called from here.
*/
class MangaInfoPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
private val db: DatabaseHelper = Injekt.get(),
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
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
sendMangaToView()
// Update chapter count
chapterCountRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setChapterCount)
// Update favorite status
mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
//update last update date
lastUpdateRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setLastUpdateDate)
}
/**
* Sends the active manga to the view.
*/
fun sendMangaToView() {
viewMangaSubscription?.let { remove(it) }
viewMangaSubscription = Observable.just(manga)
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
}
/**
* Fetch manga information from source.
*/
fun fetchMangaFromSource() {
if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
.map { networkManga ->
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
manga
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { sendMangaToView() }
.subscribeFirst({ view, _ ->
view.onFetchMangaDone()
}, MangaInfoController::onFetchMangaError)
}
/**
* Update favorite status of manga, (removes / adds) manga (to / from) library.
*
* @return the new status of the manga.
*/
fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite
if (!manga.favorite) {
coverCache.deleteFromCache(manga.thumbnail_url)
}
db.insertManga(manga).executeAsBlocking()
sendMangaToView()
return manga.favorite
}
private fun setFavorite(favorite: Boolean) {
if (manga.favorite == favorite) {
return
}
toggleFavorite()
}
/**
* Returns true if the manga has any downloads.
*/
fun hasDownloads(): Boolean {
return downloadManager.getDownloadCount(manga) > 0
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.deleteManga(manga, source)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<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.
* @param categories the selected categories.
*/
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 manga the manga to move.
* @param category the selected category, or null for default category.
*/
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
}

View file

@ -1,74 +1,74 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.widget.NumberPicker
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackChaptersDialog<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)
}) {
targetController = target
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val dialog = MaterialDialog.Builder(activity!!)
.title(R.string.chapters)
.customView(R.layout.track_chapters_dialog, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog, _ ->
val view = dialog.customView
if (view != null) {
// Remove focus to update selected number
val np: NumberPicker = view.findViewById(R.id.chapters_picker)
np.clearFocus()
(targetController as? Listener)?.setChaptersRead(item, np.value)
}
}
.build()
val view = dialog.customView
if (view != null) {
val np: NumberPicker = view.findViewById(R.id.chapters_picker)
// Set initial value
np.value = item.track?.last_chapter_read ?: 0
// Don't allow to go from 0 to 9999
np.wrapSelectorWheel = false
}
return dialog
}
interface Listener {
fun setChaptersRead(item: TrackItem, chaptersRead: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track"
}
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.widget.NumberPicker
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackChaptersDialog<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)
}) {
targetController = target
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val dialog = MaterialDialog.Builder(activity!!)
.title(R.string.chapters)
.customView(R.layout.track_chapters_dialog, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog, _ ->
val view = dialog.customView
if (view != null) {
// Remove focus to update selected number
val np: NumberPicker = view.findViewById(R.id.chapters_picker)
np.clearFocus()
(targetController as? Listener)?.setChaptersRead(item, np.value)
}
}
.build()
val view = dialog.customView
if (view != null) {
val np: NumberPicker = view.findViewById(R.id.chapters_picker)
// Set initial value
np.value = item.track?.last_chapter_read ?: 0
// Don't allow to go from 0 to 9999
np.wrapSelectorWheel = false
}
return dialog
}
interface Listener {
fun setChaptersRead(item: TrackItem, chaptersRead: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track"
}
}

View file

@ -1,80 +1,80 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.widget.NumberPicker
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackScoreDialog<T> : DialogController
where T : Controller, T : SetTrackScoreDialog.Listener {
private val item: TrackItem
constructor(target: T, item: TrackItem) : super(Bundle().apply {
putSerializable(KEY_ITEM_TRACK, item.track)
}) {
targetController = target
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val dialog = MaterialDialog.Builder(activity!!)
.title(R.string.score)
.customView(R.layout.track_score_dialog, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog, _ ->
val view = dialog.customView
if (view != null) {
// Remove focus to update selected number
val np: NumberPicker = view.findViewById(R.id.score_picker)
np.clearFocus()
(targetController as? Listener)?.setScore(item, np.value)
}
}
.show()
val view = dialog.customView
if (view != null) {
val np: NumberPicker = view.findViewById(R.id.score_picker)
val scores = item.service.getScoreList().toTypedArray()
np.maxValue = scores.size - 1
np.displayedValues = scores
// Set initial value
val displayedScore = item.service.displayScore(item.track!!)
if (displayedScore != "-") {
val index = scores.indexOf(displayedScore)
np.value = if (index != -1) index else 0
}
}
return dialog
}
interface Listener {
fun setScore(item: TrackItem, score: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track"
}
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.widget.NumberPicker
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackScoreDialog<T> : DialogController
where T : Controller, T : SetTrackScoreDialog.Listener {
private val item: TrackItem
constructor(target: T, item: TrackItem) : super(Bundle().apply {
putSerializable(KEY_ITEM_TRACK, item.track)
}) {
targetController = target
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val dialog = MaterialDialog.Builder(activity!!)
.title(R.string.score)
.customView(R.layout.track_score_dialog, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog, _ ->
val view = dialog.customView
if (view != null) {
// Remove focus to update selected number
val np: NumberPicker = view.findViewById(R.id.score_picker)
np.clearFocus()
(targetController as? Listener)?.setScore(item, np.value)
}
}
.show()
val view = dialog.customView
if (view != null) {
val np: NumberPicker = view.findViewById(R.id.score_picker)
val scores = item.service.getScoreList().toTypedArray()
np.maxValue = scores.size - 1
np.displayedValues = scores
// Set initial value
val displayedScore = item.service.displayScore(item.track!!)
if (displayedScore != "-") {
val index = scores.indexOf(displayedScore)
np.value = if (index != -1) index else 0
}
}
return dialog
}
interface Listener {
fun setScore(item: TrackItem, score: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track"
}
}

View file

@ -1,58 +1,58 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackStatusDialog<T> : DialogController
where T : Controller, T : SetTrackStatusDialog.Listener {
private val item: TrackItem
constructor(target: T, item: TrackItem) : super(Bundle().apply {
putSerializable(KEY_ITEM_TRACK, item.track)
}) {
targetController = target
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val statusList = item.service.getStatusList().orEmpty()
val statusString = statusList.mapNotNull { item.service.getStatus(it) }
val selectedIndex = statusList.indexOf(item.track?.status)
return MaterialDialog.Builder(activity!!)
.title(R.string.status)
.negativeText(android.R.string.cancel)
.items(statusString)
.itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ ->
(targetController as? Listener)?.setStatus(item, i)
true
})
.build()
}
interface Listener {
fun setStatus(item: TrackItem, selection: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track"
}
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackStatusDialog<T> : DialogController
where T : Controller, T : SetTrackStatusDialog.Listener {
private val item: TrackItem
constructor(target: T, item: TrackItem) : super(Bundle().apply {
putSerializable(KEY_ITEM_TRACK, item.track)
}) {
targetController = target
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val statusList = item.service.getStatusList().orEmpty()
val statusString = statusList.mapNotNull { item.service.getStatus(it) }
val selectedIndex = statusList.indexOf(item.track?.status)
return MaterialDialog.Builder(activity!!)
.title(R.string.status)
.negativeText(android.R.string.cancel)
.items(statusString)
.itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ ->
(targetController as? Listener)?.setStatus(item, i)
true
})
.build()
}
interface Listener {
fun setStatus(item: TrackItem, selection: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track"
}
}

View file

@ -1,45 +1,45 @@
package eu.kanade.tachiyomi.ui.manga.track
import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.inflate
class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHolder>() {
var items = emptyList<TrackItem>()
set(value) {
if (field !== value) {
field = value
notifyDataSetChanged()
}
}
val rowClickListener: OnClickListener = controller
fun getItem(index: Int): TrackItem? {
return items.getOrNull(index)
}
override fun getItemCount(): Int {
return items.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder {
val view = parent.inflate(R.layout.track_item)
return TrackHolder(view, this)
}
override fun onBindViewHolder(holder: TrackHolder, position: Int) {
holder.bind(items[position])
}
interface OnClickListener {
fun onLogoClick(position: Int)
fun onTitleClick(position: Int)
fun onStatusClick(position: Int)
fun onChaptersClick(position: Int)
fun onScoreClick(position: Int)
}
}
package eu.kanade.tachiyomi.ui.manga.track
import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.inflate
class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHolder>() {
var items = emptyList<TrackItem>()
set(value) {
if (field !== value) {
field = value
notifyDataSetChanged()
}
}
val rowClickListener: OnClickListener = controller
fun getItem(index: Int): TrackItem? {
return items.getOrNull(index)
}
override fun getItemCount(): Int {
return items.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder {
val view = parent.inflate(R.layout.track_item)
return TrackHolder(view, this)
}
override fun onBindViewHolder(holder: TrackHolder, position: Int) {
holder.bind(items[position])
}
interface OnClickListener {
fun onLogoClick(position: Int)
fun onTitleClick(position: Int)
fun onStatusClick(position: Int)
fun onChaptersClick(position: Int)
fun onScoreClick(position: Int)
}
}

View file

@ -1,142 +1,142 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.content.Intent
import android.net.Uri
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.track_controller.*
import timber.log.Timber
class TrackController : NucleusController<TrackPresenter>(),
TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener,
SetTrackChaptersDialog.Listener,
SetTrackScoreDialog.Listener {
private var adapter: TrackAdapter? = null
init {
// There's no menu, but this avoids a bug when coming from the catalogue, where the menu
// disappears if the searchview is expanded
setHasOptionsMenu(true)
}
override fun createPresenter(): TrackPresenter {
return TrackPresenter((parentController as MangaController).manga!!)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.track_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
adapter = TrackAdapter(this)
with(view) {
track_recycler.layoutManager = LinearLayoutManager(context)
track_recycler.adapter = adapter
swipe_refresh.isEnabled = false
swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() }
}
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
fun onNextTrackings(trackings: List<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) {
Timber.e(error)
getSearchDialog()?.onSearchResultsError()
}
private fun getSearchDialog(): TrackSearchDialog? {
return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog
}
fun onRefreshDone() {
swipe_refresh?.isRefreshing = false
}
fun onRefreshError(error: Throwable) {
swipe_refresh?.isRefreshing = false
activity?.toast(error.message)
}
override fun onLogoClick(position: Int) {
val track = adapter?.getItem(position)?.track ?: return
if (track.tracking_url.isNullOrBlank()) {
activity?.toast(R.string.url_not_set)
} else {
activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url)))
}
}
override fun onTitleClick(position: Int) {
val item = adapter?.getItem(position) ?: return
TrackSearchDialog(this, item.service).showDialog(router, TAG_SEARCH_CONTROLLER)
}
override fun onStatusClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackStatusDialog(this, item).showDialog(router)
}
override fun onChaptersClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackChaptersDialog(this, item).showDialog(router)
}
override fun onScoreClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackScoreDialog(this, item).showDialog(router)
}
override fun setStatus(item: TrackItem, selection: Int) {
presenter.setStatus(item, selection)
swipe_refresh?.isRefreshing = true
}
override fun setScore(item: TrackItem, score: Int) {
presenter.setScore(item, score)
swipe_refresh?.isRefreshing = true
}
override fun setChaptersRead(item: TrackItem, chaptersRead: Int) {
presenter.setLastChapterRead(item, chaptersRead)
swipe_refresh?.isRefreshing = true
}
private companion object {
const val TAG_SEARCH_CONTROLLER = "track_search_controller"
}
}
package eu.kanade.tachiyomi.ui.manga.track
import android.content.Intent
import android.net.Uri
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.track_controller.*
import timber.log.Timber
class TrackController : NucleusController<TrackPresenter>(),
TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener,
SetTrackChaptersDialog.Listener,
SetTrackScoreDialog.Listener {
private var adapter: TrackAdapter? = null
init {
// There's no menu, but this avoids a bug when coming from the catalogue, where the menu
// disappears if the searchview is expanded
setHasOptionsMenu(true)
}
override fun createPresenter(): TrackPresenter {
return TrackPresenter((parentController as MangaController).manga!!)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.track_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
adapter = TrackAdapter(this)
with(view) {
track_recycler.layoutManager = LinearLayoutManager(context)
track_recycler.adapter = adapter
swipe_refresh.isEnabled = false
swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() }
}
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
fun onNextTrackings(trackings: List<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) {
Timber.e(error)
getSearchDialog()?.onSearchResultsError()
}
private fun getSearchDialog(): TrackSearchDialog? {
return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog
}
fun onRefreshDone() {
swipe_refresh?.isRefreshing = false
}
fun onRefreshError(error: Throwable) {
swipe_refresh?.isRefreshing = false
activity?.toast(error.message)
}
override fun onLogoClick(position: Int) {
val track = adapter?.getItem(position)?.track ?: return
if (track.tracking_url.isNullOrBlank()) {
activity?.toast(R.string.url_not_set)
} else {
activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url)))
}
}
override fun onTitleClick(position: Int) {
val item = adapter?.getItem(position) ?: return
TrackSearchDialog(this, item.service).showDialog(router, TAG_SEARCH_CONTROLLER)
}
override fun onStatusClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackStatusDialog(this, item).showDialog(router)
}
override fun onChaptersClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackChaptersDialog(this, item).showDialog(router)
}
override fun onScoreClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackScoreDialog(this, item).showDialog(router)
}
override fun setStatus(item: TrackItem, selection: Int) {
presenter.setStatus(item, selection)
swipe_refresh?.isRefreshing = true
}
override fun setScore(item: TrackItem, score: Int) {
presenter.setScore(item, score)
swipe_refresh?.isRefreshing = true
}
override fun setChaptersRead(item: TrackItem, chaptersRead: Int) {
presenter.setLastChapterRead(item, chaptersRead)
swipe_refresh?.isRefreshing = true
}
private companion object {
const val TAG_SEARCH_CONTROLLER = "track_search_controller"
}
}

View file

@ -1,42 +1,42 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.annotation.SuppressLint
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
import kotlinx.android.synthetic.main.track_item.*
class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
init {
val listener = adapter.rowClickListener
logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) }
title_container.setOnClickListener { listener.onTitleClick(adapterPosition) }
status_container.setOnClickListener { listener.onStatusClick(adapterPosition) }
chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) }
score_container.setOnClickListener { listener.onScoreClick(adapterPosition) }
}
@SuppressLint("SetTextI18n")
@Suppress("DEPRECATION")
fun bind(item: TrackItem) {
val track = item.track
track_logo.setImageResource(item.service.getLogo())
logo_container.setBackgroundColor(item.service.getLogoColor())
if (track != null) {
track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary)
track_title.setAllCaps(false)
track_title.text = track.title
track_chapters.text = "${track.last_chapter_read}/" +
if (track.total_chapters > 0) track.total_chapters else "-"
track_status.text = item.service.getStatus(track.status)
track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track)
} else {
track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Medium_Button)
track_title.setText(R.string.action_edit)
track_chapters.text = ""
track_score.text = ""
track_status.text = ""
}
}
}
package eu.kanade.tachiyomi.ui.manga.track
import android.annotation.SuppressLint
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
import kotlinx.android.synthetic.main.track_item.*
class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
init {
val listener = adapter.rowClickListener
logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) }
title_container.setOnClickListener { listener.onTitleClick(adapterPosition) }
status_container.setOnClickListener { listener.onStatusClick(adapterPosition) }
chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) }
score_container.setOnClickListener { listener.onScoreClick(adapterPosition) }
}
@SuppressLint("SetTextI18n")
@Suppress("DEPRECATION")
fun bind(item: TrackItem) {
val track = item.track
track_logo.setImageResource(item.service.getLogo())
logo_container.setBackgroundColor(item.service.getLogoColor())
if (track != null) {
track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary)
track_title.setAllCaps(false)
track_title.text = track.title
track_chapters.text = "${track.last_chapter_read}/" +
if (track.total_chapters > 0) track.total_chapters else "-"
track_status.text = item.service.getStatus(track.status)
track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track)
} else {
track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Medium_Button)
track_title.setText(R.string.action_edit)
track_chapters.text = ""
track_score.text = ""
track_status.text = ""
}
}
}

View file

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.manga.track
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
data class TrackItem(val track: Track?, val service: TrackService)
package eu.kanade.tachiyomi.ui.manga.track
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
data class TrackItem(val track: Track?, val service: TrackService)

View file

@ -1,130 +1,130 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.toast
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class TrackPresenter(
val manga: Manga,
preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val trackManager: TrackManager = Injekt.get()
) : BasePresenter<TrackController>() {
private val context = preferences.context
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
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
fetchTrackings()
}
fun fetchTrackings() {
trackSubscription?.let { remove(it) }
trackSubscription = db.getTracks(manga)
.asRxObservable()
.map { tracks ->
loggedServices.map { service ->
TrackItem(tracks.find { it.sync_id == service.id }, service)
}
}
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { trackList = it }
.subscribeLatestCache(TrackController::onNextTrackings)
}
fun refresh() {
refreshSubscription?.let { remove(it) }
refreshSubscription = Observable.from(trackList)
.filter { it.track != null }
.concatMap { item ->
item.service.refresh(item.track!!)
.flatMap { db.insertTrack(it).asRxObservable() }
.map { item }
.onErrorReturn { item }
}
.toList()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ -> view.onRefreshDone() },
TrackController::onRefreshError)
}
fun search(query: String, service: TrackService) {
searchSubscription?.let { remove(it) }
searchSubscription = service.search(query)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(TrackController::onSearchResults,
TrackController::onSearchResultsError)
}
fun registerTracking(item: Track?, service: TrackService) {
if (item != null) {
item.manga_id = manga.id!!
add(service.bind(item)
.flatMap { db.insertTrack(item).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ },
{ error -> context.toast(error.message) }))
} else {
db.deleteTrackForManga(manga, service).executeAsBlocking()
}
}
private fun updateRemote(track: Track, service: TrackService) {
service.update(track)
.flatMap { db.insertTrack(track).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ -> view.onRefreshDone() },
{ view, error ->
view.onRefreshError(error)
// Restart on error to set old values
fetchTrackings()
})
}
fun setStatus(item: TrackItem, index: Int) {
val track = item.track!!
track.status = item.service.getStatusList()[index]
updateRemote(track, item.service)
}
fun setScore(item: TrackItem, index: Int) {
val track = item.track!!
track.score = item.service.indexToScore(index)
updateRemote(track, item.service)
}
fun setLastChapterRead(item: TrackItem, chapterNumber: Int) {
val track = item.track!!
track.last_chapter_read = chapterNumber
updateRemote(track, item.service)
}
package eu.kanade.tachiyomi.ui.manga.track
import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.toast
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class TrackPresenter(
val manga: Manga,
preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val trackManager: TrackManager = Injekt.get()
) : BasePresenter<TrackController>() {
private val context = preferences.context
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
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
fetchTrackings()
}
fun fetchTrackings() {
trackSubscription?.let { remove(it) }
trackSubscription = db.getTracks(manga)
.asRxObservable()
.map { tracks ->
loggedServices.map { service ->
TrackItem(tracks.find { it.sync_id == service.id }, service)
}
}
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { trackList = it }
.subscribeLatestCache(TrackController::onNextTrackings)
}
fun refresh() {
refreshSubscription?.let { remove(it) }
refreshSubscription = Observable.from(trackList)
.filter { it.track != null }
.concatMap { item ->
item.service.refresh(item.track!!)
.flatMap { db.insertTrack(it).asRxObservable() }
.map { item }
.onErrorReturn { item }
}
.toList()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ -> view.onRefreshDone() },
TrackController::onRefreshError)
}
fun search(query: String, service: TrackService) {
searchSubscription?.let { remove(it) }
searchSubscription = service.search(query)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(TrackController::onSearchResults,
TrackController::onSearchResultsError)
}
fun registerTracking(item: Track?, service: TrackService) {
if (item != null) {
item.manga_id = manga.id!!
add(service.bind(item)
.flatMap { db.insertTrack(item).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ },
{ error -> context.toast(error.message) }))
} else {
db.deleteTrackForManga(manga, service).executeAsBlocking()
}
}
private fun updateRemote(track: Track, service: TrackService) {
service.update(track)
.flatMap { db.insertTrack(track).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ -> view.onRefreshDone() },
{ view, error ->
view.onRefreshError(error)
// Restart on error to set old values
fetchTrackings()
})
}
fun setStatus(item: TrackItem, index: Int) {
val track = item.track!!
track.status = item.service.getStatusList()[index]
updateRemote(track, item.service)
}
fun setScore(item: TrackItem, index: Int) {
val track = item.track!!
track.score = item.service.indexToScore(index)
updateRemote(track, item.service)
}
fun setLastChapterRead(item: TrackItem, chapterNumber: Int) {
val track = item.track!!
track.last_chapter_read = chapterNumber
updateRemote(track, item.service)
}
}

View file

@ -1,79 +1,79 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.track_search_item.view.*
import java.util.*
class TrackSearchAdapter(context: Context)
: ArrayAdapter<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
} else {
holder = v.tag as TrackSearchHolder
}
holder.onSetValues(track)
return v
}
fun setItems(syncs: List<TrackSearch>) {
setNotifyOnChange(false)
clear()
addAll(syncs)
notifyDataSetChanged()
}
class TrackSearchHolder(private val view: View) {
fun onSetValues(track: TrackSearch) {
view.track_search_title.text = track.title
view.track_search_summary.text = track.summary
GlideApp.with(view.context).clear(view.track_search_cover)
if (!track.cover_url.isNullOrEmpty()) {
GlideApp.with(view.context)
.load(track.cover_url)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(view.track_search_cover)
}
if (track.publishing_status.isNullOrBlank()) {
view.track_search_status.gone()
view.track_search_status_result.gone()
} else {
view.track_search_status_result.text = track.publishing_status.capitalize()
}
if (track.publishing_type.isNullOrBlank()) {
view.track_search_type.gone()
view.track_search_type_result.gone()
} else {
view.track_search_type_result.text = track.publishing_type.capitalize()
}
if (track.start_date.isNullOrBlank()) {
view.track_search_start.gone()
view.track_search_start_result.gone()
} else {
view.track_search_start_result.text = track.start_date
}
}
}
package eu.kanade.tachiyomi.ui.manga.track
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.track_search_item.view.*
import java.util.*
class TrackSearchAdapter(context: Context)
: ArrayAdapter<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
} else {
holder = v.tag as TrackSearchHolder
}
holder.onSetValues(track)
return v
}
fun setItems(syncs: List<TrackSearch>) {
setNotifyOnChange(false)
clear()
addAll(syncs)
notifyDataSetChanged()
}
class TrackSearchHolder(private val view: View) {
fun onSetValues(track: TrackSearch) {
view.track_search_title.text = track.title
view.track_search_summary.text = track.summary
GlideApp.with(view.context).clear(view.track_search_cover)
if (!track.cover_url.isNullOrEmpty()) {
GlideApp.with(view.context)
.load(track.cover_url)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(view.track_search_cover)
}
if (track.publishing_status.isNullOrBlank()) {
view.track_search_status.gone()
view.track_search_status_result.gone()
} else {
view.track_search_status_result.text = track.publishing_status.capitalize()
}
if (track.publishing_type.isNullOrBlank()) {
view.track_search_type.gone()
view.track_search_type_result.gone()
} else {
view.track_search_type_result.text = track.publishing_type.capitalize()
}
if (track.start_date.isNullOrBlank()) {
view.track_search_start.gone()
view.track_search_start_result.gone()
} else {
view.track_search_start_result.text = track.start_date
}
}
}
}

View file

@ -1,144 +1,144 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import com.jakewharton.rxbinding.widget.itemClicks
import com.jakewharton.rxbinding.widget.textChanges
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.util.plusAssign
import kotlinx.android.synthetic.main.track_search_dialog.view.*
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class TrackSearchDialog : DialogController {
private var dialogView: View? = null
private var adapter: TrackSearchAdapter? = null
private var selectedItem: Track? = null
private val service: TrackService
private var subscriptions = CompositeSubscription()
private var searchTextSubscription: Subscription? = null
private val trackController
get() = targetController as TrackController
constructor(target: TrackController, service: TrackService) : super(Bundle().apply {
putInt(KEY_SERVICE, service.id)
}) {
targetController = target
this.service = service
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
service = Injekt.get<TrackManager>().getService(bundle.getInt(KEY_SERVICE))!!
}
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(activity!!)
.customView(R.layout.track_search_dialog, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { _, _ -> onPositiveButtonClick() }
.build()
if (subscriptions.isUnsubscribed) {
subscriptions = CompositeSubscription()
}
dialogView = dialog.view
onViewCreated(dialog.view, savedState)
return dialog
}
fun onViewCreated(view: View, savedState: Bundle?) {
// Create adapter
val adapter = TrackSearchAdapter(view.context)
this.adapter = adapter
view.track_search_list.adapter = adapter
// Set listeners
selectedItem = null
subscriptions += view.track_search_list.itemClicks().subscribe { position ->
selectedItem = adapter.getItem(position)
}
// Do an initial search based on the manga's title
if (savedState == null) {
val title = trackController.presenter.manga.title
view.track_search.append(title)
search(title)
}
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
subscriptions.unsubscribe()
dialogView = null
adapter = null
}
override fun onAttach(view: View) {
super.onAttach(view)
searchTextSubscription = dialogView!!.track_search.textChanges()
.skip(1)
.debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread())
.map { it.toString() }
.filter(String::isNotBlank)
.subscribe { search(it) }
}
override fun onDetach(view: View) {
super.onDetach(view)
searchTextSubscription?.unsubscribe()
}
private fun search(query: String) {
val view = dialogView ?: return
view.progress.visibility = View.VISIBLE
view.track_search_list.visibility = View.INVISIBLE
trackController.presenter.search(query, service)
}
fun onSearchResults(results: List<TrackSearch>) {
selectedItem = null
val view = dialogView ?: return
view.progress.visibility = View.INVISIBLE
view.track_search_list.visibility = View.VISIBLE
adapter?.setItems(results)
}
fun onSearchResultsError() {
val view = dialogView ?: return
view.progress.visibility = View.VISIBLE
view.track_search_list.visibility = View.INVISIBLE
adapter?.setItems(emptyList())
}
private fun onPositiveButtonClick() {
trackController.presenter.registerTracking(selectedItem, service)
}
private companion object {
const val KEY_SERVICE = "service_id"
}
}
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import com.jakewharton.rxbinding.widget.itemClicks
import com.jakewharton.rxbinding.widget.textChanges
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.util.plusAssign
import kotlinx.android.synthetic.main.track_search_dialog.view.*
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class TrackSearchDialog : DialogController {
private var dialogView: View? = null
private var adapter: TrackSearchAdapter? = null
private var selectedItem: Track? = null
private val service: TrackService
private var subscriptions = CompositeSubscription()
private var searchTextSubscription: Subscription? = null
private val trackController
get() = targetController as TrackController
constructor(target: TrackController, service: TrackService) : super(Bundle().apply {
putInt(KEY_SERVICE, service.id)
}) {
targetController = target
this.service = service
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
service = Injekt.get<TrackManager>().getService(bundle.getInt(KEY_SERVICE))!!
}
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(activity!!)
.customView(R.layout.track_search_dialog, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { _, _ -> onPositiveButtonClick() }
.build()
if (subscriptions.isUnsubscribed) {
subscriptions = CompositeSubscription()
}
dialogView = dialog.view
onViewCreated(dialog.view, savedState)
return dialog
}
fun onViewCreated(view: View, savedState: Bundle?) {
// Create adapter
val adapter = TrackSearchAdapter(view.context)
this.adapter = adapter
view.track_search_list.adapter = adapter
// Set listeners
selectedItem = null
subscriptions += view.track_search_list.itemClicks().subscribe { position ->
selectedItem = adapter.getItem(position)
}
// Do an initial search based on the manga's title
if (savedState == null) {
val title = trackController.presenter.manga.title
view.track_search.append(title)
search(title)
}
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
subscriptions.unsubscribe()
dialogView = null
adapter = null
}
override fun onAttach(view: View) {
super.onAttach(view)
searchTextSubscription = dialogView!!.track_search.textChanges()
.skip(1)
.debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread())
.map { it.toString() }
.filter(String::isNotBlank)
.subscribe { search(it) }
}
override fun onDetach(view: View) {
super.onDetach(view)
searchTextSubscription?.unsubscribe()
}
private fun search(query: String) {
val view = dialogView ?: return
view.progress.visibility = View.VISIBLE
view.track_search_list.visibility = View.INVISIBLE
trackController.presenter.search(query, service)
}
fun onSearchResults(results: List<TrackSearch>) {
selectedItem = null
val view = dialogView ?: return
view.progress.visibility = View.INVISIBLE
view.track_search_list.visibility = View.VISIBLE
adapter?.setItems(results)
}
fun onSearchResultsError() {
val view = dialogView ?: return
view.progress.visibility = View.VISIBLE
view.track_search_list.visibility = View.INVISIBLE
adapter?.setItems(emptyList())
}
private fun onPositiveButtonClick() {
trackController.presenter.registerTracking(selectedItem, service)
}
private companion object {
const val KEY_SERVICE = "service_id"
}
}

View file

@ -1,333 +1,333 @@
package eu.kanade.tachiyomi.ui.recent_updates
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.*
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.recent_chapters_controller.*
import timber.log.Timber
/**
* Fragment that shows recent chapters.
* Uses [R.layout.recent_chapters_controller].
* UI related actions should be called from here.
*/
class RecentChaptersController : NucleusController<RecentChaptersPresenter>(),
NoToolbarElevationController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.OnUpdateListener,
ConfirmDeleteChaptersDialog.Listener,
RecentChaptersAdapter.OnCoverClickListener {
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
/**
* Adapter containing the recent chapters.
*/
var adapter: RecentChaptersAdapter? = null
private set
override fun getTitle(): String? {
return resources?.getString(R.string.label_recent_updates)
}
override fun createPresenter(): RecentChaptersPresenter {
return RecentChaptersPresenter()
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.recent_chapters_controller, container, false)
}
/**
* Called when view is created
* @param view created view
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Init RecyclerView and adapter
val layoutManager = LinearLayoutManager(view.context)
recycler.layoutManager = layoutManager
recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
adapter = RecentChaptersAdapter(this@RecentChaptersController)
recycler.adapter = adapter
recycler.scrollStateChanges().subscribeUntilDestroy {
// Disable swipe refresh when view is not at the top
val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition()
swipe_refresh.isEnabled = firstPos <= 0
}
swipe_refresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt())
swipe_refresh.refreshes().subscribeUntilDestroy {
if (!LibraryUpdateService.isRunning(view.context)) {
LibraryUpdateService.start(view.context)
view.context.toast(R.string.action_update_library)
}
// It can be a very long operation, so we disable swipe refresh and show a toast.
swipe_refresh.isRefreshing = false
}
}
override fun onDestroyView(view: View) {
adapter = null
actionMode = null
super.onDestroyView(view)
}
/**
* Returns selected chapters
* @return list of selected chapters
*/
fun getSelectedChapters(): List<RecentChapterItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem }
}
/**
* Called when item in list is clicked
* @param position position of clicked item
*/
override fun onItemClick(view: View, position: Int): Boolean {
val adapter = adapter ?: return false
// Get item from position
val item = adapter.getItem(position) as? RecentChapterItem ?: return false
if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position)
return true
} else {
openChapter(item)
return false
}
}
/**
* Called when item in list is long clicked
* @param position position of clicked item
*/
override fun onItemLongClick(position: Int) {
if (actionMode == null)
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
toggleSelection(position)
}
/**
* Called to toggle selection
* @param position position of selected item
*/
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
adapter.toggleSelection(position)
actionMode?.invalidate()
}
/**
* Open chapter in reader
* @param chapter selected chapter
*/
private fun openChapter(item: RecentChapterItem) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter)
startActivity(intent)
}
/**
* Download selected items
* @param chapters list of selected [RecentChapter]s
*/
fun downloadChapters(chapters: List<RecentChapterItem>) {
destroyActionModeIfNeeded()
presenter.downloadChapters(chapters)
}
/**
* Populate adapter with chapters
* @param chapters list of [Any]
*/
fun onNextRecentChapters(chapters: List<IFlexible<*>>) {
destroyActionModeIfNeeded()
adapter?.updateDataSet(chapters)
}
override fun onUpdateEmptyView(size: Int) {
if (size > 0) {
empty_view?.hide()
} else {
empty_view?.show(R.drawable.ic_update_black_128dp, R.string.information_no_recent)
}
}
/**
* Update download status of chapter
* @param download [Download] object containing download progress.
*/
fun onChapterStatusChange(download: Download) {
getHolder(download)?.notifyStatus(download.status)
}
/**
* Returns holder belonging to chapter
* @param download [Download] object containing download progress.
*/
private fun getHolder(download: Download): RecentChapterHolder? {
return recycler?.findViewHolderForItemId(download.chapter.id!!) as? RecentChapterHolder
}
/**
* Mark chapter as read
* @param chapters list of chapters
*/
fun markAsRead(chapters: List<RecentChapterItem>) {
presenter.markChapterRead(chapters, true)
if (presenter.preferences.removeAfterMarkedAsRead()) {
deleteChapters(chapters)
}
}
override fun deleteChapters(chaptersToDelete: List<RecentChapterItem>) {
destroyActionModeIfNeeded()
DeletingChaptersDialog().showDialog(router)
presenter.deleteChapters(chaptersToDelete)
}
/**
* Destory [ActionMode] if it's shown
*/
fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
/**
* Mark chapter as unread
* @param chapters list of selected [RecentChapter]
*/
fun markAsUnread(chapters: List<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
* @param chapter selected chapter with manga
*/
fun deleteChapter(chapter: RecentChapterItem) {
DeletingChaptersDialog().showDialog(router)
presenter.deleteChapters(listOf(chapter))
}
override fun onCoverClick(position: Int) {
val chapterClicked = adapter?.getItem(position) as? RecentChapterItem ?: return
openManga(chapterClicked)
}
fun openManga(chapter: RecentChapterItem) {
router.pushController(MangaController(chapter.manga).withFadeTransaction())
}
/**
* Called when chapters are deleted
*/
fun onChaptersDeleted() {
dismissDeletingDialog()
adapter?.notifyDataSetChanged()
}
/**
* Called when error while deleting
* @param error error message
*/
fun onChaptersDeletedError(error: Throwable) {
dismissDeletingDialog()
Timber.e(error)
}
/**
* Called to dismiss deleting dialog
*/
fun dismissDeletingDialog() {
router.popControllerWithTag(DeletingChaptersDialog.TAG)
}
/**
* Called when ActionMode created.
* @param mode the ActionMode object
* @param menu menu object of ActionMode
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu)
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = resources?.getString(R.string.label_selected, count)
}
return false
}
/**
* Called when ActionMode item clicked
* @param mode the ActionMode object
* @param item item from ActionMode.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters())
.showDialog(router)
else -> return false
}
return true
}
/**
* Called when ActionMode destroyed
* @param mode the ActionMode object
*/
override fun onDestroyActionMode(mode: ActionMode?) {
adapter?.mode = SelectableAdapter.Mode.IDLE
adapter?.clearSelection()
actionMode = null
}
}
package eu.kanade.tachiyomi.ui.recent_updates
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.*
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.recent_chapters_controller.*
import timber.log.Timber
/**
* Fragment that shows recent chapters.
* Uses [R.layout.recent_chapters_controller].
* UI related actions should be called from here.
*/
class RecentChaptersController : NucleusController<RecentChaptersPresenter>(),
NoToolbarElevationController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.OnUpdateListener,
ConfirmDeleteChaptersDialog.Listener,
RecentChaptersAdapter.OnCoverClickListener {
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
/**
* Adapter containing the recent chapters.
*/
var adapter: RecentChaptersAdapter? = null
private set
override fun getTitle(): String? {
return resources?.getString(R.string.label_recent_updates)
}
override fun createPresenter(): RecentChaptersPresenter {
return RecentChaptersPresenter()
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.recent_chapters_controller, container, false)
}
/**
* Called when view is created
* @param view created view
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Init RecyclerView and adapter
val layoutManager = LinearLayoutManager(view.context)
recycler.layoutManager = layoutManager
recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
adapter = RecentChaptersAdapter(this@RecentChaptersController)
recycler.adapter = adapter
recycler.scrollStateChanges().subscribeUntilDestroy {
// Disable swipe refresh when view is not at the top
val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition()
swipe_refresh.isEnabled = firstPos <= 0
}
swipe_refresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt())
swipe_refresh.refreshes().subscribeUntilDestroy {
if (!LibraryUpdateService.isRunning(view.context)) {
LibraryUpdateService.start(view.context)
view.context.toast(R.string.action_update_library)
}
// It can be a very long operation, so we disable swipe refresh and show a toast.
swipe_refresh.isRefreshing = false
}
}
override fun onDestroyView(view: View) {
adapter = null
actionMode = null
super.onDestroyView(view)
}
/**
* Returns selected chapters
* @return list of selected chapters
*/
fun getSelectedChapters(): List<RecentChapterItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem }
}
/**
* Called when item in list is clicked
* @param position position of clicked item
*/
override fun onItemClick(view: View, position: Int): Boolean {
val adapter = adapter ?: return false
// Get item from position
val item = adapter.getItem(position) as? RecentChapterItem ?: return false
if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position)
return true
} else {
openChapter(item)
return false
}
}
/**
* Called when item in list is long clicked
* @param position position of clicked item
*/
override fun onItemLongClick(position: Int) {
if (actionMode == null)
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
toggleSelection(position)
}
/**
* Called to toggle selection
* @param position position of selected item
*/
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
adapter.toggleSelection(position)
actionMode?.invalidate()
}
/**
* Open chapter in reader
* @param chapter selected chapter
*/
private fun openChapter(item: RecentChapterItem) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter)
startActivity(intent)
}
/**
* Download selected items
* @param chapters list of selected [RecentChapter]s
*/
fun downloadChapters(chapters: List<RecentChapterItem>) {
destroyActionModeIfNeeded()
presenter.downloadChapters(chapters)
}
/**
* Populate adapter with chapters
* @param chapters list of [Any]
*/
fun onNextRecentChapters(chapters: List<IFlexible<*>>) {
destroyActionModeIfNeeded()
adapter?.updateDataSet(chapters)
}
override fun onUpdateEmptyView(size: Int) {
if (size > 0) {
empty_view?.hide()
} else {
empty_view?.show(R.drawable.ic_update_black_128dp, R.string.information_no_recent)
}
}
/**
* Update download status of chapter
* @param download [Download] object containing download progress.
*/
fun onChapterStatusChange(download: Download) {
getHolder(download)?.notifyStatus(download.status)
}
/**
* Returns holder belonging to chapter
* @param download [Download] object containing download progress.
*/
private fun getHolder(download: Download): RecentChapterHolder? {
return recycler?.findViewHolderForItemId(download.chapter.id!!) as? RecentChapterHolder
}
/**
* Mark chapter as read
* @param chapters list of chapters
*/
fun markAsRead(chapters: List<RecentChapterItem>) {
presenter.markChapterRead(chapters, true)
if (presenter.preferences.removeAfterMarkedAsRead()) {
deleteChapters(chapters)
}
}
override fun deleteChapters(chaptersToDelete: List<RecentChapterItem>) {
destroyActionModeIfNeeded()
DeletingChaptersDialog().showDialog(router)
presenter.deleteChapters(chaptersToDelete)
}
/**
* Destory [ActionMode] if it's shown
*/
fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
/**
* Mark chapter as unread
* @param chapters list of selected [RecentChapter]
*/
fun markAsUnread(chapters: List<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
* @param chapter selected chapter with manga
*/
fun deleteChapter(chapter: RecentChapterItem) {
DeletingChaptersDialog().showDialog(router)
presenter.deleteChapters(listOf(chapter))
}
override fun onCoverClick(position: Int) {
val chapterClicked = adapter?.getItem(position) as? RecentChapterItem ?: return
openManga(chapterClicked)
}
fun openManga(chapter: RecentChapterItem) {
router.pushController(MangaController(chapter.manga).withFadeTransaction())
}
/**
* Called when chapters are deleted
*/
fun onChaptersDeleted() {
dismissDeletingDialog()
adapter?.notifyDataSetChanged()
}
/**
* Called when error while deleting
* @param error error message
*/
fun onChaptersDeletedError(error: Throwable) {
dismissDeletingDialog()
Timber.e(error)
}
/**
* Called to dismiss deleting dialog
*/
fun dismissDeletingDialog() {
router.popControllerWithTag(DeletingChaptersDialog.TAG)
}
/**
* Called when ActionMode created.
* @param mode the ActionMode object
* @param menu menu object of ActionMode
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu)
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = resources?.getString(R.string.label_selected, count)
}
return false
}
/**
* Called when ActionMode item clicked
* @param mode the ActionMode object
* @param item item from ActionMode.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters())
.showDialog(router)
else -> return false
}
return true
}
/**
* Called when ActionMode destroyed
* @param mode the ActionMode object
*/
override fun onDestroyActionMode(mode: ActionMode?) {
adapter?.mode = SelectableAdapter.Mode.IDLE
adapter?.clearSelection()
actionMode = null
}
}

View file

@ -1,87 +1,87 @@
package eu.kanade.tachiyomi.ui.setting
import android.content.Context
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceController
import androidx.preference.PreferenceScreen
import android.util.TypedValue
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import rx.Observable
import rx.Subscription
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
abstract class SettingsController : PreferenceController() {
val preferences: PreferencesHelper = Injekt.get()
var untilDestroySubscriptions = CompositeSubscription()
private set
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View {
if (untilDestroySubscriptions.isUnsubscribed) {
untilDestroySubscriptions = CompositeSubscription()
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
untilDestroySubscriptions.unsubscribe()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val screen = preferenceManager.createPreferenceScreen(getThemedContext())
preferenceScreen = screen
setupPreferenceScreen(screen)
}
abstract fun setupPreferenceScreen(screen: PreferenceScreen): Any?
private fun getThemedContext(): Context {
val tv = TypedValue()
activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true)
return ContextThemeWrapper(activity, tv.resourceId)
}
open fun getTitle(): String? {
return preferenceScreen?.title?.toString()
}
fun setTitle() {
var parentController = parentController
while (parentController != null) {
if (parentController is BaseController && parentController.getTitle() != null) {
return
}
parentController = parentController.parentController
}
(activity as? AppCompatActivity)?.supportActionBar?.title = getTitle()
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
if (type.isEnter) {
setTitle()
}
super.onChangeStarted(handler, type)
}
fun <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.content.Context
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceController
import androidx.preference.PreferenceScreen
import android.util.TypedValue
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import rx.Observable
import rx.Subscription
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
abstract class SettingsController : PreferenceController() {
val preferences: PreferencesHelper = Injekt.get()
var untilDestroySubscriptions = CompositeSubscription()
private set
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View {
if (untilDestroySubscriptions.isUnsubscribed) {
untilDestroySubscriptions = CompositeSubscription()
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
untilDestroySubscriptions.unsubscribe()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val screen = preferenceManager.createPreferenceScreen(getThemedContext())
preferenceScreen = screen
setupPreferenceScreen(screen)
}
abstract fun setupPreferenceScreen(screen: PreferenceScreen): Any?
private fun getThemedContext(): Context {
val tv = TypedValue()
activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true)
return ContextThemeWrapper(activity, tv.resourceId)
}
open fun getTitle(): String? {
return preferenceScreen?.title?.toString()
}
fun setTitle() {
var parentController = parentController
while (parentController != null) {
if (parentController is BaseController && parentController.getTitle() != null) {
return
}
parentController = parentController.parentController
}
(activity as? AppCompatActivity)?.supportActionBar?.title = getTitle()
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
if (type.isEnter) {
setTitle()
}
super.onChangeStarted(handler, type)
}
fun <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) }
}
}

View file

@ -1,61 +1,61 @@
package eu.kanade.tachiyomi.ui.setting
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.util.getResourceColor
class SettingsMainController : SettingsController() {
override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) {
titleRes = R.string.label_settings
val tintColor = context.getResourceColor(R.attr.colorAccent)
preference {
iconRes = R.drawable.ic_tune_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_general
onClick { navigateTo(SettingsGeneralController()) }
}
preference {
iconRes = R.drawable.ic_chrome_reader_mode_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_reader
onClick { navigateTo(SettingsReaderController()) }
}
preference {
iconRes = R.drawable.ic_file_download_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_downloads
onClick { navigateTo(SettingsDownloadController()) }
}
preference {
iconRes = R.drawable.ic_sync_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_tracking
onClick { navigateTo(SettingsTrackingController()) }
}
preference {
iconRes = R.drawable.ic_backup_black_24dp
iconTint = tintColor
titleRes = R.string.backup
onClick { navigateTo(SettingsBackupController()) }
}
preference {
iconRes = R.drawable.ic_code_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_advanced
onClick { navigateTo(SettingsAdvancedController()) }
}
preference {
iconRes = R.drawable.ic_help_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_about
onClick { navigateTo(SettingsAboutController()) }
}
}
private fun navigateTo(controller: SettingsController) {
router.pushController(controller.withFadeTransaction())
}
}
package eu.kanade.tachiyomi.ui.setting
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.util.getResourceColor
class SettingsMainController : SettingsController() {
override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) {
titleRes = R.string.label_settings
val tintColor = context.getResourceColor(R.attr.colorAccent)
preference {
iconRes = R.drawable.ic_tune_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_general
onClick { navigateTo(SettingsGeneralController()) }
}
preference {
iconRes = R.drawable.ic_chrome_reader_mode_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_reader
onClick { navigateTo(SettingsReaderController()) }
}
preference {
iconRes = R.drawable.ic_file_download_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_downloads
onClick { navigateTo(SettingsDownloadController()) }
}
preference {
iconRes = R.drawable.ic_sync_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_tracking
onClick { navigateTo(SettingsTrackingController()) }
}
preference {
iconRes = R.drawable.ic_backup_black_24dp
iconTint = tintColor
titleRes = R.string.backup
onClick { navigateTo(SettingsBackupController()) }
}
preference {
iconRes = R.drawable.ic_code_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_advanced
onClick { navigateTo(SettingsAdvancedController()) }
}
preference {
iconRes = R.drawable.ic_help_black_24dp
iconTint = tintColor
titleRes = R.string.pref_category_about
onClick { navigateTo(SettingsAboutController()) }
}
}
private fun navigateTo(controller: SettingsController) {
router.pushController(controller.withFadeTransaction())
}
}

View file

@ -1,239 +1,239 @@
package eu.kanade.tachiyomi.widget
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.annotation.CallSuper
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceColor
/**
* An alternative implementation of [android.support.design.widget.NavigationView], without menu
* inflation and allowing customizable items (multiple selections, custom views, etc).
*/
open class ExtendedNavigationView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0)
: SimpleNavigationView(context, attrs, defStyleAttr) {
/**
* Every item of the nav view. Generic items must belong to this list, custom items could be
* implemented by an abstract class. If more customization is needed in the future, this can be
* changed to an interface instead of sealed class.
*/
sealed class Item {
/**
* A view separator.
*/
class Separator(val paddingTop: Int = 0, val paddingBottom: Int = 0) : Item()
/**
* A header with a title.
*/
class Header(val resTitle: Int) : Item()
/**
* A checkbox.
*/
open class Checkbox(val resTitle: Int, var checked: Boolean = false) : Item()
/**
* A checkbox belonging to a group. The group must handle selections and restrictions.
*/
class CheckboxGroup(resTitle: Int, override val group: Group, checked: Boolean = false)
: Checkbox(resTitle, checked), GroupedItem
/**
* A radio belonging to a group (a sole radio makes no sense). The group must handle
* selections and restrictions.
*/
class Radio(val resTitle: Int, override val group: Group, var checked: Boolean = false)
: Item(), GroupedItem
/**
* An item with which needs more than two states (selected/deselected).
*/
abstract class MultiState(val resTitle: Int, var state: Int = 0) : Item() {
/**
* Returns the drawable associated to every possible each state.
*/
abstract fun getStateDrawable(context: Context): Drawable?
/**
* Creates a vector tinted with the accent color.
*
* @param context any context.
* @param resId the vector resource to load and tint
*/
fun tintVector(context: Context, resId: Int): Drawable {
return VectorDrawableCompat.create(context.resources, resId, context.theme)!!.apply {
setTint(context.getResourceColor(R.attr.colorAccent))
}
}
}
/**
* An item with which needs more than two states (selected/deselected) belonging to a group.
* The group must handle selections and restrictions.
*/
abstract class MultiStateGroup(resTitle: Int, override val group: Group, state: Int = 0)
: MultiState(resTitle, state), GroupedItem
/**
* A multistate item for sorting lists (unselected, ascending, descending).
*/
class MultiSort(resId: Int, group: Group) : MultiStateGroup(resId, group) {
companion object {
const val SORT_NONE = 0
const val SORT_ASC = 1
const val SORT_DESC = 2
}
override fun getStateDrawable(context: Context): Drawable? {
return when (state) {
SORT_ASC -> tintVector(context, R.drawable.ic_arrow_up_white_32dp)
SORT_DESC -> tintVector(context, R.drawable.ic_arrow_down_white_32dp)
SORT_NONE -> ContextCompat.getDrawable(context, R.drawable.empty_drawable_32dp)
else -> null
}
}
}
}
/**
* Interface for an item belonging to a group.
*/
interface GroupedItem {
val group: Group
}
/**
* A group containing a list of items.
*/
interface Group {
/**
* An optional header for the group, typically a [Item.Header].
*/
val header: Item?
/**
* An optional footer for the group, typically a [Item.Separator].
*/
val footer: Item?
/**
* The items of the group, excluding header and footer.
*/
val items: List<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 getItemCount(): Int {
return items.size
}
@CallSuper
override fun getItemViewType(position: Int): Int {
val item = items[position]
return when (item) {
is Item.Header -> VIEW_TYPE_HEADER
is Item.Separator -> VIEW_TYPE_SEPARATOR
is Item.Radio -> VIEW_TYPE_RADIO
is Item.Checkbox -> VIEW_TYPE_CHECKBOX
is Item.MultiState -> VIEW_TYPE_MULTISTATE
}
}
@CallSuper
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return when (viewType) {
VIEW_TYPE_HEADER -> HeaderHolder(parent)
VIEW_TYPE_SEPARATOR -> SeparatorHolder(parent)
VIEW_TYPE_RADIO -> RadioHolder(parent, onClick)
VIEW_TYPE_CHECKBOX -> CheckboxHolder(parent, onClick)
VIEW_TYPE_MULTISTATE -> MultiStateHolder(parent, onClick)
else -> throw Exception("Unknown view type")
}
}
@CallSuper
override fun onBindViewHolder(holder: Holder, position: Int) {
when (holder) {
is HeaderHolder -> {
val item = items[position] as Item.Header
holder.title.setText(item.resTitle)
}
is SeparatorHolder -> {
val view = holder.itemView
val item = items[position] as Item.Separator
view.setPadding(0, item.paddingTop, 0, item.paddingBottom)
}
is RadioHolder -> {
val item = items[position] as Item.Radio
holder.radio.setText(item.resTitle)
holder.radio.isChecked = item.checked
}
is CheckboxHolder -> {
val item = items[position] as Item.CheckboxGroup
holder.check.setText(item.resTitle)
holder.check.isChecked = item.checked
}
is MultiStateHolder -> {
val item = items[position] as Item.MultiStateGroup
val drawable = item.getStateDrawable(context)
holder.text.setText(item.resTitle)
holder.text.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null)
}
}
}
abstract fun onItemClicked(item: Item)
}
}
package eu.kanade.tachiyomi.widget
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.annotation.CallSuper
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceColor
/**
* An alternative implementation of [android.support.design.widget.NavigationView], without menu
* inflation and allowing customizable items (multiple selections, custom views, etc).
*/
open class ExtendedNavigationView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0)
: SimpleNavigationView(context, attrs, defStyleAttr) {
/**
* Every item of the nav view. Generic items must belong to this list, custom items could be
* implemented by an abstract class. If more customization is needed in the future, this can be
* changed to an interface instead of sealed class.
*/
sealed class Item {
/**
* A view separator.
*/
class Separator(val paddingTop: Int = 0, val paddingBottom: Int = 0) : Item()
/**
* A header with a title.
*/
class Header(val resTitle: Int) : Item()
/**
* A checkbox.
*/
open class Checkbox(val resTitle: Int, var checked: Boolean = false) : Item()
/**
* A checkbox belonging to a group. The group must handle selections and restrictions.
*/
class CheckboxGroup(resTitle: Int, override val group: Group, checked: Boolean = false)
: Checkbox(resTitle, checked), GroupedItem
/**
* A radio belonging to a group (a sole radio makes no sense). The group must handle
* selections and restrictions.
*/
class Radio(val resTitle: Int, override val group: Group, var checked: Boolean = false)
: Item(), GroupedItem
/**
* An item with which needs more than two states (selected/deselected).
*/
abstract class MultiState(val resTitle: Int, var state: Int = 0) : Item() {
/**
* Returns the drawable associated to every possible each state.
*/
abstract fun getStateDrawable(context: Context): Drawable?
/**
* Creates a vector tinted with the accent color.
*
* @param context any context.
* @param resId the vector resource to load and tint
*/
fun tintVector(context: Context, resId: Int): Drawable {
return VectorDrawableCompat.create(context.resources, resId, context.theme)!!.apply {
setTint(context.getResourceColor(R.attr.colorAccent))
}
}
}
/**
* An item with which needs more than two states (selected/deselected) belonging to a group.
* The group must handle selections and restrictions.
*/
abstract class MultiStateGroup(resTitle: Int, override val group: Group, state: Int = 0)
: MultiState(resTitle, state), GroupedItem
/**
* A multistate item for sorting lists (unselected, ascending, descending).
*/
class MultiSort(resId: Int, group: Group) : MultiStateGroup(resId, group) {
companion object {
const val SORT_NONE = 0
const val SORT_ASC = 1
const val SORT_DESC = 2
}
override fun getStateDrawable(context: Context): Drawable? {
return when (state) {
SORT_ASC -> tintVector(context, R.drawable.ic_arrow_up_white_32dp)
SORT_DESC -> tintVector(context, R.drawable.ic_arrow_down_white_32dp)
SORT_NONE -> ContextCompat.getDrawable(context, R.drawable.empty_drawable_32dp)
else -> null
}
}
}
}
/**
* Interface for an item belonging to a group.
*/
interface GroupedItem {
val group: Group
}
/**
* A group containing a list of items.
*/
interface Group {
/**
* An optional header for the group, typically a [Item.Header].
*/
val header: Item?
/**
* An optional footer for the group, typically a [Item.Separator].
*/
val footer: Item?
/**
* The items of the group, excluding header and footer.
*/
val items: List<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 getItemCount(): Int {
return items.size
}
@CallSuper
override fun getItemViewType(position: Int): Int {
val item = items[position]
return when (item) {
is Item.Header -> VIEW_TYPE_HEADER
is Item.Separator -> VIEW_TYPE_SEPARATOR
is Item.Radio -> VIEW_TYPE_RADIO
is Item.Checkbox -> VIEW_TYPE_CHECKBOX
is Item.MultiState -> VIEW_TYPE_MULTISTATE
}
}
@CallSuper
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return when (viewType) {
VIEW_TYPE_HEADER -> HeaderHolder(parent)
VIEW_TYPE_SEPARATOR -> SeparatorHolder(parent)
VIEW_TYPE_RADIO -> RadioHolder(parent, onClick)
VIEW_TYPE_CHECKBOX -> CheckboxHolder(parent, onClick)
VIEW_TYPE_MULTISTATE -> MultiStateHolder(parent, onClick)
else -> throw Exception("Unknown view type")
}
}
@CallSuper
override fun onBindViewHolder(holder: Holder, position: Int) {
when (holder) {
is HeaderHolder -> {
val item = items[position] as Item.Header
holder.title.setText(item.resTitle)
}
is SeparatorHolder -> {
val view = holder.itemView
val item = items[position] as Item.Separator
view.setPadding(0, item.paddingTop, 0, item.paddingBottom)
}
is RadioHolder -> {
val item = items[position] as Item.Radio
holder.radio.setText(item.resTitle)
holder.radio.isChecked = item.checked
}
is CheckboxHolder -> {
val item = items[position] as Item.CheckboxGroup
holder.check.setText(item.resTitle)
holder.check.isChecked = item.checked
}
is MultiStateHolder -> {
val item = items[position] as Item.MultiStateGroup
val drawable = item.getStateDrawable(context)
holder.text.setText(item.resTitle)
holder.text.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null)
}
}
}
abstract fun onItemClicked(item: Item)
}
}

View file

@ -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>

View file

@ -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>

View file

@ -1,9 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
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"/>
</vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
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"/>
</vector>

View file

@ -1,23 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
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
xmlns:android="http://schemas.android.com/apk/res/android"
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>

View file

@ -1,30 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?attr/listPreferredItemHeightSmall"
android:background="?colorPrimary"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
android:paddingRight="?attr/listPreferredItemPaddingRight"
android:elevation="2dp">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
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"/>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?attr/listPreferredItemHeightSmall"
android:background="?colorPrimary"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
android:paddingRight="?attr/listPreferredItemPaddingRight"
android:elevation="2dp">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
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>

View file

@ -1,62 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:baselineAligned="false"
android:clipToPadding="false"
android:gravity="center_vertical"
android:minHeight="42dp"
android:paddingLeft="?listPreferredItemPaddingLeft"
android:paddingRight="?listPreferredItemPaddingRight"
tools:ignore="RtlHardcoded">
<LinearLayout
android:id="@android:id/widget_frame"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:clipToPadding="false"
android:gravity="start|center_vertical"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"/>
<TextView
android:id="@android:id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="marquee"
android:singleLine="true"
android:textAppearance="?textAppearanceListItem"/>
<!-- Hidden view -->
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone" />
<LinearLayout
android:id="@+id/login_frame"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="-16dp"
android:layout_marginRight="-16dp"
android:clipToPadding="false"
android:gravity="end|center_vertical"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:visibility="gone">
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/login" />
</LinearLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:baselineAligned="false"
android:clipToPadding="false"
android:gravity="center_vertical"
android:minHeight="42dp"
android:paddingLeft="?listPreferredItemPaddingLeft"
android:paddingRight="?listPreferredItemPaddingRight"
tools:ignore="RtlHardcoded">
<LinearLayout
android:id="@android:id/widget_frame"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:clipToPadding="false"
android:gravity="start|center_vertical"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"/>
<TextView
android:id="@android:id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="marquee"
android:singleLine="true"
android:textAppearance="?textAppearanceListItem"/>
<!-- Hidden view -->
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone" />
<LinearLayout
android:id="@+id/login_frame"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="-16dp"
android:layout_marginRight="-16dp"
android:clipToPadding="false"
android:gravity="end|center_vertical"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:visibility="gone">
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/login" />
</LinearLayout>
</LinearLayout>

View file

@ -1,191 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/track"
style="@style/Theme.Widget.CardView.Item"
android:padding="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
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">
<ImageView
android:id="@+id/track_logo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:src="@drawable/mal" />
</FrameLayout>
<LinearLayout
android:id="@+id/title_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"
android:clickable="true"
android:padding="16dp"
app:layout_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
style="@style/TextAppearance.Regular.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title" />
<TextView
android:id="@+id/track_title"
style="@style/TextAppearance.Medium.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:ellipsize="middle"
android:gravity="end"
android:maxLines="1"
android:text="@string/action_edit" />
</LinearLayout>
<View
android:id="@+id/divider1"
android:layout_width="0dp"
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_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/title_container" />
<LinearLayout
android:id="@+id/status_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"
android:clickable="true"
android:padding="16dp"
app:layout_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider1">
<TextView
style="@style/TextAppearance.Regular.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status" />
<TextView
android:id="@+id/track_status"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:gravity="end"
tools:text="Reading" />
</LinearLayout>
<View
android:id="@+id/divider2"
android:layout_width="0dp"
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_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/status_container" />
<LinearLayout
android:id="@+id/chapters_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"
android:clickable="true"
android:padding="16dp"
app:layout_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider2">
<TextView
style="@style/TextAppearance.Regular.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/chapters" />
<TextView
android:id="@+id/track_chapters"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:gravity="end"
tools:text="12/24" />
</LinearLayout>
<View
android:id="@+id/divider3"
android:layout_width="0dp"
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_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chapters_container" />
<LinearLayout
android:id="@+id/score_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"
android:clickable="true"
android:padding="16dp"
app:layout_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider3">
<TextView
style="@style/TextAppearance.Regular.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/score" />
<TextView
android:id="@+id/track_score"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:gravity="end"
tools:text="10" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/track"
style="@style/Theme.Widget.CardView.Item"
android:padding="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
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">
<ImageView
android:id="@+id/track_logo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:src="@drawable/mal" />
</FrameLayout>
<LinearLayout
android:id="@+id/title_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"
android:clickable="true"
android:padding="16dp"
app:layout_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
style="@style/TextAppearance.Regular.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title" />
<TextView
android:id="@+id/track_title"
style="@style/TextAppearance.Medium.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:ellipsize="middle"
android:gravity="end"
android:maxLines="1"
android:text="@string/action_edit" />
</LinearLayout>
<View
android:id="@+id/divider1"
android:layout_width="0dp"
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_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/title_container" />
<LinearLayout
android:id="@+id/status_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"
android:clickable="true"
android:padding="16dp"
app:layout_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider1">
<TextView
style="@style/TextAppearance.Regular.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status" />
<TextView
android:id="@+id/track_status"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:gravity="end"
tools:text="Reading" />
</LinearLayout>
<View
android:id="@+id/divider2"
android:layout_width="0dp"
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_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/status_container" />
<LinearLayout
android:id="@+id/chapters_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"
android:clickable="true"
android:padding="16dp"
app:layout_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider2">
<TextView
style="@style/TextAppearance.Regular.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/chapters" />
<TextView
android:id="@+id/track_chapters"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:gravity="end"
tools:text="12/24" />
</LinearLayout>
<View
android:id="@+id/divider3"
android:layout_width="0dp"
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_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chapters_container" />
<LinearLayout
android:id="@+id/score_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"
android:clickable="true"
android:padding="16dp"
app:layout_constraintLeft_toRightOf="@+id/logo_container"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider3">
<TextView
style="@style/TextAppearance.Regular.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/score" />
<TextView
android:id="@+id/track_score"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:gravity="end"
tools:text="10" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>