merge md2 stuff in

initial changes for tracking
This commit is contained in:
Carlos 2020-03-14 08:53:47 -04:00
commit f83a6bd489
115 changed files with 3185 additions and 4722 deletions

View File

@ -21,14 +21,14 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaDetailsController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.notificationManager
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
@ -405,7 +405,7 @@ class NotificationReceiver : BroadcastReceiver() {
val newIntent = val newIntent =
Intent(context, MainActivity::class.java).setAction(MainActivity.SHORTCUT_MANGA) Intent(context, MainActivity::class.java).setAction(MainActivity.SHORTCUT_MANGA)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(MangaController.MANGA_EXTRA, manga.id) .putExtra(MangaDetailsController.MANGA_EXTRA, manga.id)
.putExtra("notificationId", manga.id.hashCode()) .putExtra("notificationId", manga.id.hashCode())
.putExtra("groupId", groupId) .putExtra("groupId", groupId)
return PendingIntent.getActivity( return PendingIntent.getActivity(

View File

@ -145,6 +145,8 @@ object PreferenceKeys {
const val keepCatSort = "keep_cat_sort" const val keepCatSort = "keep_cat_sort"
const val alwaysShowChapterTransition = "always_show_chapter_transition"
@Deprecated("Use the preferences of the source") @Deprecated("Use the preferences of the source")
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
@ -153,6 +155,7 @@ object PreferenceKeys {
fun sourceSharedPref(sourceId: Long) = "source_$sourceId" fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"

View File

@ -248,4 +248,6 @@ class PreferencesHelper(val context: Context) {
fun keepCatSort() = rxPrefs.getInteger(Keys.keepCatSort, 0) fun keepCatSort() = rxPrefs.getInteger(Keys.keepCatSort, 0)
fun hideFiltersAtStart() = rxPrefs.getBoolean("hide_filters_at_start", false) fun hideFiltersAtStart() = rxPrefs.getBoolean("hide_filters_at_start", false)
fun alwaysShowChapterTransition() = rxPrefs.getBoolean(Keys.alwaysShowChapterTransition, true)
} }

View File

@ -7,7 +7,7 @@ import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
class TrackManager(private val context: Context) { class TrackManager(context: Context) {
companion object { companion object {
const val MYANIMELIST = 1 const val MYANIMELIST = 1

View File

@ -7,8 +7,6 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) { abstract class TrackService(val id: Int) {
@ -39,17 +37,17 @@ abstract class TrackService(val id: Int) {
abstract fun displayScore(track: Track): String abstract fun displayScore(track: Track): String
abstract fun add(track: Track): Observable<Track> abstract suspend fun add(track: Track): Track
abstract fun update(track: Track): Observable<Track> abstract suspend fun update(track: Track): Track
abstract fun bind(track: Track): Observable<Track> abstract suspend fun bind(track: Track): Track
abstract fun search(query: String): Observable<List<TrackSearch>> abstract suspend fun search(query: String): List<TrackSearch>
abstract fun refresh(track: Track): Observable<Track> abstract suspend fun refresh(track: Track): Track
abstract fun login(username: String, password: String): Completable abstract suspend fun login(username: String, password: String): Boolean
@CallSuper @CallSuper
open fun logout() { open fun logout() {

View File

@ -8,8 +8,6 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class Anilist(private val context: Context, id: Int) : TrackService(id) { class Anilist(private val context: Context, id: Int) : TrackService(id) {
@ -128,68 +126,69 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun add(track: Track): Observable<Track> { override suspend fun add(track: Track): Track {
return api.addLibManga(track) return api.addLibManga(track)
} }
override fun update(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
// If user was using API v1 fetch library_id // If user was using API v1 fetch library_id
if (track.library_id == null || track.library_id!! == 0L){ if (track.library_id == null || track.library_id!! == 0L) {
return api.findLibManga(track, getUsername().toInt()).flatMap { val libManga = api.findLibManga(track, getUsername().toInt())
if (it == null) {
if (libManga == null) {
throw Exception("$track not found on user library") throw Exception("$track not found on user library")
} }
track.library_id = it.library_id track.library_id = libManga.library_id
api.updateLibManga(track)
}
} }
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override suspend fun bind(track: Track): Track {
return api.findLibManga(track, getUsername().toInt()) val remoteTrack = api.findLibManga(track, getUsername().toInt())
.flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
update(track) return update(track)
} else { } else {
// Set default fields if it's not found in the list // Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat() track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) return add(track)
}
} }
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String): List<TrackSearch> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.getLibManga(track, getUsername().toInt()) val remoteTrack = api.getLibManga(track, getUsername().toInt())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track return track
}
} }
override fun login(username: String, password: String) = login(password) override suspend fun login(username: String, password: String) = login(password)
fun login(token: String): Completable { suspend fun login(token: String): Boolean {
val oauth = api.createOAuth(token) val oauth = api.createOAuth(token)
interceptor.setAuth(oauth) interceptor.setAuth(oauth)
return api.getCurrentUser().map { (username, scoreType) ->
scorePreference.set(scoreType) try {
saveCredentials(username.toString(), oauth.access_token) val currentUser = api.getCurrentUser()
}.doOnError{ scorePreference.set(currentUser.second)
saveCredentials(currentUser.first.toString(), oauth.access_token)
return true
} catch (e: Exception) {
logout() logout()
}.toCompletable() return false
}
} }
override fun logout() { override fun logout() {

View File

@ -11,24 +11,19 @@ import com.google.gson.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.await
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
import java.util.Calendar import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val parser = JsonParser()
private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull() private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> { suspend fun addLibManga(track: Track): Track {
val query = """ val query = """
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) { |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
@ -51,21 +46,20 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.url(apiUrl) .url(apiUrl)
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) val netResponse = authClient.newCall(request).await()
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
netResponse.close() netResponse.close()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = parser.parse(responseBody).obj val response = JsonParser().parse(responseBody).obj
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
track
} return track
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun updateLibManga(track: Track): Track {
val query = """ val query = """
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
@ -90,14 +84,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.url(apiUrl) .url(apiUrl)
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) authClient.newCall(request).execute()
.asObservableSuccess() return track
.map {
track
}
} }
fun search(search: String): Observable<List<TrackSearch>> { suspend fun search(search: String): List<TrackSearch> {
val query = """ val query = """
|query Search(${'$'}query: String) { |query Search(${'$'}query: String) {
|Page (perPage: 50) { |Page (perPage: 50) {
@ -134,24 +125,20 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.url(apiUrl) .url(apiUrl)
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) val netResponse = authClient.newCall(request).await()
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = parser.parse(responseBody).obj val response = JsonParser().parse(responseBody).obj
val data = response["data"]!!.obj val data = response["data"]!!.obj
val page = data["Page"].obj val page = data["Page"].obj
val media = page["media"].array val media = page["media"].array
val entries = media.map { jsonToALManga(it.obj) } val entries = media.map { jsonToALManga(it.obj) }
entries.map { it.toTrack() } return entries.map { it.toTrack() }
}
} }
suspend fun findLibManga(track: Track, userid: Int): Track? {
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
val query = """ val query = """
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) { |query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|Page { |Page {
@ -195,33 +182,35 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.url(apiUrl) .url(apiUrl)
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) val result = authClient.newCall(request).await()
.asObservableSuccess() return result.let { resp ->
.map { netResponse -> val responseBody = resp.body?.string().orEmpty()
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = parser.parse(responseBody).obj val response = JsonParser().parse(responseBody).obj
val data = response["data"]!!.obj val data = response["data"]!!.obj
val page = data["Page"].obj val page = data["Page"].obj
val media = page["mediaList"].array val media = page["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) } val entries = media.map { jsonToALUserManga(it.obj) }
entries.firstOrNull()?.toTrack() entries.firstOrNull()?.toTrack()
} }
} }
fun getLibManga(track: Track, userid: Int): Observable<Track> { suspend fun getLibManga(track: Track, userid: Int): Track {
return findLibManga(track, userid) val track = findLibManga(track, userid)
.map { it ?: throw Exception("Could not find manga") } if (track == null) {
throw Exception("Could not find manga")
} else {
return track
}
} }
fun createOAuth(token: String): OAuth { fun createOAuth(token: String): OAuth {
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000) return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
} }
fun getCurrentUser(): Observable<Pair<Int, String>> { suspend fun getCurrentUser(): Pair<Int, String> {
val query = """ val query = """
|query User { |query User {
|Viewer { |Viewer {
@ -240,42 +229,55 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.url(apiUrl) .url(apiUrl)
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) val netResponse = authClient.newCall(request).await()
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = parser.parse(responseBody).obj val response = JsonParser().parse(responseBody).obj
val data = response["data"]!!.obj val data = response["data"]!!.obj
val viewer = data["Viewer"].obj val viewer = data["Viewer"].obj
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) return Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
}
} }
private fun jsonToALManga(struct: JsonObject): ALManga { private fun jsonToALManga(struct: JsonObject): ALManga {
val date = try { val date = try {
val date = Calendar.getInstance() val date = Calendar.getInstance()
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1, date.set(
struct["startDate"]["day"].nullInt ?: 0) struct["startDate"]["year"].nullInt ?: 0,
(struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0
)
date.timeInMillis date.timeInMillis
} catch (_: Exception) { } catch (_: Exception) {
0L 0L
} }
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, return ALManga(
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, struct["id"].asInt,
date, struct["chapters"].nullInt ?: 0) 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 { private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj)) return ALUserManga(
struct["id"].asLong,
struct["status"].asString,
struct["scoreRaw"].asInt,
struct["progress"].asInt,
jsonToALManga(struct["media"].obj)
)
} }
companion object { companion object {
private const val clientId = "385" private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val apiUrl = "https://graphql.anilist.co/" private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/" private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/" private const val baseMangaUrl = "https://anilist.co/manga/"
@ -289,5 +291,4 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.appendQueryParameter("response_type", "token") .appendQueryParameter("response_type", "token")
.build() .build()
} }
} }

View File

@ -1,18 +1,13 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import android.app.DownloadManager
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Locale
data class ALManga( data class ALManga(
val media_id: Int, val media_id: Int,
@ -49,8 +44,7 @@ data class ALUserManga(
val list_status: String, val list_status: String,
val score_raw: Int, val score_raw: Int,
val chapters_read: Int, val chapters_read: Int,
val manga: ALManga, val manga: ALManga
val context: Context = Injekt.get<PreferencesHelper>().context
) { ) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply { fun toTrack() = Track.create(TrackManager.ANILIST).apply {
@ -62,17 +56,15 @@ data class ALUserManga(
total_chapters = manga.total_chapters total_chapters = manga.total_chapters
} }
fun toTrackStatus() = with(context) { fun toTrackStatus() = when (list_status) {
when (list_status) { "CURRENT" -> Anilist.READING
getString(R.string.reading) -> Anilist.READING "COMPLETED" -> Anilist.COMPLETED
getString(R.string.completed) -> Anilist.COMPLETED "PAUSED" -> Anilist.PAUSED
getString(R.string.paused) -> Anilist.PAUSED "DROPPED" -> Anilist.DROPPED
getString(R.string.dropped) -> Anilist.DROPPED "PLANNING" -> Anilist.PLANNING
getString(R.string.plan_to_read) -> Anilist.PLANNING "REPEATING" -> Anilist.REPEATING
getString(R.string.repeating)-> Anilist.REPEATING
else -> throw NotImplementedError("Unknown status") else -> throw NotImplementedError("Unknown status")
} }
}
} }
fun Track.toAnilistStatus() = when (status) { fun Track.toAnilistStatus() = when (status) {

View File

@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Avatar(
val large: String? = "",
val medium: String? = "",
val small: String? = ""
)

View File

@ -7,8 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import timber.log.Timber
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class Bangumi(private val context: Context, id: Int) : TrackService(id) { class Bangumi(private val context: Context, id: Int) : TrackService(id) {
@ -29,55 +28,48 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString() return track.score.toInt().toString()
} }
override fun add(track: Track): Observable<Track> { override suspend fun add(track: Track): Track {
return api.addLibManga(track) return api.addLibManga(track)
} }
override fun update(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override suspend fun bind(track: Track): Track {
return api.statusLibManga(track) val statusTrack = api.statusLibManga(track)
.flatMap { val remoteTrack = api.findLibManga(track)
api.findLibManga(track).flatMap { remoteTrack -> if (statusTrack != null && remoteTrack != null) {
if (remoteTrack != null && it != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
track.status = remoteTrack.status track.status = remoteTrack.status
track.last_chapter_read = remoteTrack.last_chapter_read track.last_chapter_read = remoteTrack.last_chapter_read
refresh(track) refresh(track)
} else { } else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat() track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) add(track)
update(track) update(track)
} }
} return track
}
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String): List<TrackSearch> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.statusLibManga(track) val statusTrack = api.statusLibManga(track)
.flatMap { track.copyPersonalFrom(statusTrack!!)
track.copyPersonalFrom(it!!) val remoteTrack = api.findLibManga(track)
api.findLibManga(track) if(remoteTrack != null){
.map { remoteTrack ->
if (remoteTrack != null) {
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track.status = remoteTrack.status track.status = remoteTrack.status
} }
track return track
}
}
} }
override fun getLogo() = R.drawable.tracker_bangumi override fun getLogo() = R.drawable.tracker_bangumi
@ -99,17 +91,20 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun login(username: String, password: String) = login(password) override suspend fun login(username: String, password: String): Boolean = login(password)
fun login(code: String): Completable { suspend fun login(code: String): Boolean {
return api.accessToken(code).map { oauth: OAuth? -> try {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth) interceptor.newAuth(oauth)
if (oauth != null) {
saveCredentials(oauth.user_id.toString(), oauth.access_token) saveCredentials(oauth.user_id.toString(), oauth.access_token)
} return true
}.doOnError { } catch (e: Exception) {
Timber.e(e)
logout() logout()
}.toCompletable() }
return false
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: OAuth?) {
@ -128,7 +123,7 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).set(null) preferences.trackToken(this).set(null)
interceptor.newAuth(null) interceptor.clearOauth()
} }
companion object { companion object {

View File

@ -10,22 +10,22 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.CacheControl import okhttp3.CacheControl
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder import java.net.URLEncoder
class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) { class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) {
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> { suspend fun addLibManga(track: Track): Track {
val body = FormBody.Builder() val body = FormBody.Builder()
.add("rating", track.score.toInt().toString()) .add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus()) .add("status", track.toBangumiStatus())
@ -34,15 +34,13 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
.url("$apiUrl/collection/${track.media_id}/update") .url("$apiUrl/collection/${track.media_id}/update")
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) val response = authClient.newCall(request).await()
.asObservableSuccess() return track
.map {
track
}
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun updateLibManga(track: Track): Track {
// chapter update // chapter update
return withContext(Dispatchers.IO) {
val body = FormBody.Builder() val body = FormBody.Builder()
.add("watched_eps", track.last_chapter_read.toString()) .add("watched_eps", track.last_chapter_read.toString())
.build() .build()
@ -59,31 +57,25 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
.url("$apiUrl/collection/${track.media_id}/update") .url("$apiUrl/collection/${track.media_id}/update")
.post(sbody) .post(sbody)
.build() .build()
return authClient.newCall(srequest) authClient.newCall(srequest).execute()
.asObservableSuccess() authClient.newCall(request).execute()
.map {
track track
}.flatMap {
authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
} }
} }
fun search(search: String): Observable<List<TrackSearch>> { suspend fun search(search: String): List<TrackSearch> {
return withContext(Dispatchers.IO) {
val url = Uri.parse( val url = Uri.parse(
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon() "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}"
).buildUpon()
.appendQueryParameter("max_results", "20") .appendQueryParameter("max_results", "20")
.build() .build()
val request = Request.Builder() val request = Request.Builder()
.url(url.toString()) .url(url.toString())
.get() .get()
.build() .build()
return authClient.newCall(request)
.asObservableSuccess() val netResponse = authClient.newCall(request).await()
.map { netResponse ->
var responseBody = netResponse.body?.string().orEmpty() var responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
@ -91,10 +83,13 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
if (responseBody.contains("\"code\":404")) { if (responseBody.contains("\"code\":404")) {
responseBody = "{\"results\":0,\"list\":[]}" responseBody = "{\"results\":0,\"list\":[]}"
} }
val response = parser.parse(responseBody).obj["list"]?.array val response = JsonParser.parseString(responseBody).obj["list"]?.array
response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) } if (response != null) {
response.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) }
} else {
listOf()
}
} }
} }
private fun jsonToSearch(obj: JsonObject): TrackSearch { private fun jsonToSearch(obj: JsonObject): TrackSearch {
@ -119,23 +114,20 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
} }
} }
fun findLibManga(track: Track): Observable<Track?> { suspend fun findLibManga(track: Track): Track? {
return withContext(Dispatchers.IO) {
val urlMangas = "$apiUrl/subject/${track.media_id}" val urlMangas = "$apiUrl/subject/${track.media_id}"
val requestMangas = Request.Builder() val requestMangas = Request.Builder()
.url(urlMangas) .url(urlMangas)
.get() .get()
.build() .build()
val netResponse = authClient.newCall(requestMangas).execute()
return authClient.newCall(requestMangas)
.asObservableSuccess()
.map { netResponse ->
// get comic info
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
jsonToTrack(parser.parse(responseBody).obj) jsonToTrack(JsonParser.parseString(responseBody).obj)
} }
} }
fun statusLibManga(track: Track): Observable<Track?> { suspend fun statusLibManga(track: Track): Track? {
val urlUserRead = "$apiUrl/collection/${track.media_id}" val urlUserRead = "$apiUrl/collection/${track.media_id}"
val requestUserRead = Request.Builder() val requestUserRead = Request.Builder()
.url(urlUserRead) .url(urlUserRead)
@ -144,28 +136,27 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
.build() .build()
// todo get user readed chapter here // todo get user readed chapter here
return authClient.newCall(requestUserRead) val response = authClient.newCall(requestUserRead).await()
.asObservableSuccess() val resp = response.body?.toString()
.map { netResponse ->
val resp = netResponse.body?.string()
val coll = gson.fromJson(resp, Collection::class.java) val coll = gson.fromJson(resp, Collection::class.java)
track.status = coll.status?.id!! track.status = coll.status?.id!!
track.last_chapter_read = coll.ep_status!! track.last_chapter_read = coll.ep_status!!
track return track
}
} }
fun accessToken(code: String): Observable<OAuth> { suspend fun accessToken(code: String): OAuth {
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> return withContext(Dispatchers.IO){
val netResponse = client.newCall(accessTokenRequest(code)).execute()
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if(responseBody.isEmpty()){
throw Exception("Null Response") throw Exception("Null Response")
} }
gson.fromJson(responseBody, OAuth::class.java) gson.fromJson(responseBody, OAuth::class.java)
} }
} }
private fun accessTokenRequest(code: String) = POST(oauthUrl, private fun accessTokenRequest(code: String) = POST(
oauthUrl,
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "authorization_code") .add("grant_type", "authorization_code")
.add("client_id", clientId) .add("client_id", clientId)
@ -198,14 +189,15 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
.appendQueryParameter("redirect_uri", redirectUrl) .appendQueryParameter("redirect_uri", redirectUrl)
.build() .build()
fun refreshTokenRequest(token: String) = POST(oauthUrl, fun refreshTokenRequest(token: String) = POST(
oauthUrl,
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.add("client_id", clientId) .add("client_id", clientId)
.add("client_secret", clientSecret) .add("client_secret", clientSecret)
.add("refresh_token", token) .add("refresh_token", token)
.add("redirect_uri", redirectUrl) .add("redirect_uri", redirectUrl)
.build()) .build()
)
} }
} }

View File

@ -47,8 +47,8 @@ class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
return chain.proceed(authRequest) return chain.proceed(authRequest)
} }
fun newAuth(oauth: OAuth?) { fun newAuth(oauth: OAuth) {
this.oauth = if (oauth == null) null else OAuth( this.oauth = OAuth(
oauth.access_token, oauth.access_token,
oauth.token_type, oauth.token_type,
System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000,
@ -58,4 +58,8 @@ class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
bangumi.saveToken(oauth) bangumi.saveToken(oauth)
} }
fun clearOauth(){
bangumi.saveToken(null)
}
} }

View File

@ -11,3 +11,39 @@ data class Collection(
val user: User? = User(), val user: User? = User(),
val vol_status: Int? = 0 val vol_status: Int? = 0
) )
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 refresh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}
data class Status(
val id: Int? = 0,
val name: String? = "",
val type: String? = ""
)
data class User(
val avatar: Avatar? = Avatar(),
val id: Int? = 0,
val nickname: String? = "",
val sign: String? = "",
val url: String? = "",
val usergroup: Int? = 0,
val username: String? = ""
)
data class Avatar(
val large: String? = "",
val medium: String? = "",
val small: String? = ""
)

View File

@ -1,16 +0,0 @@
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 refresh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Status(
val id: Int? = 0,
val name: String? = "",
val type: String? = ""
)

View File

@ -1,11 +0,0 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class User(
val avatar: Avatar? = Avatar(),
val id: Int? = 0,
val nickname: String? = "",
val sign: String? = "",
val url: String? = "",
val usergroup: Int? = 0,
val username: String? = ""
)

View File

@ -7,8 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import timber.log.Timber
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat import java.text.DecimalFormat
@ -70,11 +69,11 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
return df.format(track.score) return df.format(track.score)
} }
override fun add(track: Track): Observable<Track> { override suspend fun add(track: Track): Track {
return api.addLibManga(track, getUserId()) return api.addLibManga(track, getUserId())
} }
override fun update(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
@ -82,41 +81,41 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override suspend fun bind(track: Track): Track {
return api.findLibManga(track, getUserId()) val remoteTrack = api.findLibManga(track, getUserId())
.flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id track.media_id = remoteTrack.media_id
update(track) return update(track)
} else { } else {
track.score = DEFAULT_SCORE track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) return add(track)
}
} }
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String): List<TrackSearch> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.getLibManga(track) val remoteTrack = api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track return track
}
} }
override fun login(username: String, password: String): Completable { override suspend fun login(username: String, password: String): Boolean {
return api.login(username, password) try {
.doOnNext { interceptor.newAuth(it) } val oauth = api.login(username, password)
.flatMap { api.getCurrentUser() } interceptor.newAuth(oauth)
.doOnNext { userId -> saveCredentials(username, userId) } val userId = api.getCurrentUser()
.doOnError { logout() } saveCredentials(username, userId)
.toCompletable() return true
} catch (e: Exception) {
Timber.e(e)
return false
}
} }
override fun logout() { override fun logout() {
@ -140,5 +139,4 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
null null
} }
} }
} }

View File

@ -1,6 +1,11 @@
package eu.kanade.tachiyomi.data.track.kitsu package eu.kanade.tachiyomi.data.track.kitsu
import com.github.salomonbrys.kotson.* import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.obj
import com.github.salomonbrys.kotson.string
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonObject import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@ -11,8 +16,16 @@ import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.* import retrofit2.http.Body
import rx.Observable import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) { class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
@ -42,8 +55,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.build() .build()
.create(KitsuApi.AgoliaSearchRest::class.java) .create(KitsuApi.AgoliaSearchRest::class.java)
fun addLibManga(track: Track, userId: String): Observable<Track> { suspend fun addLibManga(track: Track, userId: String): Track {
return Observable.defer {
// @formatter:off // @formatter:off
val data = jsonObject( val data = jsonObject(
"type" to "libraryEntries", "type" to "libraryEntries",
@ -67,16 +79,12 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
) )
) )
rest.addLibManga(jsonObject("data" to data)) val json = rest.addLibManga(jsonObject("data" to data))
.map { json ->
track.media_id = json["data"]["id"].int track.media_id = json["data"]["id"].int
track return track
}
}
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun updateLibManga(track: Track): Track {
return Observable.defer {
// @formatter:off // @formatter:off
val data = jsonObject( val data = jsonObject(
"type" to "libraryEntries", "type" to "libraryEntries",
@ -90,60 +98,46 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
// @formatter:on // @formatter:on
rest.updateLibManga(track.media_id, jsonObject("data" to data)) rest.updateLibManga(track.media_id, jsonObject("data" to data))
.map { track } return track
}
} }
suspend fun search(query: String): List<TrackSearch> {
fun search(query: String): Observable<List<TrackSearch>> { val key = searchRest.getKey()["media"].asJsonObject["key"].string
return searchRest return algoliaSearch(key, query)
.getKey().map { json ->
json["media"].asJsonObject["key"].string
}.flatMap { key ->
algoliaSearch(key, query)
}
} }
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter") val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
return algoliaRest val json = algoliaRest.getSearchQuery(algoliaAppId, key, jsonObject)
.getSearchQuery(algoliaAppId, key, jsonObject)
.map { json ->
val data = json["hits"].array val data = json["hits"].array
data.map { KitsuSearchManga(it.obj) } return data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" } .filter { it.subType != "novel" }
.map { it.toTrack() } .map { it.toTrack() }
} }
}
fun findLibManga(track: Track, userId: String): Observable<Track?> { suspend fun findLibManga(track: Track, userId: String): Track? {
return rest.findLibManga(track.media_id, userId) val json = rest.findLibManga(track.media_id, userId)
.map { json ->
val data = json["data"].array val data = json["data"].array
if (data.size() > 0) { return if (data.size() > 0) {
val manga = json["included"].array[0].obj val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack() KitsuLibManga(data[0].obj, manga).toTrack()
} else { } else {
null null
} }
} }
}
fun getLibManga(track: Track): Observable<Track> { suspend fun getLibManga(track: Track): Track {
return rest.getLibManga(track.media_id) val json = rest.getLibManga(track.media_id)
.map { json ->
val data = json["data"].array val data = json["data"].array
if (data.size() > 0) { if (data.size() > 0) {
val manga = json["included"].array[0].obj val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack() return KitsuLibManga(data[0].obj, manga).toTrack()
} else { } else {
throw Exception("Could not find manga") throw Exception("Could not find manga")
} }
} }
}
fun login(username: String, password: String): Observable<OAuth> { suspend fun login(username: String, password: String): OAuth {
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(loginUrl) .baseUrl(loginUrl)
.client(client) .client(client)
@ -154,95 +148,99 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.requestAccessToken(username, password) .requestAccessToken(username, password)
} }
fun getCurrentUser(): Observable<String> { suspend fun getCurrentUser(): String {
return rest.getCurrentUser().map { it["data"].array[0]["id"].string } val currentUser = rest.getCurrentUser()
return currentUser["data"].array[0]["id"].string
} }
private interface Rest { private interface Rest {
@Headers("Content-Type: application/vnd.api+json") @Headers("Content-Type: application/vnd.api+json")
@POST("library-entries") @POST("library-entries")
fun addLibManga( suspend fun addLibManga(
@Body data: JsonObject @Body data: JsonObject
): Observable<JsonObject> ): JsonObject
@Headers("Content-Type: application/vnd.api+json") @Headers("Content-Type: application/vnd.api+json")
@PATCH("library-entries/{id}") @PATCH("library-entries/{id}")
fun updateLibManga( suspend fun updateLibManga(
@Path("id") remoteId: Int, @Path("id") remoteId: Int,
@Body data: JsonObject @Body data: JsonObject
): Observable<JsonObject> ): JsonObject
@GET("library-entries") @GET("library-entries")
fun findLibManga( suspend fun findLibManga(
@Query("filter[manga_id]", encoded = true) remoteId: Int, @Query("filter[manga_id]", encoded = true) remoteId: Int,
@Query("filter[user_id]", encoded = true) userId: String, @Query("filter[user_id]", encoded = true) userId: String,
@Query("include") includes: String = "manga" @Query("include") includes: String = "manga"
): Observable<JsonObject> ): JsonObject
@GET("library-entries") @GET("library-entries")
fun getLibManga( suspend fun getLibManga(
@Query("filter[id]", encoded = true) remoteId: Int, @Query("filter[id]", encoded = true) remoteId: Int,
@Query("include") includes: String = "manga" @Query("include") includes: String = "manga"
): Observable<JsonObject> ): JsonObject
@GET("users") @GET("users")
fun getCurrentUser( suspend fun getCurrentUser(
@Query("filter[self]", encoded = true) self: Boolean = true @Query("filter[self]", encoded = true) self: Boolean = true
): Observable<JsonObject> ): JsonObject
} }
private interface SearchKeyRest { private interface SearchKeyRest {
@GET("media/") @GET("media/")
fun getKey(): Observable<JsonObject> suspend fun getKey(): JsonObject
} }
private interface AgoliaSearchRest { private interface AgoliaSearchRest {
@POST("query/") @POST("query/")
fun getSearchQuery(@Header("X-Algolia-Application-Id") appid: String, @Header("X-Algolia-API-Key") key: String, @Body json: JsonObject): Observable<JsonObject> suspend fun getSearchQuery(
@Header("X-Algolia-Application-Id") appid: String,
@Header("X-Algolia-API-Key") key: String,
@Body json: JsonObject
): JsonObject
} }
private interface LoginRest { private interface LoginRest {
@FormUrlEncoded @FormUrlEncoded
@POST("oauth/token") @POST("oauth/token")
fun requestAccessToken( suspend fun requestAccessToken(
@Field("username") username: String, @Field("username") username: String,
@Field("password") password: String, @Field("password") password: String,
@Field("grant_type") grantType: String = "password", @Field("grant_type") grantType: String = "password",
@Field("client_id") client_id: String = clientId, @Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret @Field("client_secret") client_secret: String = clientSecret
): Observable<OAuth> ): OAuth
} }
companion object { companion object {
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd" private const val clientId =
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret =
"54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseUrl = "https://kitsu.io/api/edge/" private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/" private const val loginUrl = "https://kitsu.io/api/"
private const val baseMangaUrl = "https://kitsu.io/manga/" private const val baseMangaUrl = "https://kitsu.io/manga/"
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/" private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/"
private const val algoliaUrl = "https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/" private const val algoliaUrl =
"https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/"
private const val algoliaAppId = "AWQO5J657S" private const val algoliaAppId = "AWQO5J657S"
private const val algoliaFilter = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D" private const val algoliaFilter =
"&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
fun mangaUrl(remoteId: Int): String { fun mangaUrl(remoteId: Int): String {
return baseMangaUrl + remoteId return baseMangaUrl + remoteId
} }
fun refreshTokenRequest(token: String) = POST(
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token", "${loginUrl}oauth/token",
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.add("client_id", clientId) .add("client_id", clientId)
.add("client_secret", clientSecret) .add("client_secret", clientSecret)
.add("refresh_token", token) .add("refresh_token", token)
.build()) .build()
)
} }
} }

View File

@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import rx.Completable
import rx.Observable
class Myanimelist(private val context: Context, id: Int) : TrackService(id) { class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
@ -62,11 +59,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString() return track.score.toInt().toString()
} }
override fun add(track: Track): Observable<Track> { override suspend fun add(track: Track): Track {
return api.addLibManga(track) return api.addLibManga(track)
} }
override fun update(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
@ -74,9 +71,8 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override suspend fun bind(track: Track): Track {
return api.findLibManga(track) val remoteTrack = api.findLibManga(track)
.flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
update(track) update(track)
@ -86,30 +82,31 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) add(track)
} }
} return track
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String): List<TrackSearch> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.getLibManga(track) val remoteTrack = api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track return track
}
} }
override fun login(username: String, password: String): Completable { override suspend fun login(username: String, password: String): Boolean {
logout() logout()
try {
return Observable.fromCallable { api.login(username, password) } val csrf = api.login(username, password)
.doOnNext { csrf -> saveCSRF(csrf) } saveCSRF(csrf)
.doOnNext { saveCredentials(username, password) } saveCredentials(username, password)
.doOnError { logout() } return true
.toCompletable() } catch (e: Exception) {
logout()
return false
}
} }
fun refreshLogin() { fun refreshLogin() {
@ -160,5 +157,4 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return ckCount == 2 return ckCount == 2
} }
} }

View File

@ -6,8 +6,9 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.consumeBody
import eu.kanade.tachiyomi.network.consumeXmlBody
import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText import eu.kanade.tachiyomi.util.selectText
import okhttp3.FormBody import okhttp3.FormBody
@ -15,41 +16,29 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import rx.Observable
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun search(query: String): Observable<List<TrackSearch>> { suspend fun search(query: String): List<TrackSearch> {
return if (query.startsWith(PREFIX_MY)) { if (query.startsWith(PREFIX_MY)) {
val realQuery = query.removePrefix(PREFIX_MY) val realQuery = query.removePrefix(PREFIX_MY)
getList() return getList().filter { it.title.contains(realQuery, true) }.toList()
.flatMap { Observable.from(it) }
.filter { it.title.contains(realQuery, true) }
.toList()
} else { } else {
client.newCall(GET(searchUrl(query))) val realQuery = query.take(100)
.asObservable() val response = client.newCall(GET(searchUrl(realQuery))).await()
.flatMap { response -> val matches = Jsoup.parse(response.consumeBody())
Observable.from(Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list") .select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody") .select("table").select("tbody")
.select("tr").drop(1)) .select("tr").drop(1)
}
.filter { row -> return matches.filter { row -> row.select(TD)[2].text() != "Novel" }
row.select(TD)[2].text() != "Novel"
}
.map { row -> .map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply { TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = row.searchTitle() title = row.searchTitle()
@ -67,46 +56,44 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
} }
} }
fun addLibManga(track: Track): Observable<Track> { suspend fun addLibManga(track: Track): Track {
return Observable.defer { authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))).await()
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))) return track
.asObservableSuccess()
.map { track }
}
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun updateLibManga(track: Track): Track {
return Observable.defer { authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await()
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))) return track
.asObservableSuccess()
.map { track }
}
} }
fun findLibManga(track: Track): Observable<Track?> { suspend fun findLibManga(track: Track): Track? {
return authClient.newCall(GET(url = listEntryUrl(track.media_id))) val response = authClient.newCall(GET(url = listEntryUrl(track.media_id))).await()
.asObservable()
.map {response ->
var libTrack: Track? = null var libTrack: Track? = null
response.use { response.use {
if (it.priorResponse?.isRedirect != true) { if (it.priorResponse?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody()) val trackForm = Jsoup.parse(it.consumeBody())
libTrack = Track.create(TrackManager.MYANIMELIST).apply { libTrack = Track.create(TrackManager.MYANIMELIST).apply {
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt() last_chapter_read =
trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
total_chapters = trackForm.select("#totalChap").text().toInt() total_chapters = trackForm.select("#totalChap").text().toInt()
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt() status =
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
score = trackForm.select("#add_manga_score > option[selected]").`val`()
.toFloatOrNull() ?: 0f
} }
} }
} }
libTrack return libTrack
}
} }
fun getLibManga(track: Track): Observable<Track> { suspend fun getLibManga(track: Track): Track {
return findLibManga(track) val result = findLibManga(track)
.map { it ?: throw Exception("Could not find manga") } if (result == null) {
throw Exception("Could not find manga")
} else {
return result
}
} }
fun login(username: String, password: String): String { fun login(username: String, password: String): String {
@ -126,22 +113,19 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
} }
private fun login(username: String, password: String, csrf: String) { private fun login(username: String, password: String, csrf: String) {
val response = client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf))).execute() val response =
client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf)))
.execute()
response.use { response.use {
if (response.priorResponse?.code != 302) throw Exception("Authentication error") if (response.priorResponse?.code != 302) throw Exception("Authentication error")
} }
} }
private fun getList(): Observable<List<TrackSearch>> { private suspend fun getList(): List<TrackSearch> {
return getListUrl() val results = getListXml(getListUrl()).select("manga")
.flatMap { url ->
getListXml(url) return results.map {
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply { TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!! title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id") media_id = it.selectInt("manga_mangadb_id")
@ -155,43 +139,19 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.toList() .toList()
} }
private fun getListUrl(): Observable<String> { private suspend fun getListUrl(): String {
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())) val response =
.asObservable() authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())).await()
.map {response ->
baseUrl + Jsoup.parse(response.consumeBody()) return baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult") .select("div.goodresult")
.select("a") .select("a")
.attr("href") .attr("href")
} }
}
private fun getListXml(url: String): Observable<Document> { private suspend fun getListXml(url: String): Document {
return authClient.newCall(GET(url)) val response = authClient.newCall(GET(url)).await()
.asObservable() return Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
private fun Response.consumeBody(): String? {
use {
if (it.code != 200) throw Exception("HTTP error ${it.code}")
return it.body?.string()
}
}
private fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
}
}
} }
companion object { companion object {
@ -233,7 +193,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.toString() .toString()
private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon() private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json") .appendPath("add.json")
.toString() .toString()
private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon() private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
@ -266,12 +226,14 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.put("score", track.score) .put("score", track.score)
.put("num_read_chapters", track.last_chapter_read) .put("num_read_chapters", track.last_chapter_read)
return body.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) return body.toString()
.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
} }
private fun Element.searchTitle() = select("strong").text()!! private fun Element.searchTitle() = select("strong").text()!!
private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt() private fun Element.searchTotalChapters() =
if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
private fun Element.searchCoverUrl() = select("img") private fun Element.searchCoverUrl() = select("img")
.attr("data-src") .attr("data-src")
@ -287,7 +249,8 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.first() .first()
.ownText()!! .ownText()!!
private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished" private fun Element.searchPublishingStatus() =
if (select(TD).last().text() == "-") "Publishing" else "Finished"
private fun Element.searchPublishingType() = select(TD)[2].text()!! private fun Element.searchPublishingType() = select(TD)[2].text()!!

View File

@ -7,8 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import timber.log.Timber
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class Shikimori(private val context: Context, id: Int) : TrackService(id) { class Shikimori(private val context: Context, id: Int) : TrackService(id) {
@ -21,20 +20,20 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString() return track.score.toInt().toString()
} }
override fun add(track: Track): Observable<Track> { override suspend fun add(track: Track): Track {
return api.addLibManga(track, getUsername()) return api.addLibManga(track, getUsername())
} }
override fun update(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
return api.updateLibManga(track, getUsername()) return api.updateLibManga(track, getUsername())
} }
override fun bind(track: Track): Observable<Track> { override suspend fun bind(track: Track): Track {
return api.findLibManga(track, getUsername()) val remoteTrack = api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
@ -45,22 +44,21 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) add(track)
} }
} return track
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String): List<TrackSearch> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.findLibManga(track, getUsername()) val remoteTrack = api.findLibManga(track, getUsername())
.map { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
} }
track return track
}
} }
companion object { companion object {
@ -103,18 +101,21 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun login(username: String, password: String) = login(password) override suspend fun login(username: String, password: String) = login(password)
suspend fun login(code: String): Boolean {
try {
val oauth = api.accessToken(code)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth) interceptor.newAuth(oauth)
if (oauth != null) {
val user = api.getCurrentUser() val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token) saveCredentials(user.toString(), oauth.access_token)
} return true
}.doOnError { } catch (e: java.lang.Exception) {
Timber.e(e)
logout() logout()
}.toCompletable() return false
}
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: OAuth?) {

View File

@ -14,23 +14,25 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) { class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull() private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track, user_id: String): Observable<Track> { suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id)
suspend fun addLibManga(track: Track, user_id: String): Track {
return withContext(Dispatchers.IO) {
val payload = jsonObject( val payload = jsonObject(
"user_rate" to jsonObject( "user_rate" to jsonObject(
"user_id" to user_id, "user_id" to user_id,
@ -46,16 +48,13 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.url("$apiUrl/v2/user_rates") .url("$apiUrl/v2/user_rates")
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) authClient.newCall(request).execute()
.asObservableSuccess()
.map {
track track
} }
} }
fun updateLibManga(track: Track, user_id: String): Observable<Track> = addLibManga(track, user_id) suspend fun search(search: String): List<TrackSearch> {
return withContext(Dispatchers.IO) {
fun search(search: String): Observable<List<TrackSearch>> {
val url = Uri.parse("$apiUrl/mangas").buildUpon() val url = Uri.parse("$apiUrl/mangas").buildUpon()
.appendQueryParameter("order", "popularity") .appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search) .appendQueryParameter("search", search)
@ -65,17 +64,17 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.url(url.toString()) .url(url.toString())
.get() .get()
.build() .build()
return authClient.newCall(request) val netResponse = authClient.newCall(request).execute()
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = parser.parse(responseBody).array val response = JsonParser.parseString(responseBody).array
response.map { jsonToSearch(it.obj) }
}
response.map { jsonToSearch(it.obj) }
}
} }
private fun jsonToSearch(obj: JsonObject): TrackSearch { private fun jsonToSearch(obj: JsonObject): TrackSearch {
@ -104,7 +103,8 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
} }
} }
fun findLibManga(track: Track, user_id: String): Observable<Track?> { suspend fun findLibManga(track: Track, user_id: String): Track? {
return withContext(Dispatchers.IO) {
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon() val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon()
.appendQueryParameter("user_id", user_id) .appendQueryParameter("user_id", user_id)
.appendQueryParameter("target_id", track.media_id.toString()) .appendQueryParameter("target_id", track.media_id.toString())
@ -122,20 +122,18 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.url(urlMangas.toString()) .url(urlMangas.toString())
.get() .get()
.build() .build()
return authClient.newCall(requestMangas)
.asObservableSuccess() val requestMangasResponse = authClient.newCall(requestMangas).execute()
.map { netResponse -> val requestMangasBody = requestMangasResponse.body?.string().orEmpty()
val responseBody = netResponse.body?.string().orEmpty() val mangas = JsonParser.parseString(requestMangasBody).obj
parser.parse(responseBody).obj
}.flatMap { mangas -> val requestResponse = authClient.newCall(request).execute()
authClient.newCall(request) val requestResponseBody = requestResponse.body?.string().orEmpty()
.asObservableSuccess()
.map { netResponse -> if (requestResponseBody.isEmpty()) {
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = parser.parse(responseBody).array val response = JsonParser.parseString(requestResponseBody).array
if (response.size() > 1) { if (response.size() > 1) {
throw Exception("Too much mangas in response") throw Exception("Too much mangas in response")
} }
@ -145,15 +143,15 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
entry.firstOrNull() entry.firstOrNull()
} }
} }
}
fun getCurrentUser(): Int { fun getCurrentUser(): Int {
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body?.string() val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body?.string()
return parser.parse(user).obj["id"].asInt return JsonParser.parseString(user).obj["id"].asInt
} }
fun accessToken(code: String): Observable<OAuth> { suspend fun accessToken(code: String): OAuth {
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> return withContext(Dispatchers.IO) {
val netResponse= client.newCall(accessTokenRequest(code)).execute()
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
@ -162,7 +160,8 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
} }
} }
private fun accessTokenRequest(code: String) = POST(oauthUrl, private fun accessTokenRequest(code: String) = POST(
oauthUrl,
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "authorization_code") .add("grant_type", "authorization_code")
.add("client_id", clientId) .add("client_id", clientId)
@ -172,10 +171,11 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.build() .build()
) )
companion object { companion object {
private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc" private const val clientId =
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0" "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
private const val clientSecret =
"229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val baseUrl = "https://shikimori.one" private const val baseUrl = "https://shikimori.one"
private const val apiUrl = "https://shikimori.one/api" private const val apiUrl = "https://shikimori.one/api"
@ -196,15 +196,14 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.appendQueryParameter("response_type", "code") .appendQueryParameter("response_type", "code")
.build() .build()
fun refreshTokenRequest(token: String) = POST(
fun refreshTokenRequest(token: String) = POST(oauthUrl, oauthUrl,
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.add("client_id", clientId) .add("client_id", clientId)
.add("client_secret", clientSecret) .add("client_secret", clientSecret)
.add("refresh_token", token) .add("refresh_token", token)
.build()) .build()
)
} }
} }

View File

@ -1,20 +1,13 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.devrepo.DevRepoUpdateChecker
import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker
import rx.Observable import rx.Observable
abstract class UpdateChecker { abstract class UpdateChecker {
companion object { companion object {
fun getUpdateChecker(): UpdateChecker { fun getUpdateChecker(): UpdateChecker = GithubUpdateChecker()
return if (BuildConfig.DEBUG) {
DevRepoUpdateChecker()
} else {
GithubUpdateChecker()
}
}
} }
/** /**

View File

@ -23,7 +23,7 @@ class ExtensionUpdateJob : Job() {
override fun onRunJob(params: Params): Result { override fun onRunJob(params: Params): Result {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
val pendingUpdates = ExtensionGithubApi().checkforUpdates(context) val pendingUpdates = ExtensionGithubApi().checkForUpdates(context)
if (pendingUpdates.isNotEmpty()) { if (pendingUpdates.isNotEmpty()) {
val names = pendingUpdates.map { it.name } val names = pendingUpdates.map { it.name }
val preferences: PreferencesHelper by injectLazy() val preferences: PreferencesHelper by injectLazy()

View File

@ -7,18 +7,16 @@ import com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.string import com.github.salomonbrys.kotson.string
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonArray import com.google.gson.JsonArray
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import eu.kanade.tachiyomi.network.await
import okhttp3.Response import okhttp3.Response
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.lang.Exception
internal class ExtensionGithubApi { internal class ExtensionGithubApi {
@ -34,7 +32,7 @@ internal class ExtensionGithubApi {
} }
} }
suspend fun checkforUpdates(context: Context): List<Extension.Installed> { suspend fun checkForUpdates(context: Context): List<Extension.Installed> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val call = GET("$REPO_URL/index.json") val call = GET("$REPO_URL/index.json")
val response = network.client.newCall(call).await() val response = network.client.newCall(call).await()

View File

@ -7,21 +7,23 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.widget.Toast
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.WebViewClientCompat import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import okhttp3.Cookie import eu.kanade.tachiyomi.util.system.isOutdated
import okhttp3.Interceptor import eu.kanade.tachiyomi.util.system.toast
import okhttp3.Request
import okhttp3.Response
import okhttp3.HttpUrl.Companion.toHttpUrl
import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class CloudflareInterceptor(private val context: Context) : Interceptor { class CloudflareInterceptor(private val context: Context) : Interceptor {
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val networkHelper: NetworkHelper by injectLazy() private val networkHelper: NetworkHelper by injectLazy()
@ -43,16 +45,28 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
val response = chain.proceed(originalRequest) val response = chain.proceed(originalRequest)
// Check if Cloudflare anti-bot is on // Check if Cloudflare anti-bot is on
if (response.code == 503 && response.header("Server") in serverCheck) { if (response.code != 503 || response.header("Server") !in SERVER_CHECK) {
return response
}
try { try {
response.close() response.close()
networkHelper.cookieManager.remove(originalRequest.url, listOf("__cfduid", "cf_clearance"), 0) networkHelper.cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0)
val oldCookie = networkHelper.cookieManager.get(originalRequest.url) val oldCookie = networkHelper.cookieManager.get(originalRequest.url)
.firstOrNull { it.name == "cf_clearance" } .firstOrNull { it.name == "cf_clearance" }
return if (resolveWithWebView(originalRequest, oldCookie)) { resolveWithWebView(originalRequest, oldCookie)
chain.proceed(originalRequest)
// Avoid use empty User-Agent
return if (originalRequest.header("User-Agent").isNullOrEmpty()) {
val newRequest = originalRequest
.newBuilder()
.removeHeader("User-Agent")
.addHeader("User-Agent",
DEFAULT_USERAGENT)
.build()
chain.proceed(newRequest)
} else { } else {
throw IOException("Failed to bypass Cloudflare!") chain.proceed(originalRequest)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
@ -61,41 +75,45 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
} }
} }
return response
}
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(request: Request, oldCookie: Cookie?): Boolean { private fun resolveWithWebView(request: Request, oldCookie: Cookie?) {
// We need to lock this thread until the WebView finds the challenge solution url, because // We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors. // OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
var webView: WebView? = null var webView: WebView? = null
var challengeFound = false var challengeFound = false
var cloudflareBypassed = false var cloudflareBypassed = false
var isWebviewOutdated = false
val origRequestUrl = request.url.toString() val origRequestUrl = request.url.toString()
val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
val withUserAgent = request.header("User-Agent").isNullOrEmpty()
handler.post { handler.post {
val view = WebView(context.applicationContext) val webview = WebView(context)
webView = view webView = webview
view.settings.javaScriptEnabled = true webview.settings.javaScriptEnabled = true
view.settings.userAgentString = request.header("User-Agent")
view.webViewClient = object : WebViewClientCompat() {
// Avoid set empty User-Agent, Chromium WebView will reset to default if empty
webview.settings.userAgentString = request.header("User-Agent")
?: DEFAULT_USERAGENT
webview.webViewClient = object : WebViewClientCompat() {
override fun onPageFinished(view: WebView, url: String) { override fun onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean { fun isCloudFlareBypassed(): Boolean {
return networkHelper.cookieManager.get(origRequestUrl.toHttpUrl()) return networkHelper.cookieManager.get(origRequestUrl.toHttpUrl())
.firstOrNull { it.name == "cf_clearance" } .firstOrNull { it.name == "cf_clearance" }
.let { it != null && it != oldCookie } .let { it != null && (it != oldCookie || withUserAgent) }
} }
if (isCloudFlareBypassed()) { if (isCloudFlareBypassed()) {
cloudflareBypassed = true cloudflareBypassed = true
latch.countDown() latch.countDown()
} }
// Http error codes are only received since M
// HTTP error codes are only received since M
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
url == origRequestUrl && !challengeFound url == origRequestUrl && !challengeFound
) { ) {
@ -122,6 +140,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
} }
} }
} }
webView?.loadUrl(origRequestUrl, headers) webView?.loadUrl(origRequestUrl, headers)
} }
@ -130,10 +149,28 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
latch.await(12, TimeUnit.SECONDS) latch.await(12, TimeUnit.SECONDS)
handler.post { handler.post {
if (!cloudflareBypassed) {
isWebviewOutdated = webView?.isOutdated() == true
}
webView?.stopLoading() webView?.stopLoading()
webView?.destroy() webView?.destroy()
} }
return cloudflareBypassed
// Throw exception if we failed to bypass Cloudflare
if (!cloudflareBypassed) {
// Prompt user to update WebView if it seems too outdated
if (isWebviewOutdated) {
context.toast(R.string.information_webview_outdated, Toast.LENGTH_LONG)
} }
throw Exception(context.getString(R.string.information_cloudflare_bypass_failure))
}
}
companion object {
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
private val COOKIE_NAMES = listOf("__cfduid", "cf_clearance")
private const val DEFAULT_USERAGENT = "Mozilla/5.0 (Windows NT 6.3; WOW64)"
}
} }

View File

@ -5,8 +5,11 @@ import okhttp3.*
import rx.Observable import rx.Observable
import rx.Producer import rx.Producer
import rx.Subscription import rx.Subscription
import java.io.BufferedReader
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.GZIPInputStream
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@ -94,3 +97,23 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene
return progressClient.newCall(request) return progressClient.newCall(request)
} }
fun Response.consumeBody(): String? {
use {
if (it.code != 200) throw Exception("HTTP error ${it.code}")
return it.body?.string()
}
}
fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
}
}
}

View File

@ -20,6 +20,10 @@ class CenteredToolbar@JvmOverloads constructor(context: Context, attrs: Attribut
} }
else { else {
toolbar_title.text = context.getString(resId) toolbar_title.text = context.getString(resId)
post {
toolbar_title.text = context.getString(resId)
requestLayout()
}
super.setTitle(null) super.setTitle(null)
} }
} }
@ -31,6 +35,10 @@ class CenteredToolbar@JvmOverloads constructor(context: Context, attrs: Attribut
} }
else { else {
toolbar_title.text = title toolbar_title.text = title
post {
toolbar_title.text = title
requestLayout()
}
super.setTitle(null) super.setTitle(null)
} }
} }

View File

@ -13,7 +13,7 @@ import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.ControllerChangeType
import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
import com.jakewharton.rxbinding.support.v7.widget.queryTextChangeEvents import com.google.android.material.bottomsheet.BottomSheetBehavior
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@ -26,17 +26,20 @@ import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCatalogueController import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCatalogueController
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.catalogue.latest.LatestUpdatesController import eu.kanade.tachiyomi.ui.catalogue.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.extension.SettingsExtensionsController
import eu.kanade.tachiyomi.ui.main.RootSearchInterface import eu.kanade.tachiyomi.ui.main.RootSearchInterface
import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController
import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener
import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController
import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.scrollViewWith
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.catalogue_main_controller.* import kotlinx.android.synthetic.main.catalogue_main_controller.*
import kotlinx.android.synthetic.main.extensions_bottom_sheet.*
import kotlinx.android.synthetic.main.main_activity.* import kotlinx.android.synthetic.main.main_activity.*
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.math.max
/** /**
* This controller shows and manages the different catalogues enabled by the user. * This controller shows and manages the different catalogues enabled by the user.
@ -50,6 +53,7 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemClickListener,
CatalogueAdapter.OnBrowseClickListener, CatalogueAdapter.OnBrowseClickListener,
RootSearchInterface, RootSearchInterface,
CatalogueAdapter.OnLatestClickListener { CatalogueAdapter.OnLatestClickListener {
/** /**
@ -62,6 +66,13 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
*/ */
private var adapter: CatalogueAdapter? = null private var adapter: CatalogueAdapter? = null
var extQuery = ""
private set
var headerHeight = 0
var customTitle = ""
/** /**
* Called when controller is initialized. * Called when controller is initialized.
*/ */
@ -76,7 +87,9 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
* @return title. * @return title.
*/ */
override fun getTitle(): String? { override fun getTitle(): String? {
return applicationContext?.getString(R.string.label_catalogues) return if (ext_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED)
applicationContext?.getString(R.string.label_extensions)
else applicationContext?.getString(R.string.label_catalogues)
} }
/** /**
@ -114,11 +127,49 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context)
recycler.adapter = adapter recycler.adapter = adapter
recycler.addItemDecoration(SourceDividerItemDecoration(view.context)) recycler.addItemDecoration(SourceDividerItemDecoration(view.context))
recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) val attrsArray = intArrayOf(android.R.attr.actionBarSize)
val array = view.context.obtainStyledAttributes(attrsArray)
scrollViewWith(recycler) val appBarHeight = array.getDimensionPixelSize(0, 0)
array.recycle()
scrollViewWith(recycler) {
headerHeight = it.systemWindowInsetTop + appBarHeight
}
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
ext_bottom_sheet.onCreate(this)
ext_bottom_sheet.sheetBehavior?.addBottomSheetCallback(object : BottomSheetBehavior
.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, progress: Float) {
shadow2.alpha = (1 - max(0f, progress)) * 0.25f
sheet_layout.alpha = 1 - progress
activity?.appbar?.y = max(activity!!.appbar.y, -headerHeight * (1 - progress))
}
override fun onStateChanged(p0: View, state: Int) {
if (state == BottomSheetBehavior.STATE_EXPANDED) activity?.appbar?.y = 0f
if (state == BottomSheetBehavior.STATE_EXPANDED ||
state == BottomSheetBehavior.STATE_COLLAPSED)
sheet_layout.alpha =
if (state == BottomSheetBehavior.STATE_COLLAPSED) 1f else 0f
retainViewMode = if (state == BottomSheetBehavior.STATE_EXPANDED)
RetainViewMode.RETAIN_DETACH else RetainViewMode.RELEASE_DETACH
activity?.invalidateOptionsMenu()
setTitle()
sheet_layout.isClickable = state == BottomSheetBehavior.STATE_COLLAPSED
sheet_layout.isFocusable = state == BottomSheetBehavior.STATE_COLLAPSED
}
})
}
override fun handleRootBack(): Boolean {
if (ext_bottom_sheet.sheetBehavior?.state != BottomSheetBehavior.STATE_COLLAPSED) {
ext_bottom_sheet.sheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
return true
}
return false
} }
override fun onDestroyView(view: View) { override fun onDestroyView(view: View) {
@ -129,6 +180,7 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type) super.onChangeStarted(handler, type)
if (!type.isPush && handler is SettingsSourcesFadeChangeHandler) { if (!type.isPush && handler is SettingsSourcesFadeChangeHandler) {
ext_bottom_sheet.updateExtTitle()
presenter.updateSources() presenter.updateSources()
} }
} }
@ -192,6 +244,25 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
* @param inflater used to load the menu xml. * @param inflater used to load the menu xml.
*/ */
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (ext_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED) {
// Inflate menu
inflater.inflate(R.menu.extension_main, menu)
// Initialize search option.
val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView
// Change hint to show global search.
searchView.queryHint = applicationContext?.getString(R.string.search_extensions)
// Create query listener which opens the global search view.
setOnQueryTextChangeListener(searchView) {
extQuery = it ?: ""
ext_bottom_sheet.drawExtensions()
true
}
}
else {
// Inflate menu // Inflate menu
inflater.inflate(R.menu.catalogue_main, menu) inflater.inflate(R.menu.catalogue_main, menu)
@ -203,9 +274,11 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
searchView.queryHint = applicationContext?.getString(R.string.action_global_search_hint) searchView.queryHint = applicationContext?.getString(R.string.action_global_search_hint)
// Create query listener which opens the global search view. // Create query listener which opens the global search view.
searchView.queryTextChangeEvents() setOnQueryTextChangeListener(searchView, true) {
.filter { it.isSubmitted } if (!it.isNullOrBlank()) performGlobalSearch(it)
.subscribeUntilDestroy { performGlobalSearch(it.queryText().toString()) } true
}
}
} }
private fun performGlobalSearch(query: String){ private fun performGlobalSearch(query: String){
@ -222,9 +295,18 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
when (item.itemId) { when (item.itemId) {
// Initialize option to open catalogue settings. // Initialize option to open catalogue settings.
R.id.action_filter -> { R.id.action_filter -> {
router.pushController((RouterTransaction.with(SettingsSourcesController())) val controller =
.popChangeHandler(SettingsSourcesFadeChangeHandler()) if (ext_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED)
.pushChangeHandler(FadeChangeHandler())) SettingsExtensionsController()
else SettingsSourcesController()
router.pushController(
(RouterTransaction.with(controller)).popChangeHandler(
SettingsSourcesFadeChangeHandler()
).pushChangeHandler(FadeChangeHandler())
)
}
R.id.action_dismiss -> {
ext_bottom_sheet.sheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
} }
else -> return super.onOptionsItemSelected(item) else -> return super.onOptionsItemSelected(item)
} }

View File

@ -12,7 +12,7 @@ import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.* import java.util.TreeMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**

View File

@ -3,17 +3,19 @@ package eu.kanade.tachiyomi.ui.extension
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.extension.ExtensionAdapter.OnButtonClickListener
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
/** /**
* Adapter that holds the catalogue cards. * Adapter that holds the catalogue cards.
* *
* @param controller instance of [ExtensionController]. * @param listener instance of [OnButtonClickListener].
*/ */
class ExtensionAdapter(val controller: ExtensionController) : class ExtensionAdapter(val listener: OnButtonClickListener) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) { FlexibleAdapter<IFlexible<*>>(null, listener, true) {
val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card) val cardBackground = (listener as ExtensionBottomSheet).context.getResourceColor(R.attr
.background_card)
init { init {
setDisplayHeadersAtStartUp(true) setDisplayHeadersAtStartUp(true)
@ -22,7 +24,7 @@ class ExtensionAdapter(val controller: ExtensionController) :
/** /**
* Listener for browse item clicks. * Listener for browse item clicks.
*/ */
val buttonClickListener: ExtensionAdapter.OnButtonClickListener = controller val buttonClickListener: ExtensionAdapter.OnButtonClickListener = listener
interface OnButtonClickListener { interface OnButtonClickListener {
fun onButtonClick(position: Int) fun onButtonClick(position: Int)

View File

@ -0,0 +1,153 @@
package eu.kanade.tachiyomi.ui.extension
import android.app.Application
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
import kotlin.coroutines.CoroutineContext
/**
* Presenter of [ExtensionController].
*/
open class ExtensionBottomPresenter(
private val bottomSheet: ExtensionBottomSheet,
private val extensionManager: ExtensionManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
) : CoroutineScope {
override var coroutineContext: CoroutineContext = Job() + Dispatchers.Default
private var extensions = emptyList<ExtensionItem>()
private var currentDownloads = hashMapOf<String, InstallStep>()
fun onCreate() {
extensionManager.findAvailableExtensions()
bindToExtensionsObservable()
}
private fun bindToExtensionsObservable(): Subscription {
val installedObservable = extensionManager.getInstalledExtensionsObservable()
val untrustedObservable = extensionManager.getUntrustedExtensionsObservable()
val availableObservable = extensionManager.getAvailableExtensionsObservable()
.startWith(emptyList<Extension.Available>())
return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable)
{ installed, untrusted, available -> Triple(installed, untrusted, available) }
.debounce(100, TimeUnit.MILLISECONDS)
.map(::toItems)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
bottomSheet.setExtensions(extensions)
}
}
@Synchronized
private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
val context = Injekt.get<Application>()
val activeLangs = preferences.enabledLanguages().getOrDefault()
val (installed, untrusted, available) = tuple
val items = mutableListOf<ExtensionItem>()
val installedSorted = installed.sortedWith(compareBy({ !it.hasUpdate }, { !it.isObsolete }, { it.pkgName }))
val untrustedSorted = untrusted.sortedBy { it.pkgName }
val availableSorted = available
// Filter out already installed extensions and disabled languages
.filter { avail -> installed.none { it.pkgName == avail.pkgName }
&& untrusted.none { it.pkgName == avail.pkgName }
&& (avail.lang in activeLangs || avail.lang == "all")}
.sortedBy { it.pkgName }
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
items += installedSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
}
items += untrustedSorted.map { extension ->
ExtensionItem(extension, header)
}
}
if (availableSorted.isNotEmpty()) {
val availableGroupedByLang = availableSorted
.groupBy { LocaleHelper.getDisplayName(it.lang, context) }
.toSortedMap()
availableGroupedByLang
.forEach {
val header = ExtensionGroupItem(it.key, it.value.size)
items += it.value.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
}
}
}
this.extensions = items
return items
}
fun getExtensionUpdateCount():Int = preferences.extensionUpdatesCount().getOrDefault()
fun getAutoCheckPref() = preferences.automaticExtUpdates()
@Synchronized
private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? {
val extensions = extensions.toMutableList()
val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
return if (position != -1) {
val item = extensions[position].copy(installStep = state)
extensions[position] = item
this.extensions = extensions
item
} else {
null
}
}
fun installExtension(extension: Extension.Available) {
extensionManager.installExtension(extension).subscribeToInstallUpdate(extension)
}
fun updateExtension(extension: Extension.Installed) {
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
}
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
this.doOnNext { currentDownloads[extension.pkgName] = it }
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
.map { state -> updateInstallStep(extension, state) }
.subscribe { item ->
if (item != null) {
bottomSheet.downloadUpdate(item)
}
}
}
fun uninstallExtension(pkgName: String) {
extensionManager.uninstallExtension(pkgName)
}
fun findAvailableExtensions() {
extensionManager.findAvailableExtensions()
}
fun trustSignature(signatureHash: String) {
extensionManager.trustSignature(signatureHash)
}
}

View File

@ -0,0 +1,225 @@
package eu.kanade.tachiyomi.ui.extension
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.CheckBox
import android.widget.CompoundButton
import android.widget.LinearLayout
import com.f2prateek.rx.preferences.Preference
import com.google.android.material.bottomsheet.BottomSheetBehavior
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener
import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets
import eu.kanade.tachiyomi.util.view.updateLayoutParams
import kotlinx.android.synthetic.main.extensions_bottom_sheet.view.*
class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: LinearLayout(context, attrs),
ExtensionAdapter.OnButtonClickListener,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ExtensionTrustDialog.Listener {
var sheetBehavior: BottomSheetBehavior<*>? = null
lateinit var autoCheckItem:AutoCheckItem
/**
* Adapter containing the list of manga from the catalogue.
*/
private var adapter: FlexibleAdapter<IFlexible<*>>? = null
val presenter = ExtensionBottomPresenter(this)
private var extensions: List<ExtensionItem> = emptyList()
lateinit var controller: CatalogueController
fun onCreate(controller: CatalogueController) {
// Initialize adapter, scroll listener and recycler views
autoCheckItem = AutoCheckItem(presenter.getAutoCheckPref())
adapter = ExtensionAdapter(this)
sheetBehavior = BottomSheetBehavior.from(this)
// Create recycler and set adapter.
ext_recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context)
ext_recycler.adapter = adapter
ext_recycler.addItemDecoration(ExtensionDividerItemDecoration(context))
ext_recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener)
this.controller = controller
presenter.onCreate()
updateExtTitle()
val attrsArray = intArrayOf(android.R.attr.actionBarSize)
val array = context.obtainStyledAttributes(attrsArray)
val headerHeight = array.getDimensionPixelSize(0, 0)
array.recycle()
ext_recycler.doOnApplyWindowInsets { _, windowInsets, _ ->
ext_recycler.updateLayoutParams<LayoutParams> {
topMargin = windowInsets.systemWindowInsetTop + headerHeight -
(sheet_layout.height)
}
}
sheet_layout.setOnClickListener {
if (sheetBehavior?.state != BottomSheetBehavior.STATE_EXPANDED) {
sheetBehavior?.state = BottomSheetBehavior.STATE_EXPANDED
} else {
sheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
presenter.getExtensionUpdateCount()
}
fun updateExtTitle() {
val extCount = presenter.getExtensionUpdateCount()
title_text.text = if (extCount == 0) context.getString(R.string.label_extensions)
else resources.getQuantityString(R.plurals.extensions_updates_available, extCount,
extCount)
title_text.setTextColor(context.getResourceColor(
if (extCount == 0) R.attr.actionBarTintColor else R.attr.colorAccent))
}
override fun onButtonClick(position: Int) {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
when (extension) {
is Extension.Installed -> {
if (!extension.hasUpdate) {
openDetails(extension)
} else {
presenter.updateExtension(extension)
}
}
is Extension.Available -> {
presenter.installExtension(extension)
}
is Extension.Untrusted -> {
openTrustDialog(extension)
}
}
}
override fun onItemClick(view: View?, position: Int): Boolean {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false
if (extension is Extension.Installed) {
openDetails(extension)
} else if (extension is Extension.Untrusted) {
openTrustDialog(extension)
}
return false
}
override fun onItemLongClick(position: Int) {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
if (extension is Extension.Installed || extension is Extension.Untrusted) {
uninstallExtension(extension.pkgName)
}
}
private fun openDetails(extension: Extension.Installed) {
val controller = ExtensionDetailsController(extension.pkgName)
this.controller.router.pushController(controller.withFadeTransaction())
}
private fun openTrustDialog(extension: Extension.Untrusted) {
ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName)
.showDialog(controller.router)
}
fun setExtensions(extensions: List<ExtensionItem>) {
//ext_swipe_refresh?.isRefreshing = false
this.extensions = extensions
controller.presenter.updateSources()
drawExtensions()
}
fun drawExtensions() {
if (!controller.extQuery.isBlank()) {
adapter?.updateDataSet(
extensions.filter {
it.extension.name.contains(controller.extQuery, ignoreCase = true)
})
} else {
adapter?.updateDataSet(extensions)
}
updateExtTitle()
setLastUsedSource()
}
/**
* Called to set the last used catalogue at the top of the view.
*/
private fun setLastUsedSource() {
adapter?.removeAllScrollableHeaders()
adapter?.addScrollableHeader(autoCheckItem)
}
fun downloadUpdate(item: ExtensionItem) {
adapter?.updateItem(item, item.installStep)
}
override fun trustSignature(signatureHash: String) {
presenter.trustSignature(signatureHash)
}
override fun uninstallExtension(pkgName: String) {
presenter.uninstallExtension(pkgName)
}
}
class AutoCheckItem(private val autoCheck: Preference<Boolean>) : AbstractHeaderItem<AutoCheckItem.AutoCheckHolder>() {
override fun getLayoutRes(): Int {
return R.layout.auto_ext_checkbox
}
override fun createViewHolder(
view: View, adapter: FlexibleAdapter<IFlexible<*>>
): AutoCheckHolder {
return AutoCheckHolder(view, adapter, autoCheck)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<*>>,
holder: AutoCheckHolder,
position: Int,
payloads: MutableList<Any?>?
) {
//holder.bind(autoCheck.getOrDefault())
}
override fun equals(other: Any?): Boolean {
return (this === other)
}
override fun hashCode(): Int {
return -1
}
class AutoCheckHolder(val view: View, private val adapter: FlexibleAdapter<IFlexible<*>>,
autoCheck: Preference<Boolean>) :
FlexibleViewHolder(view, adapter, true) {
private val autoCheckbox: CheckBox = view.findViewById(R.id.auto_checkbox)
init {
autoCheckbox.bindToPreference(autoCheck)
}
/**
* Binds a checkbox or switch view with a boolean preference.
*/
private fun CompoundButton.bindToPreference(pref: Preference<Boolean>) {
isChecked = pref.getOrDefault()
setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) }
}
}
}

View File

@ -17,7 +17,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private typealias ExtensionTuple typealias ExtensionTuple
= Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>> = Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
/** /**

View File

@ -3,18 +3,18 @@ package eu.kanade.tachiyomi.ui.extension
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle) class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T: ExtensionTrustDialog.Listener { where T: ExtensionTrustDialog.Listener {
lateinit var listener: Listener
constructor(target: T, signatureHash: String, pkgName: String) : this(Bundle().apply { constructor(target: T, signatureHash: String, pkgName: String) : this(Bundle().apply {
putString(SIGNATURE_KEY, signatureHash) putString(SIGNATURE_KEY, signatureHash)
putString(PKGNAME_KEY, pkgName) putString(PKGNAME_KEY, pkgName)
}) { }) {
targetController = target listener = target
} }
override fun onCreateDialog(savedViewState: Bundle?): Dialog { override fun onCreateDialog(savedViewState: Bundle?): Dialog {
@ -22,10 +22,10 @@ class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
.title(R.string.untrusted_extension) .title(R.string.untrusted_extension)
.message(R.string.untrusted_extension_message) .message(R.string.untrusted_extension_message)
.positiveButton(R.string.ext_trust) { .positiveButton(R.string.ext_trust) {
(targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!) listener.trustSignature(args.getString(SIGNATURE_KEY)!!)
} }
.negativeButton(R.string.ext_uninstall) { .negativeButton(R.string.ext_uninstall) {
(targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!) listener.uninstallExtension(args.getString(PKGNAME_KEY)!!)
} }
} }

View File

@ -183,5 +183,6 @@ class LibraryCategoryAdapter(val libraryListener: LibraryListener) :
fun selectAll(position: Int) fun selectAll(position: Int)
fun allSelected(position: Int): Boolean fun allSelected(position: Int): Boolean
fun showCategories(position: Int, view: View) fun showCategories(position: Int, view: View)
fun recyclerIsScrolling(): Boolean
} }
} }

View File

@ -399,4 +399,5 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
override fun selectAll(position: Int) { } override fun selectAll(position: Int) { }
override fun allSelected(position: Int): Boolean = false override fun allSelected(position: Int): Boolean = false
override fun showCategories(position: Int, view: View) { } override fun showCategories(position: Int, view: View) { }
override fun recyclerIsScrolling() = false
} }

View File

@ -40,8 +40,7 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.download.DownloadController import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet
import eu.kanade.tachiyomi.ui.library.filter.SortFilterBottomSheet
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.RootSearchInterface import eu.kanade.tachiyomi.ui.main.RootSearchInterface
import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.manga.MangaDetailsController
@ -51,7 +50,7 @@ import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationListController
import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationProcedureConfig import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationProcedureConfig
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.launchUI import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.snack
@ -192,26 +191,22 @@ open class LibraryController(
bottom_sheet.onGroupClicked = { bottom_sheet.onGroupClicked = {
when (it) { when (it) {
SortFilterBottomSheet.ACTION_REFRESH -> onRefresh() FilterBottomSheet.ACTION_REFRESH -> onRefresh()
SortFilterBottomSheet.ACTION_FILTER -> onFilterChanged() FilterBottomSheet.ACTION_FILTER -> onFilterChanged()
SortFilterBottomSheet.ACTION_SORT -> onSortChanged() FilterBottomSheet.ACTION_HIDE_FILTER_TIP -> activity?.toast(R.string.hide_filters_tip)
SortFilterBottomSheet.ACTION_DISPLAY -> reattachAdapter()
SortFilterBottomSheet.ACTION_DOWNLOAD_BADGE -> presenter.requestDownloadBadgesUpdate()
SortFilterBottomSheet.ACTION_UNREAD_BADGE -> presenter.requestUnreadBadgesUpdate()
SortFilterBottomSheet.ACTION_CAT_SORT -> onCatSortChanged()
} }
} }
fab.setOnClickListener { /* fab.setOnClickListener {
router.pushController(DownloadController().withFadeTransaction()) router.pushController(DownloadController().withFadeTransaction())
} }*/
if (presenter.isDownloading()) { /* if (presenter.isDownloading()) {
fab.scaleY = 1f fab.scaleY = 1f
fab.scaleX = 1f fab.scaleX = 1f
fab.isClickable = true fab.isClickable = true
fab.isFocusable = true fab.isFocusable = true
} }*/
val config = resources?.configuration val config = resources?.configuration
phoneLandscape = (config?.orientation == Configuration.ORIENTATION_LANDSCAPE && phoneLandscape = (config?.orientation == Configuration.ORIENTATION_LANDSCAPE &&
@ -290,14 +285,14 @@ open class LibraryController(
} }
override fun downloadStatusChanged(downloading: Boolean) { override fun downloadStatusChanged(downloading: Boolean) {
launchUI { /* launchUI {
val scale = if (downloading) 1f else 0f val scale = if (downloading) 1f else 0f
val fab = fab ?: return@launchUI val fab = fab ?: return@launchUI
fab.animate().scaleX(scale).scaleY(scale).setDuration(200).start() fab.animate().scaleX(scale).scaleY(scale).setDuration(200).start()
fab.isClickable = downloading fab.isClickable = downloading
fab.isFocusable = downloading fab.isFocusable = downloading
bottom_sheet?.adjustFiltersMargin(downloading) bottom_sheet?.adjustFiltersMargin(downloading)
} }*/
} }
override fun onUpdateManga(manga: LibraryManga) { override fun onUpdateManga(manga: LibraryManga) {

View File

@ -144,6 +144,7 @@ class LibraryHeaderItem(private val categoryF: (Int) -> Category, val catId: Int
} }
} }
private fun showCatSortOptions() { private fun showCatSortOptions() {
if (adapter.libraryListener.recyclerIsScrolling()) return
val category = val category =
(adapter.getItem(adapterPosition) as? LibraryHeaderItem)?.category ?: return (adapter.getItem(adapterPosition) as? LibraryHeaderItem)?.category ?: return
// Create a PopupMenu, giving it the clicked view for an anchor // Create a PopupMenu, giving it the clicked view for an anchor

View File

@ -50,4 +50,9 @@ abstract class LibraryHolder(
super.onItemReleased(position) super.onItemReleased(position)
(adapter as? LibraryCategoryAdapter)?.libraryListener?.onItemReleased(position) (adapter as? LibraryCategoryAdapter)?.libraryListener?.onItemReleased(position)
} }
override fun onLongClick(view: View?): Boolean {
super.onLongClick(view)
return false // !adapter.libraryListener.recyclerIsScrolling()
}
} }

View File

@ -1,15 +1,21 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.TypedValue import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.math.MathUtils.clamp import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -26,6 +32,7 @@ import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.main.OnTouchEventInterface
import eu.kanade.tachiyomi.ui.main.SpinnerTitleInterface import eu.kanade.tachiyomi.ui.main.SpinnerTitleInterface
import eu.kanade.tachiyomi.ui.main.SwipeGestureInterface import eu.kanade.tachiyomi.ui.main.SwipeGestureInterface
import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.dpToPx
@ -33,6 +40,7 @@ import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.view.inflate import eu.kanade.tachiyomi.util.view.inflate
import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.scrollViewWith
import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.updateLayoutParams
import eu.kanade.tachiyomi.util.view.updatePaddingRelative import eu.kanade.tachiyomi.util.view.updatePaddingRelative
import kotlinx.android.synthetic.main.filter_bottom_sheet.* import kotlinx.android.synthetic.main.filter_bottom_sheet.*
import kotlinx.android.synthetic.main.library_grid_recycler.* import kotlinx.android.synthetic.main.library_grid_recycler.*
@ -40,8 +48,14 @@ import kotlinx.android.synthetic.main.library_list_controller.*
import kotlinx.android.synthetic.main.main_activity.* import kotlinx.android.synthetic.main.main_activity.*
import kotlinx.android.synthetic.main.spinner_title.view.* import kotlinx.android.synthetic.main.spinner_title.view.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import timber.log.Timber
import java.util.Locale import java.util.Locale
import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sign
class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle), class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemClickListener,
@ -49,6 +63,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
FlexibleAdapter.OnItemMoveListener, FlexibleAdapter.OnItemMoveListener,
LibraryCategoryAdapter.LibraryListener, LibraryCategoryAdapter.LibraryListener,
SpinnerTitleInterface, SpinnerTitleInterface,
OnTouchEventInterface,
SwipeGestureInterface { SwipeGestureInterface {
private lateinit var adapter: LibraryCategoryAdapter private lateinit var adapter: LibraryCategoryAdapter
@ -66,6 +81,18 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
private var switchingCategories = false private var switchingCategories = false
var startPosX:Float? = null
var startPosY:Float? = null
var moved = false
var lockedRecycler = false
var lockedY = false
var nextCategory:Int? = null
var ogCategory:Int? = null
var prevCategory:Int? = null
private val swipeDistance = 300f
var flinging = false
var isDragging = false
/** /**
* Recycler view of the list of manga. * Recycler view of the list of manga.
*/ */
@ -73,7 +100,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
override fun contentView():View = recycler_layout override fun contentView():View = recycler_layout
/* override fun getTitle(): String? { override fun getTitle(): String? {
return if (::customTitleSpinner.isInitialized) customTitleSpinner.category_title.text.toString() return if (::customTitleSpinner.isInitialized) customTitleSpinner.category_title.text.toString()
else super.getTitle() else super.getTitle()
// when { // when {
@ -81,7 +108,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
// spinnerAdapter?.array?.size == 1 -> return spinnerAdapter?.array?.firstOrNull() // spinnerAdapter?.array?.size == 1 -> return spinnerAdapter?.array?.firstOrNull()
// else -> return super.getTitle() // else -> return super.getTitle()
// } // }
}*/ }
private var scrollListener = object : RecyclerView.OnScrollListener () { private var scrollListener = object : RecyclerView.OnScrollListener () {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
@ -115,6 +142,160 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
} }
} }
override fun onTouchEvent(event: MotionEvent?) {
if (event == null) {
resetScrollingValues()
resetRecyclerY()
return
}
if (flinging) return
if (isDragging) {
resetScrollingValues()
resetRecyclerY(false)
return
}
val sheetRect = Rect()
val recyclerRect = Rect()
bottom_sheet.getGlobalVisibleRect(sheetRect)
view?.getGlobalVisibleRect(recyclerRect)
if (startPosX == null) {
startPosX = event.rawX
startPosY = event.rawY
val position =
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
val order = when (val item = adapter.getItem(position)) {
is LibraryHeaderItem -> item.category.order
is LibraryItem -> presenter.categories.find { it.id == item.manga.category }?.order
else -> null
}
if (order != null) {
ogCategory = order
var newOffsetN = order + 1
while (adapter.indexOf(newOffsetN) == -1 && presenter.categories.any { it.order == newOffsetN }) {
newOffsetN += 1
}
if (adapter.indexOf(newOffsetN) != -1)
nextCategory = newOffsetN
if (position == 0) prevCategory = null
else {
var newOffsetP = order - 1
while (adapter.indexOf(newOffsetP) == -1 && presenter.categories.any { it.order == newOffsetP }) {
newOffsetP -= 1
}
if (adapter.indexOf(newOffsetP) != -1)
prevCategory = newOffsetP
}
}
return
}
if (event.actionMasked == MotionEvent.ACTION_UP) {
recycler_layout.post {
if (!flinging) {
resetScrollingValues()
resetRecyclerY(true)
}
}
return
}
if (startPosX != null && startPosY != null &&
(sheetRect.contains(startPosX!!.toInt(), startPosY!!.toInt()) ||
!recyclerRect.contains(startPosX!!.toInt(), startPosY!!.toInt()))) {
return
}
if (event.actionMasked != MotionEvent.ACTION_UP && startPosX != null) {
val distance = abs(event.rawX - startPosX!!)
val sign = sign(event.rawX - startPosX!!)
if (lockedY) return
if (distance > 60 && abs(event.rawY - startPosY!!) <= 30 &&
!lockedRecycler) {
lockedRecycler = true
switchingCategories = true
recycler.suppressLayout(true)
}
else if (!lockedRecycler && abs(event.rawY - startPosY!!) > 30) {
lockedY = true
resetRecyclerY()
return
}
if (abs(event.rawY - startPosY!!) <= 30 || recycler.isLayoutSuppressed
|| lockedRecycler) {
if ((prevCategory == null && sign > 0) || (nextCategory == null && sign < 0)) {
recycler_layout.x = sign * distance.pow(0.6f)
recycler_layout.alpha = 1f
}
else if (distance <= swipeDistance * 1.1f) {
recycler_layout.x = (max(0f, distance - 50f) * sign) / 3
recycler_layout.alpha =
(1f - (distance - (swipeDistance * 0.1f)) / swipeDistance)
if (moved) {
scrollToHeader(ogCategory ?: -1)
moved = false
}
} else {
if (!moved) {
scrollToHeader((if (sign <= 0) nextCategory else prevCategory) ?: -1)
moved = true
}
recycler_layout.x = ((distance - swipeDistance * 2) * sign) / 3
recycler_layout.alpha = ((distance - swipeDistance * 1.1f) / swipeDistance)
if (sign > 0) {
recycler_layout.x = min(0f, recycler_layout.x)
} else {
recycler_layout.x = max(0f, recycler_layout.x)
}
recycler_layout.alpha = min(1f, recycler_layout.alpha)
}
}
}
}
private fun resetScrollingValues() {
startPosX = null
startPosY = null
nextCategory = null
prevCategory = null
ogCategory = null
lockedY = false
}
private fun resetRecyclerY(animated: Boolean = false, time: Long = 100) {
moved = false
lockedRecycler = false
if (animated) {
val set = AnimatorSet()
val translationXAnimator = ValueAnimator.ofFloat(recycler_layout.x, 0f)
translationXAnimator.duration = time
translationXAnimator.addUpdateListener {
animation -> recycler_layout.x = animation.animatedValue as Float
}
val translationAlphaAnimator = ValueAnimator.ofFloat(recycler_layout.alpha, 1f)
translationAlphaAnimator.duration = time
translationAlphaAnimator.addUpdateListener {
animation -> recycler_layout.alpha = animation.animatedValue as Float
}
set.playTogether(translationXAnimator, translationAlphaAnimator)
set.start()
launchUI {
delay(time)
if (!lockedRecycler) switchingCategories = false
}
}
else {
recycler_layout.x = 0f
recycler_layout.alpha = 1f
switchingCategories = false
}
recycler.suppressLayout(false)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.library_list_controller, container, false) return inflater.inflate(R.layout.library_list_controller, container, false)
} }
@ -133,14 +314,13 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
}) })
recycler.setHasFixedSize(true) recycler.setHasFixedSize(true)
recycler.adapter = adapter recycler.adapter = adapter
adapter.fastScroller = fast_scroller //adapter.fastScroller = fast_scroller
recycler.addOnScrollListener(scrollListener) recycler.addOnScrollListener(scrollListener)
val tv = TypedValue() val tv = TypedValue()
activity!!.theme.resolveAttribute(R.attr.actionBarTintColor, tv, true) activity!!.theme.resolveAttribute(R.attr.actionBarTintColor, tv, true)
customTitleSpinner = library_layout.inflate(R.layout.spinner_title) as ViewGroup customTitleSpinner = library_layout.inflate(R.layout.spinner_title) as ViewGroup
// (activity as MainActivity).supportActionBar?.setDisplayShowCustomEnabled(false)
spinnerAdapter = SpinnerAdapter( spinnerAdapter = SpinnerAdapter(
view.context, view.context,
R.layout.library_spinner_textview, R.layout.library_spinner_textview,
@ -155,7 +335,6 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
scrollToHeader(item.itemId) scrollToHeader(item.itemId)
true true
} }
//(activity as MainActivity).supportActionBar?.customView = customTitleSpinner
scrollViewWith(recycler) { insets -> scrollViewWith(recycler) { insets ->
fast_scroller.updateLayoutParams<CoordinatorLayout.LayoutParams> { fast_scroller.updateLayoutParams<CoordinatorLayout.LayoutParams> {
topMargin = insets.systemWindowInsetTop topMargin = insets.systemWindowInsetTop
@ -172,21 +351,13 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
activity?.toolbar?.removeSpinner() activity?.toolbar?.removeSpinner()
} }
} }
/*if (type.isEnter) {
(activity as MainActivity).supportActionBar
?.setDisplayShowCustomEnabled(router?.backstack?.lastOrNull()?.controller() ==
this && spinnerAdapter?.array?.size ?: 0 > 1)
}
else if (type == ControllerChangeType.PUSH_EXIT) {
(activity as MainActivity).toolbar.menu.findItem(R.id
.action_search)?.collapseActionView()
(activity as MainActivity).supportActionBar?.setDisplayShowCustomEnabled(false)
}*/
} }
override fun onDestroy() { override fun onActivityResumed(activity: Activity) {
// (activity as MainActivity).supportActionBar?.setDisplayShowCustomEnabled(false) super.onActivityResumed(activity)
super.onDestroy() if (view == null) return
resetScrollingValues()
resetRecyclerY()
} }
override fun onNextLibraryUpdate(mangaMap: List<LibraryItem>, freshStart: Boolean) { override fun onNextLibraryUpdate(mangaMap: List<LibraryItem>, freshStart: Boolean) {
@ -211,14 +382,12 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
val isCurrentController = router?.backstack?.lastOrNull()?.controller() == val isCurrentController = router?.backstack?.lastOrNull()?.controller() ==
this this
// (activity as AppCompatActivity).supportActionBar
// ?.setDisplayShowCustomEnabled(isCurrentController && presenter.categories.size > 1)
customTitleSpinner.category_title.text = /*customTitleSpinner.category_title.text =
presenter.categories[clamp(activeCategory, presenter.categories[clamp(activeCategory,
0, 0,
presenter.categories.size - 1)].name presenter.categories.size - 1)].name
if (isCurrentController) setTitle() if (isCurrentController) setTitle()*/
updateScroll = false updateScroll = false
if (!freshStart) { if (!freshStart) {
justStarted = false justStarted = false
@ -258,15 +427,35 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
} }
} }
private fun scrollToHeader(pos: Int, fade:Boolean = false) { private fun scrollToHeader(pos: Int) {
val headerPosition = adapter.indexOf(pos) val headerPosition = adapter.indexOf(pos)
switchingCategories = true switchingCategories = true
if (headerPosition > -1) { if (headerPosition > -1) {
activity?.appbar?.y = 0f val appbar = activity?.appbar
//if (headerPosition == 0)
//activity?.appbar?.y = 0f
recycler.suppressLayout(true) recycler.suppressLayout(true)
val appbarOffset =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (appbar?.y ?: 0f > -20) 0 else
(appbar?.y?.plus(view?.rootWindowInsets?.systemWindowInsetTop ?: 0)
?: 0f).roundToInt() + 10.dpToPx
}
else {
0
}
(recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( (recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
headerPosition, if (headerPosition == 0) 0 else (-30).dpToPx headerPosition, (if (headerPosition == 0) 0 else (-28).dpToPx)
+ appbarOffset
) )
val isCurrentController = router?.backstack?.lastOrNull()?.controller() ==
this
val headerItem = adapter.getItem(headerPosition) as? LibraryHeaderItem
if (headerItem != null) {
customTitleSpinner.category_title.text = headerItem.category.name
if (isCurrentController) setTitle()
}
recycler.suppressLayout(false) recycler.suppressLayout(false)
} }
launchUI { launchUI {
@ -349,6 +538,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
} }
override fun startReading(position: Int) { override fun startReading(position: Int) {
if (recyclerIsScrolling()) return
if (adapter.mode == SelectableAdapter.Mode.MULTI) { if (adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position) toggleSelection(position)
return return
@ -381,7 +571,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
* @return true if the item should be selected, false otherwise. * @return true if the item should be selected, false otherwise.
*/ */
override fun onItemClick(view: View?, position: Int): Boolean { override fun onItemClick(view: View?, position: Int): Boolean {
if (switchingCategories) return false if (recyclerIsScrolling()) return false
val item = adapter.getItem(position) as? LibraryItem ?: return false val item = adapter.getItem(position) as? LibraryItem ?: return false
return if (adapter.mode == SelectableAdapter.Mode.MULTI) { return if (adapter.mode == SelectableAdapter.Mode.MULTI) {
lastClickPosition = position lastClickPosition = position
@ -399,6 +589,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
* @param position the position of the element clicked. * @param position the position of the element clicked.
*/ */
override fun onItemLongClick(position: Int) { override fun onItemLongClick(position: Int) {
if (recyclerIsScrolling()) return
createActionModeIfNeeded() createActionModeIfNeeded()
when { when {
lastClickPosition == -1 -> setSelection(position) lastClickPosition == -1 -> setSelection(position)
@ -414,6 +605,8 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
override fun onActionStateChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { override fun onActionStateChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
val position = viewHolder?.adapterPosition ?: return val position = viewHolder?.adapterPosition ?: return
if (actionState == 2) { if (actionState == 2) {
isDragging = true
activity?.appbar?.y = 0f
if (lastItemPosition != null && position != lastItemPosition if (lastItemPosition != null && position != lastItemPosition
&& lastItem == adapter.getItem(position)) { && lastItem == adapter.getItem(position)) {
// because for whatever reason you can repeatedly tap on a currently dragging manga // because for whatever reason you can repeatedly tap on a currently dragging manga
@ -441,13 +634,31 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
invalidateActionMode() invalidateActionMode()
} }
override fun onItemMove(fromPosition: Int, toPosition: Int) { override fun onItemMove(fromPosition: Int, toPosition: Int) {
// Because padding a recycler causes it to scroll up we have to scroll it back down... wild
if ((adapter.getItem(fromPosition) is LibraryItem &&
adapter.getItem(fromPosition) is LibraryItem) ||
adapter.getItem(fromPosition) == null)
recycler.scrollBy(0, recycler.paddingTop)
activity?.appbar?.y = 0f
if (lastItemPosition == toPosition) if (lastItemPosition == toPosition)
lastItemPosition = null lastItemPosition = null
else if (lastItemPosition == null) else if (lastItemPosition == null)
lastItemPosition = fromPosition lastItemPosition = fromPosition
} }
override fun shouldMoveItem(fromPosition: Int, toPosition: Int): Boolean {
if (adapter.isSelected(fromPosition))
toggleSelection(fromPosition)
val item = adapter.getItem(fromPosition) as? LibraryItem ?: return false
val newHeader = adapter.getSectionHeader(toPosition) as? LibraryHeaderItem
if (toPosition <= 1) return false
return (adapter.getItem(toPosition) !is LibraryHeaderItem)&&
(newHeader?.category?.id == item.manga.category ||
!presenter.mangaIsInCategory(item.manga, newHeader?.category?.id))
}
override fun onItemReleased(position: Int) { override fun onItemReleased(position: Int) {
isDragging = false
if (adapter.selectedItemCount > 0) { if (adapter.selectedItemCount > 0) {
lastItemPosition = null lastItemPosition = null
return return
@ -508,18 +719,6 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
lastItemPosition = null lastItemPosition = null
} }
override fun shouldMoveItem(fromPosition: Int, toPosition: Int): Boolean {
//if (adapter.selectedItemCount > 1)
// return false
if (adapter.isSelected(fromPosition))
toggleSelection(fromPosition)
val item = adapter.getItem(fromPosition) as? LibraryItem ?: return false
val newHeader = adapter.getSectionHeader(toPosition) as? LibraryHeaderItem
//if (adapter.getItem(toPosition) is LibraryHeaderItem) return false
return newHeader?.category?.id == item.manga.category ||
!presenter.mangaIsInCategory(item.manga, newHeader?.category?.id)
}
override fun updateCategory(catId: Int): Boolean { override fun updateCategory(catId: Int): Boolean {
val category = (adapter.getItem(catId) as? LibraryHeaderItem)?.category ?: val category = (adapter.getItem(catId) as? LibraryHeaderItem)?.category ?:
return false return false
@ -582,35 +781,53 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
if (sheetRect.contains(x.toInt(), y.toInt())) if (sheetRect.contains(x.toInt(), y.toInt()))
showFiltersBottomSheet() showFiltersBottomSheet()
} }
override fun onSwipeLeft(x: Float, y: Float) = goToNextCategory(x, y,-1) override fun onSwipeLeft(x: Float, y: Float) = goToNextCategory(x)
override fun onSwipeRight(x: Float, y: Float) = goToNextCategory(x, y,1) override fun onSwipeRight(x: Float, y: Float) = goToNextCategory(x)
private fun goToNextCategory(x: Float, y: Float, offset: Int) { private fun goToNextCategory(x: Float) {
val sheetRect = Rect() if (lockedRecycler && abs(x) > 1000f) {
val recyclerRect = Rect() val sign = sign(x).roundToInt()
bottom_sheet.getGlobalVisibleRect(sheetRect) if ((sign < 0 && nextCategory == null) || (sign > 0) && prevCategory == null)
recycler.getGlobalVisibleRect(recyclerRect)
if (sheetRect.contains(x.toInt(), y.toInt()) ||
!recyclerRect.contains(x.toInt(), y.toInt())) {
return return
val distance = recycler_layout.alpha
val speed = max(3000f / abs(x), 0.75f)
Timber.d("Flinged $distance, velo ${abs(x)}, speed $speed")
if (sign(recycler_layout.x) == sign(x)) {
flinging = true
val duration = (distance * 100 * speed).toLong()
val set = AnimatorSet()
val translationXAnimator = ValueAnimator.ofFloat(recycler_layout.x, sign * 100f)
translationXAnimator.duration = duration
translationXAnimator.addUpdateListener { animation ->
recycler_layout.x = animation.animatedValue as Float
} }
val position =
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() val translationAlphaAnimator = ValueAnimator.ofFloat(recycler_layout.alpha, 0f)
val order = when (val item = adapter.getItem(position)) { translationAlphaAnimator.duration = duration
is LibraryHeaderItem -> item.category.order translationAlphaAnimator.addUpdateListener { animation ->
is LibraryItem -> presenter.categories.find { it.id == item.manga.category }?.order recycler_layout.alpha = animation.animatedValue as Float
?.plus(if (offset < 0) 1 else 0)
else -> null
} }
if (order != null) { set.playTogether(translationXAnimator, translationAlphaAnimator)
var newOffset = order + offset set.start()
while (adapter.indexOf(newOffset) == -1 && presenter.categories.any { it.order == newOffset }) { set.addListener(object : Animator.AnimatorListener {
newOffset += offset override fun onAnimationEnd(animation: Animator?) {
recycler_layout.x = -sign * 100f
recycler_layout.alpha = 0f
scrollToHeader((if (sign <= 0) nextCategory else prevCategory) ?: -1)
resetScrollingValues()
resetRecyclerY(true, (100 * speed).toLong())
flinging = false
}
override fun onAnimationCancel(animation: Animator?) {}
override fun onAnimationRepeat(animation: Animator?) {}
override fun onAnimationStart(animation: Animator?) {}
})
} }
scrollToHeader (newOffset, true)
} }
} }
override fun popUpMenu(): PopupMenu = titlePopupMenu override fun recyclerIsScrolling() = switchingCategories || lockedRecycler || lockedY
} }

View File

@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet
import eu.kanade.tachiyomi.ui.migration.MigrationFlags import eu.kanade.tachiyomi.ui.migration.MigrationFlags
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.lang.removeArticles import eu.kanade.tachiyomi.util.lang.removeArticles
@ -158,6 +159,8 @@ class LibraryPresenter(
val filterMangaType by lazy { preferences.filterMangaType().getOrDefault() } val filterMangaType by lazy { preferences.filterMangaType().getOrDefault() }
val filterTrackers = FilterBottomSheet.FILTER_TRACKER
val filterFn: (LibraryItem) -> Boolean = f@ { item -> val filterFn: (LibraryItem) -> Boolean = f@ { item ->
// Filter when there isn't unread chapters. // Filter when there isn't unread chapters.
if (filterUnread == STATE_INCLUDE && if (filterUnread == STATE_INCLUDE &&
@ -184,11 +187,18 @@ class LibraryPresenter(
if (filterTracked != STATE_IGNORE) { if (filterTracked != STATE_IGNORE) {
val tracks = db.getTracks(item.manga).executeAsBlocking() val tracks = db.getTracks(item.manga).executeAsBlocking()
val trackCount = loggedServices.count { service -> val trackCount = loggedServices.any { service ->
tracks.any { it.sync_id == service.id } tracks.any { it.sync_id == service.id }
} }
if (filterTracked == STATE_INCLUDE && trackCount == 0) return@f false if (filterTracked == STATE_INCLUDE && !trackCount) return@f false
if (filterTracked == STATE_EXCLUDE && trackCount > 0) return@f false if (filterTracked == STATE_EXCLUDE && trackCount) return@f false
if (filterTrackers.isNotEmpty()) {
val service = loggedServices.find { it.name == filterTrackers }
if (service != null) {
if (tracks.none { it.sync_id == service.id }) return@f false
}
}
} }
// Filter when there are no downloads. // Filter when there are no downloads.
if (filterDownloaded != STATE_IGNORE) { if (filterDownloaded != STATE_IGNORE) {

View File

@ -33,7 +33,7 @@ import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: LinearLayout(context, attrs), : LinearLayout(context, attrs),
FilterTagGroupListener { FilterTagGroupListener {
@ -50,7 +50,7 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
private lateinit var tracked: FilterTagGroup private lateinit var tracked: FilterTagGroup
// private lateinit var categories: FilterTagGroup private var trackers: FilterTagGroup? = null
private var mangaType: FilterTagGroup? = null private var mangaType: FilterTagGroup? = null
@ -115,9 +115,6 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
else else
shadow.alpha = 1f shadow.alpha = 1f
pager?.updatePaddingRelative(bottom = sheetBehavior?.peekHeight ?: 0) pager?.updatePaddingRelative(bottom = sheetBehavior?.peekHeight ?: 0)
// snackbarLayout.updatePaddingRelative(bottom = sheetBehavior?.peekHeight ?: 0)
if (!phoneLandscape)
preferences.hideFiltersAtStart().set(false)
} }
if (state == BottomSheetBehavior.STATE_EXPANDED) { if (state == BottomSheetBehavior.STATE_EXPANDED) {
top_bar.alpha = 0f top_bar.alpha = 0f
@ -129,8 +126,6 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
shadow.alpha = 0f shadow.alpha = 0f
pager?.updatePaddingRelative(bottom = 0) pager?.updatePaddingRelative(bottom = 0)
// snackbarLayout.updatePaddingRelative(bottom = 0) // snackbarLayout.updatePaddingRelative(bottom = 0)
if (!phoneLandscape)
preferences.hideFiltersAtStart().set(true)
} }
//top_bar.isClickable = state == BottomSheetBehavior.STATE_COLLAPSED //top_bar.isClickable = state == BottomSheetBehavior.STATE_COLLAPSED
//top_bar.isFocusable = state == BottomSheetBehavior.STATE_COLLAPSED //top_bar.isFocusable = state == BottomSheetBehavior.STATE_COLLAPSED
@ -147,6 +142,12 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
if (phoneLandscape && shadow2.visibility != View.GONE) { if (phoneLandscape && shadow2.visibility != View.GONE) {
shadow2.gone() shadow2.gone()
} }
hide_filters.isChecked = preferences.hideFiltersAtStart().getOrDefault()
hide_filters.setOnCheckedChangeListener { _, isChecked ->
preferences.hideFiltersAtStart().set(isChecked)
if (isChecked)
onGroupClicked(ACTION_HIDE_FILTER_TIP)
}
createTags() createTags()
clearButton.setOnClickListener { clearFilters() } clearButton.setOnClickListener { clearFilters() }
} }
@ -222,17 +223,19 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
launchUI { launchUI {
val mangaType = inflate(R.layout.filter_buttons) as FilterTagGroup val mangaType = inflate(R.layout.filter_buttons) as FilterTagGroup
mangaType.setup( mangaType.setup(
this@SortFilterBottomSheet, this@FilterBottomSheet,
types.first(), types.first(),
types.getOrNull(1), types.getOrNull(1),
types.getOrNull(2) types.getOrNull(2)
) )
this@SortFilterBottomSheet.mangaType = mangaType this@FilterBottomSheet.mangaType = mangaType
filter_layout.addView(mangaType) filter_layout.addView(mangaType)
filterItems.remove(tracked)
filterItems.add(mangaType) filterItems.add(mangaType)
filterItems.add(tracked)
} }
} }
launchUI { withContext(Dispatchers.Main) {
hide_categories.visibleIf(showCategoriesCheckBox) hide_categories.visibleIf(showCategoriesCheckBox)
// categories.setState(preferences.hideCategories().getOrDefault()) // categories.setState(preferences.hideCategories().getOrDefault())
downloaded.setState(preferences.filterDownloaded()) downloaded.setState(preferences.filterDownloaded())
@ -243,11 +246,34 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
reSortViews() reSortViews()
} }
if (filterItems.contains(tracked)) {
val loggedServices = Injekt.get<TrackManager>().services.filter { it.isLogged }
if (loggedServices.size > 1) {
val serviceNames = loggedServices.map { it.name }
withContext(Dispatchers.Main) {
trackers = inflate(R.layout.filter_buttons) as FilterTagGroup
trackers?.setup(
this@FilterBottomSheet,
serviceNames.first(),
serviceNames.getOrNull(1),
serviceNames.getOrNull(2)
)
if (tracked.isActivated) {
filter_layout.addView(trackers)
filterItems.add(trackers!!)
}
}
}
}
} }
} }
override fun onFilterClicked(view: FilterTagGroup, index: Int, updatePreference:Boolean) { override fun onFilterClicked(view: FilterTagGroup, index: Int, updatePreference:Boolean) {
if (updatePreference) { if (updatePreference) {
if (view == trackers) {
FILTER_TRACKER = view.nameOf(index) ?: ""
} else {
when (view) { when (view) {
downloaded -> preferences.filterDownloaded() downloaded -> preferences.filterDownloaded()
unread -> preferences.filterUnread() unread -> preferences.filterUnread()
@ -256,8 +282,19 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
mangaType -> preferences.filterMangaType() mangaType -> preferences.filterMangaType()
else -> null else -> null
}?.set(index + 1) }?.set(index + 1)
}
onGroupClicked(ACTION_FILTER) onGroupClicked(ACTION_FILTER)
} }
if (preferences.filterTracked().getOrDefault() == 1 &&
trackers != null && trackers?.parent == null) {
filter_layout.addView(trackers)
filterItems.add(trackers!!)
}
else if (preferences.filterTracked().getOrDefault() != 1 &&
trackers?.parent != null) {
filter_layout.removeView(trackers)
filterItems.remove(trackers!!)
}
val hasFilters = hasActiveFilters() val hasFilters = hasActiveFilters()
if (hasFilters && clearButton.parent == null) if (hasFilters && clearButton.parent == null)
filter_layout.addView(clearButton, 0) filter_layout.addView(clearButton, 0)
@ -275,6 +312,7 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
preferences.filterCompleted().set(0) preferences.filterCompleted().set(0)
preferences.filterTracked().set(0) preferences.filterTracked().set(0)
preferences.filterMangaType().set(0) preferences.filterMangaType().set(0)
FILTER_TRACKER = ""
val transition = androidx.transition.AutoTransition() val transition = androidx.transition.AutoTransition()
transition.duration = 150 transition.duration = 150
@ -305,11 +343,9 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
companion object { companion object {
const val ACTION_REFRESH = 0 const val ACTION_REFRESH = 0
const val ACTION_SORT = 1 const val ACTION_FILTER = 1
const val ACTION_FILTER = 2 const val ACTION_HIDE_FILTER_TIP = 2
const val ACTION_DISPLAY = 3 var FILTER_TRACKER = ""
const val ACTION_DOWNLOAD_BADGE = 4 private set
const val ACTION_UNREAD_BADGE = 5
const val ACTION_CAT_SORT = 6
} }
} }

View File

@ -30,6 +30,8 @@ class FilterTagGroup@JvmOverloads constructor(context: Context, attrs: Attribute
return buttons.any { it.isActivated } return buttons.any { it.isActivated }
} }
fun nameOf(index: Int):String? = buttons.getOrNull(index)?.text as? String
fun setup(root: ViewGroup, firstText: Int, secondText: Int? = null, thirdText: Int? = null) { fun setup(root: ViewGroup, firstText: Int, secondText: Int? = null, thirdText: Int? = null) {
val text1 = context.getString(firstText) val text1 = context.getString(firstText)
val text2 = if (secondText != null) context.getString(secondText) else null val text2 = if (secondText != null) context.getString(secondText) else null

View File

@ -11,6 +11,7 @@ import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.view.GestureDetector import android.view.GestureDetector
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent import android.view.MotionEvent
@ -20,7 +21,6 @@ import android.view.WindowManager
import android.webkit.WebView import android.webkit.WebView
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
@ -300,7 +300,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR) .SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && currentNightMode == Configuration if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && currentNightMode == Configuration
.UI_MODE_NIGHT_NO && preferences.theme() >= 8) .UI_MODE_NIGHT_NO)
content.systemUiVisibility = content.systemUiVisibility.or(View content.systemUiVisibility = content.systemUiVisibility.or(View
.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR) .SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)
@ -383,7 +383,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
return super.startSupportActionMode(callback) return super.startSupportActionMode(callback)
} }
/* override fun onSupportActionModeFinished(mode: androidx.appcompat.view.ActionMode) { override fun onSupportActionModeFinished(mode: androidx.appcompat.view.ActionMode) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) launchUI { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) launchUI {
val scale = Settings.Global.getFloat( val scale = Settings.Global.getFloat(
contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f
@ -391,10 +391,12 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
val duration = resources.getInteger(android.R.integer.config_mediumAnimTime) * scale val duration = resources.getInteger(android.R.integer.config_mediumAnimTime) * scale
delay(duration.toLong()) delay(duration.toLong())
delay(100) delay(100)
window?.statusBarColor = getResourceColor(android.R.attr.statusBarColor) if (Color.alpha(window?.statusBarColor ?: Color.BLACK) >= 255)
window?.statusBarColor = ColorUtils.setAlphaComponent(getResourceColor(android.R.attr
.colorBackground), 175)
} }
super.onSupportActionModeFinished(mode) super.onSupportActionModeFinished(mode)
}*/ }
private fun setExtensionsBadge() { private fun setExtensionsBadge() {
val updates = preferences.extensionUpdatesCount().getOrDefault() val updates = preferences.extensionUpdatesCount().getOrDefault()
@ -422,7 +424,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
val preferences: PreferencesHelper by injectLazy() val preferences: PreferencesHelper by injectLazy()
try { try {
val pendingUpdates = ExtensionGithubApi().checkforUpdates(this@MainActivity) val pendingUpdates = ExtensionGithubApi().checkForUpdates(this@MainActivity)
preferences.extensionUpdatesCount().set(pendingUpdates.size) preferences.extensionUpdatesCount().set(pendingUpdates.size)
preferences.lastExtCheck().set(Date().time) preferences.lastExtCheck().set(Date().time)
} catch (e: java.lang.Exception) { } } catch (e: java.lang.Exception) { }
@ -553,6 +555,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
gestureDetector.onTouchEvent(ev) gestureDetector.onTouchEvent(ev)
val controller = router.backstack.lastOrNull()?.controller()
if (controller is OnTouchEventInterface)
controller.onTouchEvent(ev)
if (ev?.action == MotionEvent.ACTION_DOWN) { if (ev?.action == MotionEvent.ACTION_DOWN) {
if (snackBar != null && snackBar!!.isShown) { if (snackBar != null && snackBar!!.isShown) {
val sRect = Rect() val sRect = Rect()
@ -654,7 +659,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
} }
override fun downloadStatusChanged(downloading: Boolean) { override fun downloadStatusChanged(downloading: Boolean) {
val downloadManager = Injekt.get<DownloadManager>() /*val downloadManager = Injekt.get<DownloadManager>()
val hasQueue = downloading || downloadManager.hasQueue() val hasQueue = downloading || downloadManager.hasQueue()
launchUI { launchUI {
if (hasQueue) { if (hasQueue) {
@ -664,7 +669,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
} else { } else {
navigationView?.removeBadge(R.id.nav_library) navigationView?.removeBadge(R.id.nav_library)
} }
} }*/
} }
@ -687,9 +692,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
&& abs(diffY) <= Companion.SWIPE_THRESHOLD * 0.75f && abs(diffY) <= Companion.SWIPE_THRESHOLD * 0.75f
) { ) {
if (diffX > 0) { if (diffX > 0) {
currentGestureDelegate?.onSwipeRight(e1.x, e1.y) currentGestureDelegate?.onSwipeRight(velocityX, e1.y)
} else { } else {
currentGestureDelegate?.onSwipeLeft(e1.x, e1.y) currentGestureDelegate?.onSwipeLeft(velocityX, e1.y)
} }
result = true result = true
} }
@ -738,9 +743,10 @@ interface BottomNavBarInterface {
} }
interface RootSearchInterface interface RootSearchInterface
interface SpinnerTitleInterface
interface SpinnerTitleInterface { interface OnTouchEventInterface {
fun popUpMenu(): PopupMenu fun onTouchEvent(event: MotionEvent?)
} }
interface SwipeGestureInterface { interface SwipeGestureInterface {

View File

@ -47,6 +47,8 @@ class SearchActivity: MainActivity() {
toolbar.navigationIcon = drawerArrow toolbar.navigationIcon = drawerArrow
drawerArrow?.progress = 1f drawerArrow?.progress = 1f
if (to !is SpinnerTitleInterface) toolbar.removeSpinner()
if (to is NoToolbarElevationController) { if (to is NoToolbarElevationController) {
appbar.disableElevation() appbar.disableElevation()
} else { } else {

View File

@ -6,17 +6,12 @@ import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.afollestad.materialdialogs.list.listItemsSingleChoice
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController
/** /**
* Dialog to choose a shape for the icon. * Dialog to choose a shape for the icon.
*/ */
class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) { class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) {
constructor(target: MangaInfoController) : this() {
targetController = target
}
constructor(target: MangaDetailsController) : this() { constructor(target: MangaDetailsController) : this() {
targetController = target targetController = target
} }
@ -35,7 +30,6 @@ class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) {
items = modes.map { activity?.getString(it) as CharSequence }, items = modes.map { activity?.getString(it) as CharSequence },
waitForPositiveButton = false) waitForPositiveButton = false)
{ _, i, _ -> { _, i, _ ->
(targetController as? MangaInfoController)?.createShortcutForShape(i)
(targetController as? MangaDetailsController)?.createShortcutForShape(i) (targetController as? MangaDetailsController)?.createShortcutForShape(i)
dismissDialog() dismissDialog()
} }

View File

@ -1,273 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
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.google.android.material.tabs.TabLayout
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.notification.NotificationReceiver
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.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.main.BottomNavBarInterface
import eu.kanade.tachiyomi.ui.main.SearchActivity
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.ui.migration.manga.process.MigrationListController
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.applyWindowInsetsForController
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, BottomNavBarInterface {
constructor(manga: Manga?,
fromCatalogue: Boolean = false,
smartSearchConfig: CatalogueController.SmartSearchConfig? = null,
update: Boolean = false) : super(Bundle().apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue)
putParcelable(SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig)
putBoolean(UPDATE_EXTRA, update)
}) {
this.manga = manga
if (manga != null) {
source = Injekt.get<SourceManager>().getOrStub(manga.source)
}
}
constructor(manga: Manga?, fromCatalogue: Boolean = false, fromExtension: 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(manga: Manga?, startY:Float?) : super(Bundle().apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
putBoolean(FROM_CATALOGUE_EXTRA, false)
}) {
this.manga = manga
startingChapterYPos = startY
if (manga != null) {
source = Injekt.get<SourceManager>().getOrStub(manga.source)
}
}
constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking())
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) {
val notificationId = bundle.getInt("notificationId", -1)
val context = applicationContext ?: return
if (notificationId > -1) NotificationReceiver.dismissNotification(
context, notificationId, bundle.getInt("groupId", 0)
)
}
var manga: Manga? = null
private set
var source: Source? = null
private set
var startingChapterYPos:Float? = null
var isLockedFromSearch = false
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?.currentTitle()
}
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)
view.applyWindowInsetsForController()
if (manga == null || source == null) return
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
adapter = MangaDetailAdapter()
manga_pager.offscreenPageLimit = 3
manga_pager.adapter = adapter
isLockedFromSearch = activity is SearchActivity &&
SecureActivityDelegate.shouldBeLocked()
if (!fromCatalogue)
manga_pager.currentItem = CHAPTERS_CONTROLLER
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
adapter = null
}
override fun onActivityResumed(activity: Activity) {
super.onActivityResumed(activity)
isLockedFromSearch = activity is SearchActivity &&
SecureActivityDelegate.shouldBeLocked()
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
tabLayout()?.setupWithViewPager(manga_pager)
checkInitialTrackState()
trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) }
}
}
private fun checkInitialTrackState() {
val manga = manga ?: return
val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
val db = Injekt.get<DatabaseHelper>()
val tracks = db.getTracks(manga).executeAsBlocking()
if (loggedServices.any { service -> tracks.any { it.sync_id == service.id } }) {
setTrackingIcon(true)
}
}
fun tabLayout():TabLayout? {
return null
}
fun updateTitle(manga: Manga) {
this.manga?.title = manga.title
setTitle()
}
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 = tabLayout()?.getTabAt(TRACK_CONTROLLER) ?: return
val drawable = if (visible)
VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null)
else null
//tab.icon = drawable
}
override fun canChangeTabs(block: () -> Unit): Boolean {
val migrationListController = router.getControllerWithTag(MigrationListController.TAG)
as? BottomNavBarInterface
if (migrationListController != null) return migrationListController.canChangeTabs(block)
return true
}
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) {
val touchOffset = if (tabLayout()?.height == 0) 144f else 0f
if (!router.hasRootController()) {
val controller = when (position) {
INFO_CONTROLLER -> MangaInfoController()
CHAPTERS_CONTROLLER -> ChaptersController(startingChapterYPos?.minus(touchOffset))
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 UPDATE_EXTRA = "update"
const val SMART_SEARCH_CONFIG_EXTRA = "smartSearchConfig"
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
}
}

View File

@ -27,6 +27,9 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
@ -51,6 +54,7 @@ import com.bumptech.glide.signature.ObjectKey
import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
@ -61,23 +65,24 @@ import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.glide.GlideApp import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.SearchActivity import eu.kanade.tachiyomi.ui.main.SearchActivity
import eu.kanade.tachiyomi.ui.manga.MangaController.Companion.FROM_CATALOGUE_EXTRA
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterMatHolder import eu.kanade.tachiyomi.ui.manga.chapter.ChapterMatHolder
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersAdapter import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersAdapter
import eu.kanade.tachiyomi.ui.manga.chapter.DownloadCustomChaptersDialog
import eu.kanade.tachiyomi.ui.manga.info.EditMangaDialog import eu.kanade.tachiyomi.ui.manga.info.EditMangaDialog
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
@ -100,22 +105,22 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
class MangaDetailsController : BaseController, open class MangaDetailsController : BaseController,
FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener, FlexibleAdapter.OnItemLongClickListener,
ActionMode.Callback,
ChaptersAdapter.MangaHeaderInterface, ChaptersAdapter.MangaHeaderInterface,
ChangeMangaCategoriesDialog.Listener, ChangeMangaCategoriesDialog.Listener,
DownloadCustomChaptersDialog.Listener,
NoToolbarElevationController { NoToolbarElevationController {
constructor(manga: Manga?, constructor(manga: Manga?,
fromCatalogue: Boolean = false, fromCatalogue: Boolean = false,
smartSearchConfig: CatalogueController.SmartSearchConfig? = null, smartSearchConfig: CatalogueController.SmartSearchConfig? = null,
update: Boolean = false) : super(Bundle().apply { update: Boolean = false) : super(Bundle().apply {
putLong(MangaController.MANGA_EXTRA, manga?.id ?: 0) putLong(MANGA_EXTRA, manga?.id ?: 0)
putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue) putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue)
putParcelable(MangaController.SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig) putParcelable(SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig)
putBoolean(MangaController.UPDATE_EXTRA, update) putBoolean(UPDATE_EXTRA, update)
}) { }) {
this.manga = manga this.manga = manga
if (manga != null) { if (manga != null) {
@ -126,7 +131,7 @@ class MangaDetailsController : BaseController,
constructor(mangaId: Long) : this( constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking()) Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking())
constructor(bundle: Bundle) : this(bundle.getLong(MangaController.MANGA_EXTRA)) { constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) {
val notificationId = bundle.getInt("notificationId", -1) val notificationId = bundle.getInt("notificationId", -1)
val context = applicationContext ?: return val context = applicationContext ?: return
if (notificationId > -1) NotificationReceiver.dismissNotification( if (notificationId > -1) NotificationReceiver.dismissNotification(
@ -143,11 +148,19 @@ class MangaDetailsController : BaseController,
private var snack: Snackbar? = null private var snack: Snackbar? = null
val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false) val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false)
var coverDrawable:Drawable? = null var coverDrawable:Drawable? = null
var trackingBottomSheet: TrackingBottomSheet? = null
var startingDLChapterPos:Int? = null
/** /**
* Adapter containing a list of chapters. * Adapter containing a list of chapters.
*/ */
private var adapter: ChaptersAdapter? = null private var adapter: ChaptersAdapter? = null
/**
* Action mode for selections.
*/
private var actionMode: ActionMode? = null
// Hold a reference to the current animator, // Hold a reference to the current animator,
// so that it can be canceled mid-way. // so that it can be canceled mid-way.
private var currentAnimator: Animator? = null private var currentAnimator: Animator? = null
@ -207,6 +220,13 @@ class MangaDetailsController : BaseController,
val atTop = !recycler.canScrollVertically(-1) val atTop = !recycler.canScrollVertically(-1)
if ((!atTop && !toolbarIsColored) || (atTop && toolbarIsColored)) { if ((!atTop && !toolbarIsColored) || (atTop && toolbarIsColored)) {
toolbarIsColored = !atTop toolbarIsColored = !atTop
val isCurrentController =
router?.backstack?.lastOrNull()?.controller() == this@MangaDetailsController
if (isCurrentController) setTitle()
if (actionMode != null) {
(activity as MainActivity).toolbar.setBackgroundColor(Color.TRANSPARENT)
return
}
val color = val color =
coverColor ?: activity!!.getResourceColor(android.R.attr.colorPrimary) coverColor ?: activity!!.getResourceColor(android.R.attr.colorPrimary)
val colorFrom = val colorFrom =
@ -228,9 +248,6 @@ class MangaDetailsController : BaseController,
activity?.window?.statusBarColor = (animator.animatedValue as Int) activity?.window?.statusBarColor = (animator.animatedValue as Int)
} }
colorAnimator?.start() colorAnimator?.start()
val isCurrentController =
router?.backstack?.lastOrNull()?.controller() == this@MangaDetailsController
if (isCurrentController) setTitle()
} }
} }
}) })
@ -308,11 +325,10 @@ class MangaDetailsController : BaseController,
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type) super.onChangeStarted(handler, type)
if (type == ControllerChangeType.PUSH_ENTER || type == ControllerChangeType.POP_ENTER) { if (type == ControllerChangeType.PUSH_ENTER || type == ControllerChangeType.POP_ENTER) {
if (type == ControllerChangeType.POP_ENTER) setStatusBar()
return
(activity as MainActivity).appbar.setBackgroundColor(Color.TRANSPARENT) (activity as MainActivity).appbar.setBackgroundColor(Color.TRANSPARENT)
(activity as MainActivity).toolbar.setBackgroundColor(Color.TRANSPARENT) (activity as MainActivity).toolbar.setBackgroundColor(activity?.window?.statusBarColor
activity?.window?.statusBarColor = Color.TRANSPARENT ?: Color.TRANSPARENT)
} }
else if (type == ControllerChangeType.PUSH_EXIT || type == ControllerChangeType.POP_EXIT) { else if (type == ControllerChangeType.PUSH_EXIT || type == ControllerChangeType.POP_EXIT) {
if (router.backstack.lastOrNull()?.controller() is DialogController) if (router.backstack.lastOrNull()?.controller() is DialogController)
@ -347,7 +363,6 @@ class MangaDetailsController : BaseController,
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
fun updateChapters(chapters: List<ChapterItem>) { fun updateChapters(chapters: List<ChapterItem>) {
swipe_refresh?.isRefreshing = presenter.isLoading swipe_refresh?.isRefreshing = presenter.isLoading
if (presenter.chapters.isEmpty() && fromCatalogue && !presenter.hasRequested) { if (presenter.chapters.isEmpty() && fromCatalogue && !presenter.hasRequested) {
@ -363,6 +378,32 @@ class MangaDetailsController : BaseController,
override fun onItemClick(view: View?, position: Int): Boolean { override fun onItemClick(view: View?, position: Int): Boolean {
val chapter = adapter?.getItem(position)?.chapter ?: return false val chapter = adapter?.getItem(position)?.chapter ?: return false
if (chapter.isHeader) return false if (chapter.isHeader) return false
if (actionMode != null) {
if (startingDLChapterPos == null) {
adapter?.addSelection(position)
(recycler.findViewHolderForAdapterPosition(position) as? BaseFlexibleViewHolder)
?.toggleActivation()
startingDLChapterPos = position
actionMode?.invalidate()
}
else {
val startingPosition = startingDLChapterPos ?: return false
var chapterList = listOf<ChapterItem>()
when {
startingPosition > position ->
chapterList = presenter.chapters.subList(position - 1, startingPosition)
startingPosition <= position ->
chapterList = presenter.chapters.subList(startingPosition - 1, position)
}
downloadChapters(chapterList)
adapter?.removeSelection(startingPosition)
(recycler.findViewHolderForAdapterPosition(startingPosition) as? BaseFlexibleViewHolder)
?.toggleActivation()
startingDLChapterPos = null
destroyActionModeIfNeeded()
}
return false
}
openChapter(chapter) openChapter(chapter)
return false return false
} }
@ -444,6 +485,8 @@ class MangaDetailsController : BaseController,
override fun onDestroyView(view: View) { override fun onDestroyView(view: View) {
snack?.dismiss() snack?.dismiss()
presenter.onDestroy() presenter.onDestroy()
adapter = null
trackingBottomSheet = null
super.onDestroyView(view) super.onDestroyView(view)
} }
@ -547,7 +590,7 @@ class MangaDetailsController : BaseController,
R.id.download_next_5 -> presenter.getUnreadChaptersSorted().take(5) R.id.download_next_5 -> presenter.getUnreadChaptersSorted().take(5)
R.id.download_next_10 -> presenter.getUnreadChaptersSorted().take(10) R.id.download_next_10 -> presenter.getUnreadChaptersSorted().take(10)
R.id.download_custom -> { R.id.download_custom -> {
showCustomDownloadDialog() createActionModeIfNeeded()
return return
} }
R.id.download_unread -> presenter.chapters.filter { !it.read } R.id.download_unread -> presenter.chapters.filter { !it.read }
@ -636,7 +679,7 @@ class MangaDetailsController : BaseController,
val shortcutIntent = activity.intent val shortcutIntent = activity.intent
.setAction(MainActivity.SHORTCUT_MANGA) .setAction(MainActivity.SHORTCUT_MANGA)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(MangaController.MANGA_EXTRA, presenter.manga.id) .putExtra(MANGA_EXTRA, presenter.manga.id)
// Check if shortcut placement is supported // Check if shortcut placement is supported
if (ShortcutManagerCompat.isRequestPinShortcutSupported(activity)) { if (ShortcutManagerCompat.isRequestPinShortcutSupported(activity)) {
@ -665,15 +708,9 @@ class MangaDetailsController : BaseController,
} }
} }
private fun showCustomDownloadDialog() { override fun startDownloadRange(position: Int) {
DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router) createActionModeIfNeeded()
} onItemClick(null, position)
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = presenter.getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
} }
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
@ -869,6 +906,91 @@ class MangaDetailsController : BaseController,
return super.handleBack() return super.handleBack()
} }
override fun showTrackingSheet() {
trackingBottomSheet = TrackingBottomSheet(this)
trackingBottomSheet?.show()
}
fun refreshTracking(trackings: List<TrackItem>) {
trackingBottomSheet?.onNextTrackings(trackings)
}
fun onTrackSearchResults(results: List<TrackSearch>) {
trackingBottomSheet?.onSearchResults(results)
}
fun refreshTracker() {
(recycler.findViewHolderForAdapterPosition(0) as? MangaHeaderHolder)
?.updateTracking()
}
fun trackRefreshDone() {
trackingBottomSheet?.onRefreshDone()
}
fun trackRefreshError(error: Exception) {
trackingBottomSheet?.onRefreshError(error)
}
fun trackSearchError(error: Exception) {
trackingBottomSheet?.onSearchResultsError(error)
}
/**
* Creates the action mode if it's not created already.
*/
private fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
(activity as MainActivity).toolbar.setBackgroundColor(Color.TRANSPARENT)
val view = activity?.window?.currentFocus ?: return
val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
?: return
imm.hideSoftInputFromWindow(view.windowToken, 0)
if (adapter?.mode != SelectableAdapter.Mode.MULTI) {
adapter?.mode = SelectableAdapter.Mode.MULTI
}
}
}
/**
* Destroys the action mode.
*/
private fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return true
}
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
actionMode = null
setStatusBar()
startingDLChapterPos = null
adapter?.mode = SelectableAdapter.Mode.IDLE
adapter?.clearSelection()
return
}
private fun setStatusBar() {
activity?.window?.statusBarColor = if (toolbarIsColored) {
val translucentColor = ColorUtils.setAlphaComponent(coverColor ?: Color.TRANSPARENT, 175)
(activity as MainActivity).toolbar.setBackgroundColor(translucentColor)
translucentColor
} else Color.TRANSPARENT
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
mode?.title = view?.context?.getString(if (startingDLChapterPos == null)
R.string.select_start_chapter else R.string.select_end_chapter)
return false
}
override fun zoomImageFromThumb(thumbView: View) { override fun zoomImageFromThumb(thumbView: View) {
// If there's an animation in progress, cancel it immediately and proceed with this one. // If there's an animation in progress, cancel it immediately and proceed with this one.
currentAnimator?.cancel() currentAnimator?.cancel()
@ -998,4 +1120,13 @@ class MangaDetailsController : BaseController,
} }
} }
} }
companion object {
const val UPDATE_EXTRA = "update"
const val SMART_SEARCH_CONFIG_EXTRA = "smartSearchConfig"
const val FROM_CATALOGUE_EXTRA = "from_catalogue"
const val MANGA_EXTRA = "manga"
}
} }

View File

@ -20,11 +20,13 @@ import eu.kanade.tachiyomi.data.library.LibraryServiceListener
import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
@ -62,6 +64,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } } private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
var tracks = emptyList<Track>() var tracks = emptyList<Track>()
var trackList: List<TrackItem> = emptyList()
var chapters:List<ChapterItem> = emptyList() var chapters:List<ChapterItem> = emptyList()
private set private set
@ -73,6 +76,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
headerItem.isLocked = isLockedFromSearch headerItem.isLocked = isLockedFromSearch
downloadManager.addListener(this) downloadManager.addListener(this)
LibraryUpdateService.setListener(this) LibraryUpdateService.setListener(this)
tracks = db.getTracks(manga).executeAsBlocking()
if (!manga.initialized) { if (!manga.initialized) {
isLoading = true isLoading = true
controller.setRefresh(true) controller.setRefresh(true)
@ -81,9 +85,9 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
} }
else { else {
updateChapters() updateChapters()
tracks = db.getTracks(manga).executeAsBlocking()
controller.updateChapters(this.chapters) controller.updateChapters(this.chapters)
} }
fetchTrackings()
} }
fun onDestroy() { fun onDestroy() {
@ -94,6 +98,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
fun fetchChapters() { fun fetchChapters() {
launch { launch {
getChapters() getChapters()
refreshTracking()
withContext(Dispatchers.Main) { controller.updateChapters(chapters) } withContext(Dispatchers.Main) { controller.updateChapters(chapters) }
} }
} }
@ -161,7 +166,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
} }
/** /**
* Sets the active display mode. * Sets the active display mode.
* @param mode the mode to set. * @param hide set title to hidden
*/ */
fun hideTitle(hide: Boolean) { fun hideTitle(hide: Boolean) {
manga.displayMode = if (hide) Manga.DISPLAY_NUMBER else Manga.DISPLAY_NAME manga.displayMode = if (hide) Manga.DISPLAY_NUMBER else Manga.DISPLAY_NAME
@ -658,7 +663,124 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
return false return false
} }
fun isTracked(): Boolean { fun isTracked(): Boolean = loggedServices.any { service -> tracks.any { it.sync_id == service.id } }
return loggedServices.any { service -> tracks.any { it.sync_id == service.id } }
fun hasTrackers(): Boolean = loggedServices.isNotEmpty()
// Tracking
private fun fetchTrackings() {
launch {
trackList = loggedServices.map { service ->
TrackItem(tracks.find { it.sync_id == service.id }, service)
}
}
}
private suspend fun refreshTracking() {
tracks = withContext(Dispatchers.IO) { db.getTracks(manga).executeAsBlocking() }
trackList = loggedServices.map { service ->
TrackItem(tracks.find { it.sync_id == service.id }, service)
}
withContext(Dispatchers.Main) { controller.refreshTracking(trackList) }
}
fun refreshTrackers() {
launch {
val list = trackList.filter { it.track != null }.map { item ->
withContext(Dispatchers.IO) {
val trackItem = try {
item.service.refresh(item.track!!)
} catch (e: Exception) {
trackError(e)
null
}
if (trackItem != null) {
db.insertTrack(trackItem).executeAsBlocking()
trackItem
}
else
item.track
}
}
refreshTracking()
}
}
fun trackSearch(query: String, service: TrackService) {
launch(Dispatchers.IO) {
val results = try {service.search(query) }
catch (e: Exception) {
withContext(Dispatchers.Main) { controller.trackSearchError(e) }
null }
if (!results.isNullOrEmpty()) {
withContext(Dispatchers.Main) { controller.onTrackSearchResults(results) }
}
}
}
fun registerTracking(item: Track?, service: TrackService) {
if (item != null) {
item.manga_id = manga.id!!
launch {
val binding = try { service.bind(item) }
catch (e: Exception) {
trackError(e)
null
}
withContext(Dispatchers.IO) {
if (binding != null) db.insertTrack(binding).executeAsBlocking() }
refreshTracking()
}
} else {
launch {
withContext(Dispatchers.IO) { db.deleteTrackForManga(manga, service)
.executeAsBlocking() }
refreshTracking()
}
}
}
private fun updateRemote(track: Track, service: TrackService) {
launch {
val binding = try { service.update(track) }
catch (e: Exception) {
trackError(e)
null
}
if (binding != null) {
withContext(Dispatchers.IO) { db.insertTrack(binding).executeAsBlocking() }
refreshTracking()
}
else trackRefreshDone()
}
}
private suspend fun trackRefreshDone() {
async(Dispatchers.Main) { controller.trackRefreshDone() }
}
private suspend fun trackError(error: Exception) {
async(Dispatchers.Main) { controller.trackRefreshError(error) }
}
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

@ -32,12 +32,10 @@ class MangaHeaderHolder(
startExpanded: Boolean startExpanded: Boolean
) : MangaChapterHolder(view, adapter) { ) : MangaChapterHolder(view, adapter) {
init { init {
start_reading_button.setOnClickListener { adapter.coverListener?.readNextChapter() } start_reading_button.setOnClickListener { adapter.coverListener.readNextChapter() }
top_view.updateLayoutParams<ConstraintLayout.LayoutParams> { top_view.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = adapter.coverListener?.topCoverHeight() ?: 0 height = adapter.coverListener.topCoverHeight()
} }
more_button.setOnClickListener { expandDesc() } more_button.setOnClickListener { expandDesc() }
manga_summary.setOnClickListener { expandDesc() } manga_summary.setOnClickListener { expandDesc() }
@ -48,29 +46,30 @@ class MangaHeaderHolder(
more_button_group.visible() more_button_group.visible()
} }
manga_genres_tags.setOnTagClickListener { manga_genres_tags.setOnTagClickListener {
adapter.coverListener?.tagClicked(it) adapter.coverListener.tagClicked(it)
} }
filter_button.setOnClickListener { adapter.coverListener?.showChapterFilter() } filter_button.setOnClickListener { adapter.coverListener.showChapterFilter() }
filters_text.setOnClickListener { adapter.coverListener?.showChapterFilter() } filters_text.setOnClickListener { adapter.coverListener.showChapterFilter() }
chapters_title.setOnClickListener { adapter.coverListener?.showChapterFilter() } chapters_title.setOnClickListener { adapter.coverListener.showChapterFilter() }
webview_button.setOnClickListener { adapter.coverListener?.openInWebView() } webview_button.setOnClickListener { adapter.coverListener.openInWebView() }
share_button.setOnClickListener { adapter.coverListener?.prepareToShareManga() } share_button.setOnClickListener { adapter.coverListener.prepareToShareManga() }
favorite_button.setOnClickListener { favorite_button.setOnClickListener {
adapter.coverListener?.favoriteManga(false) adapter.coverListener.favoriteManga(false)
} }
favorite_button.setOnLongClickListener { favorite_button.setOnLongClickListener {
adapter.coverListener?.favoriteManga(true) adapter.coverListener.favoriteManga(true)
true true
} }
manga_full_title.setOnLongClickListener { manga_full_title.setOnLongClickListener {
adapter.coverListener?.copyToClipboard(manga_full_title.text.toString(), R.string.manga_info_full_title_label) adapter.coverListener.copyToClipboard(manga_full_title.text.toString(), R.string.manga_info_full_title_label)
true true
} }
manga_author.setOnLongClickListener { manga_author.setOnLongClickListener {
adapter.coverListener?.copyToClipboard(manga_author.text.toString(), R.string.manga_info_author_label) adapter.coverListener.copyToClipboard(manga_author.text.toString(), R.string.manga_info_author_label)
true true
} }
manga_cover.setOnClickListener { adapter.coverListener?.zoomImageFromThumb(cover_card) } manga_cover.setOnClickListener { adapter.coverListener.zoomImageFromThumb(cover_card) }
track_button.setOnClickListener { adapter.coverListener.showTrackingSheet() }
if (startExpanded) if (startExpanded)
expandDesc() expandDesc()
} }
@ -144,6 +143,7 @@ class MangaHeaderHolder(
val tracked = presenter.isTracked() && !item.isLocked val tracked = presenter.isTracked() && !item.isLocked
with(track_button) { with(track_button) {
visibleIf(presenter.hasTrackers())
text = itemView.context.getString(if (tracked) R.string.action_filter_tracked text = itemView.context.getString(if (tracked) R.string.action_filter_tracked
else R.string.tracking) else R.string.tracking)
@ -154,18 +154,24 @@ class MangaHeaderHolder(
with(start_reading_button) { with(start_reading_button) {
val nextChapter = presenter.getNextUnreadChapter() val nextChapter = presenter.getNextUnreadChapter()
visibleIf(nextChapter != null && !item.isLocked) visibleIf(presenter.chapters.isNotEmpty() && !item.isLocked)
isEnabled = (nextChapter != null)
if (nextChapter != null) { if (nextChapter != null) {
val number = adapter.decimalFormat.format(nextChapter.chapter_number.toDouble()) val number = adapter.decimalFormat.format(nextChapter.chapter_number.toDouble())
text = resources.getString( text = if (nextChapter.chapter_number > 0) resources.getString(
when { if (nextChapter.last_page_read > 0) R.string.continue_reading_chapter
nextChapter.last_page_read > 0 && nextChapter.chapter_number <= 0 -> else R.string.start_reading_chapter, number
R.string.continue_reading
nextChapter.chapter_number <= 0 -> R.string.start_reading
nextChapter.last_page_read > 0 -> R.string.continue_reading_chapter
else -> R.string.start_reader_chapter
}, number
) )
else {
val name = nextChapter.name
resources.getString(
if (nextChapter.last_page_read > 0) R.string.continue_reading_x
else R.string.start_reading_x, name
)
}
}
else {
text = resources.getString(R.string.all_caught_up)
} }
} }
@ -173,7 +179,7 @@ class MangaHeaderHolder(
chapters_title.text = itemView.resources.getQuantityString(R.plurals.chapters, count, count) chapters_title.text = itemView.resources.getQuantityString(R.plurals.chapters, count, count)
top_view.updateLayoutParams<ConstraintLayout.LayoutParams> { top_view.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = adapter.coverListener.topCoverHeight() ?: 0 height = adapter.coverListener.topCoverHeight()
} }
manga_status.text = (itemView.context.getString( when (manga.status) { manga_status.text = (itemView.context.getString( when (manga.status) {
@ -230,6 +236,19 @@ class MangaHeaderHolder(
true_backdrop.setBackgroundColor(color) true_backdrop.setBackgroundColor(color)
} }
fun updateTracking() {
val presenter = adapter.coverListener?.mangaPresenter() ?: return
val tracked = presenter.isTracked()
with(track_button) {
text = itemView.context.getString(if (tracked) R.string.action_filter_tracked
else R.string.tracking)
icon = ContextCompat.getDrawable(itemView.context, if (tracked) R.drawable
.ic_check_white_24dp else R.drawable.ic_sync_black_24dp)
checked(tracked)
}
}
override fun onLongClick(view: View?): Boolean { override fun onLongClick(view: View?): Boolean {
super.onLongClick(view) super.onLongClick(view)
return false return false

View File

@ -0,0 +1,185 @@
package eu.kanade.tachiyomi.ui.manga
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.ui.manga.track.SetTrackChaptersDialog
import eu.kanade.tachiyomi.ui.manga.track.SetTrackScoreDialog
import eu.kanade.tachiyomi.ui.manga.track.SetTrackStatusDialog
import eu.kanade.tachiyomi.ui.manga.track.TrackAdapter
import eu.kanade.tachiyomi.ui.manga.track.TrackHolder
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.ui.manga.track.TrackSearchDialog
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener
import eu.kanade.tachiyomi.util.view.setEdgeToEdge
import kotlinx.android.synthetic.main.tracking_bottom_sheet.*
import timber.log.Timber
class TrackingBottomSheet(private val controller: MangaDetailsController) : BottomSheetDialog
(controller.activity!!, R.style.BottomSheetDialogTheme),
TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener,
SetTrackChaptersDialog.Listener,
SetTrackScoreDialog.Listener {
val activity = controller.activity!!
private var sheetBehavior: BottomSheetBehavior<*>
val presenter = controller.presenter
private var adapter: TrackAdapter? = null
init {
// Use activity theme for this layout
val view = activity.layoutInflater.inflate(R.layout.tracking_bottom_sheet, null)
setContentView(view)
sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup)
setEdgeToEdge(activity, display_bottom_sheet, view, false)
val height = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.window.decorView.rootWindowInsets.systemWindowInsetBottom
} else 0
sheetBehavior.peekHeight = 380.dpToPx + height
sheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, progress: Float) { }
override fun onStateChanged(p0: View, state: Int) {
if (state == BottomSheetBehavior.STATE_EXPANDED) {
sheetBehavior.skipCollapsed = true
}
}
})
}
override fun onStart() {
super.onStart()
sheetBehavior.skipCollapsed = true
}
/**
* Called when the sheet is created. It initializes the listeners and values of the preferences.
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = TrackAdapter(this)
track_recycler.layoutManager = LinearLayoutManager(context)
track_recycler.adapter = adapter
track_recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener)
adapter?.items = presenter.trackList
}
fun onNextTrackings(trackings: List<TrackItem>) {
onRefreshDone()
adapter?.items = trackings
controller.refreshTracker()
}
fun onSearchResults(results: List<TrackSearch>) {
getSearchDialog()?.onSearchResults(results)
}
fun onSearchResultsError(error: Throwable) {
Timber.e(error)
getSearchDialog()?.onSearchResultsError()
}
private fun getSearchDialog(): TrackSearchDialog? {
return controller.router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog
}
fun onRefreshDone() {
for (i in adapter!!.items.indices) {
(track_recycler.findViewHolderForAdapterPosition(i) as? TrackHolder)?.setProgress(false)
}
}
fun onRefreshError(error: Throwable) {
for (i in adapter!!.items.indices) {
(track_recycler.findViewHolderForAdapterPosition(i) as? TrackHolder)?.setProgress(false)
}
activity.toast(error.message)
}
override fun onLogoClick(position: Int) {
val track = adapter?.getItem(position)?.track ?: return
if (track.tracking_url.isBlank()) {
activity.toast(R.string.url_not_set)
} else {
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url)))
}
}
override fun onSetClick(position: Int) {
val item = adapter?.getItem(position) ?: return
TrackSearchDialog(this, item.service, item.track != null).showDialog(
controller.router, TAG_SEARCH_CONTROLLER)
}
override fun onStatusClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackStatusDialog(this, item).showDialog(controller.router)
}
override fun onChaptersClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackChaptersDialog(this, item).showDialog(controller.router)
}
override fun onScoreClick(position: Int) {
val item = adapter?.getItem(position) ?: return
if (item.track == null) return
SetTrackScoreDialog(this, item).showDialog(controller.router)
}
override fun setStatus(item: TrackItem, selection: Int) {
presenter.setStatus(item, selection)
refreshItem(item)
}
private fun refreshItem(item: TrackItem) {
refreshTrack(item.service)
}
fun refreshTrack(item: TrackService?) {
val index = adapter?.indexOf(item) ?: -1
if (index > -1 ){
(track_recycler.findViewHolderForAdapterPosition(index) as? TrackHolder)
?.setProgress(true)
}
}
override fun setScore(item: TrackItem, score: Int) {
presenter.setScore(item, score)
refreshItem(item)
}
override fun setChaptersRead(item: TrackItem, chaptersRead: Int) {
presenter.setLastChapterRead(item, chaptersRead)
refreshItem(item)
}
private companion object {
const val TAG_SEARCH_CONTROLLER = "track_search_controller"
}
}

View File

@ -1,136 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import androidx.appcompat.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.system.getResourceColor
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.invisible
import eu.kanade.tachiyomi.util.view.setVectorCompat
import eu.kanade.tachiyomi.util.view.visible
import kotlinx.android.synthetic.main.chapters_item.*
import java.util.Date
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 ?: return
val isLocked = item.isLocked
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
}
chapter_menu.visible()
// 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))
if (isLocked) chapter_menu.invisible()
// Set correct text color
chapter_title.setTextColor(if (chapter.read && !isLocked)
adapter.readColor else adapter.unreadColor)
if (chapter.bookmark && !isLocked) 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 && !isLocked) {
itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1)
} else {
""
}
notifyStatus(item.status, item.isLocked)
}
fun notifyStatus(status: Int, locked: Boolean) = with(download_text) {
if (locked) {
text = ""
return
}
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
val chapter = item.chapter ?: return
if (item.isLocked) {
adapter.unlock()
return
}
// Create a PopupMenu, giving it the clicked view for an anchor
val popup = PopupMenu(view.context, view)
// Inflate our menu resource into the PopupMenu's Menu
popup.menuInflater.inflate(R.menu.chapter_single, popup.menu)
// Hide download and show delete if the chapter is downloaded
if (item.isDownloaded) {
popup.menu.findItem(R.id.action_download).isVisible = false
popup.menu.findItem(R.id.action_delete).isVisible = true
}
// Hide bookmark if bookmark
popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark
popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark
// Hide mark as unread when the chapter is unread
if (!chapter.read && chapter.last_page_read == 0) {
popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false
}
// Hide mark as read when the chapter is read
if (chapter.read) {
popup.menu.findItem(R.id.action_mark_as_read).isVisible = false
}
// Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem ->
adapter.menuItemListener?.onMenuItemClick(adapterPosition, menuItem)
true
}
// Finally show the PopupMenu
popup.show()
}
}

View File

@ -41,7 +41,7 @@ class ChapterItem(val chapter: Chapter, val manga: Manga) :
} }
override fun isSelectable(): Boolean { override fun isSelectable(): Boolean {
return chapter.isHeader return !chapter.isHeader
} }
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): MangaChapterHolder { override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): MangaChapterHolder {

View File

@ -20,17 +20,17 @@ class ChapterMatHolder(
) : MangaChapterHolder(view, adapter) { ) : MangaChapterHolder(view, adapter) {
init { 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) } }
download_button.setOnClickListener { downloadOrRemoveMenu() } download_button.setOnClickListener { downloadOrRemoveMenu() }
download_button.setOnLongClickListener {
adapter.coverListener.startDownloadRange(adapterPosition)
true
}
} }
private fun downloadOrRemoveMenu() { private fun downloadOrRemoveMenu() {
val chapter = adapter.getItem(adapterPosition) ?: return val chapter = adapter.getItem(adapterPosition) ?: return
if (chapter.status == Download.NOT_DOWNLOADED || chapter.status == Download.ERROR) { if (chapter.status == Download.NOT_DOWNLOADED || chapter.status == Download.ERROR) {
adapter.coverListener?.downloadChapter(adapterPosition) adapter.coverListener.downloadChapter(adapterPosition)
} else { } else {
download_button.post { download_button.post {
// Create a PopupMenu, giving it the clicked view for an anchor // Create a PopupMenu, giving it the clicked view for an anchor
@ -46,7 +46,7 @@ class ChapterMatHolder(
// Set a listener so we are notified if a menu item is clicked // Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { _ -> popup.setOnMenuItemClickListener { _ ->
adapter.coverListener?.downloadChapter(adapterPosition) adapter.coverListener.downloadChapter(adapterPosition)
true true
} }

View File

@ -1,14 +1,13 @@
package eu.kanade.tachiyomi.ui.manga.chapter package eu.kanade.tachiyomi.ui.manga.chapter
import android.content.Context import android.content.Context
import android.view.MenuItem
import android.view.View import android.view.View
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.manga.MangaDetailsController
import eu.kanade.tachiyomi.ui.manga.MangaDetailsPresenter import eu.kanade.tachiyomi.ui.manga.MangaDetailsPresenter
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
@ -18,7 +17,7 @@ import java.text.DecimalFormat
import java.text.DecimalFormatSymbols import java.text.DecimalFormatSymbols
class ChaptersAdapter( class ChaptersAdapter(
val controller: BaseController, val controller: MangaDetailsController,
context: Context context: Context
) : FlexibleAdapter<ChapterItem>(null, controller, true) { ) : FlexibleAdapter<ChapterItem>(null, controller, true) {
@ -26,8 +25,7 @@ class ChaptersAdapter(
var items: List<ChapterItem> = emptyList() var items: List<ChapterItem> = emptyList()
val menuItemListener: OnMenuItemClickListener? = controller as? OnMenuItemClickListener val coverListener: MangaHeaderInterface = controller
val coverListener: MangaHeaderInterface? = controller as? MangaHeaderInterface
val readColor = context.getResourceColor(android.R.attr.textColorHint) val readColor = context.getResourceColor(android.R.attr.textColorHint)
@ -54,10 +52,6 @@ class ChaptersAdapter(
SecureActivityDelegate.promptLockIfNeeded(activity) SecureActivityDelegate.promptLockIfNeeded(activity)
} }
interface OnMenuItemClickListener {
fun onMenuItemClick(position: Int, item: MenuItem)
}
interface MangaHeaderInterface { interface MangaHeaderInterface {
fun coverColor(): Int? fun coverColor(): Int?
fun mangaPresenter(): MangaDetailsPresenter fun mangaPresenter(): MangaDetailsPresenter
@ -71,5 +65,7 @@ class ChaptersAdapter(
fun favoriteManga(longPress: Boolean) fun favoriteManga(longPress: Boolean)
fun copyToClipboard(content: String, label: Int) fun copyToClipboard(content: String, label: Int)
fun zoomImageFromThumb(thumbView: View) fun zoomImageFromThumb(thumbView: View)
fun showTrackingSheet()
fun startDownloadRange(position: Int)
} }
} }

View File

@ -1,603 +0,0 @@
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 android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
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.main.MainActivity
import eu.kanade.tachiyomi.ui.main.SearchActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets
import eu.kanade.tachiyomi.util.view.getCoordinates
import eu.kanade.tachiyomi.util.view.getText
import eu.kanade.tachiyomi.util.view.marginBottom
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.updateLayoutParams
import eu.kanade.tachiyomi.util.view.updatePaddingRelative
import kotlinx.android.synthetic.main.chapters_controller.*
import timber.log.Timber
class ChaptersController() : NucleusController<ChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ChaptersAdapter.OnMenuItemClickListener,
DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener {
constructor(startY: Float?) : this() {
this.startingChapterYPos = startY
}
/**
* Adapter containing a list of chapters.
*/
private var adapter: ChaptersAdapter? = null
private var scrollToUnread = true
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
private var snack:Snackbar? = null
/**
* Selected items. Used to restore selections after a rotation.
*/
private val selectedItems = mutableSetOf<ChapterItem>()
private var lastClickPosition = -1
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
var startingChapterYPos:Float? = null
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)
setReadingDrawable()
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(view.context)
recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
adapter?.fastScroller = fast_scroller
val fabBaseMarginBottom = fab?.marginBottom ?: 0
recycler.doOnApplyWindowInsets { v, insets, _ ->
fab?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = fabBaseMarginBottom + insets.systemWindowInsetBottom
}
fast_scroller?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.systemWindowInsetBottom
}
// offset the recycler by the fab's inset + some inset on top
v.updatePaddingRelative(bottom = insets.systemWindowInsetBottom +
v.context.resources.getDimensionPixelSize(R.dimen.fab_list_padding))
}
swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() }
fab.clicks().subscribeUntilDestroy {
if (activity is SearchActivity && presenter.isLockedFromSearch) {
SecureActivityDelegate.promptLockIfNeeded(activity)
return@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 if (snack == null || snack?.getText() != view.context.getString(R.string.no_next_chapter)) {
snack = view.snack(R.string.no_next_chapter, Snackbar.LENGTH_LONG) {
addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (snack == transientBottomBar) snack = null
}
})
}
}
}
}
override fun onDestroyView(view: View) {
adapter = null
actionMode = null
super.onDestroyView(view)
}
/**
* Update FAB with correct drawable.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setReadingDrawable() {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
fab.setImageResource(
when {
(parentController as MangaController).isLockedFromSearch -> R.drawable.ic_lock_white_24dp
else -> R.drawable.ic_play_arrow_white_24dp
}
)
}
override fun onActivityResumed(activity: Activity) {
super.onActivityResumed(activity)
if (view == null) return
if (activity is SearchActivity) {
presenter.updateLockStatus()
setReadingDrawable()
}
// 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)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (!(parentController as MangaController).isLockedFromSearch)
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
// Display mode submenu
if (presenter.manga.displayMode == Manga.DISPLAY_NAME) {
menu.findItem(R.id.display_title).isChecked = true
} else {
menu.findItem(R.id.display_chapter_number).isChecked = true
}
// Sorting mode submenu
if (presenter.manga.sorting == Manga.SORTING_SOURCE) {
menu.findItem(R.id.sort_by_source).isChecked = true
} else {
menu.findItem(R.id.sort_by_number).isChecked = true
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.display_title -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NAME)
}
R.id.display_chapter_number -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NUMBER)
}
R.id.sort_by_source -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_SOURCE)
}
R.id.sort_by_number -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_NUMBER)
}
R.id.download_next, R.id.download_next_5, R.id.download_next_10,
R.id.download_custom, R.id.download_unread, R.id.download_all
-> downloadChapters(item.itemId)
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()
}
scrollToUnread()
}
private fun scrollToUnread() {
if (adapter?.items.isNullOrEmpty()) return
if (scrollToUnread) {
val index = presenter.getFirstUnreadIndex() ?: return
val centerOfScreen =
if (startingChapterYPos != null) startingChapterYPos!!.toInt() - recycler.top - 96
else recycler.height / 2 - 96
(recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
index, centerOfScreen
)
}
scrollToUnread = false
}
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, presenter.isLockedFromSearch)
}
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) {
lastClickPosition = position
toggleSelection(position)
return true
} else {
openChapter(item.chapter)
return false
}
}
override fun onItemLongClick(position: Int) {
createActionModeIfNeeded()
when {
lastClickPosition == -1 -> setSelection(position)
lastClickPosition > position -> for (i in position until lastClickPosition)
setSelection(i)
lastClickPosition < position -> for (i in lastClickPosition + 1..position)
setSelection(i)
else -> setSelection(position)
}
lastClickPosition = position
adapter?.notifyDataSetChanged()
}
// SELECTIONS & ACTION MODE
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
val item = adapter.getItem(position) ?: return
adapter.toggleSelection(position)
adapter.notifyDataSetChanged()
if (adapter.isSelected(position)) {
selectedItems.add(item)
} else {
selectedItems.remove(item)
}
actionMode?.invalidate()
}
private fun setSelection(position: Int) {
val adapter = adapter ?: return
val item = adapter.getItem(position) ?: return
if (!adapter.isSelected(position)) {
adapter.toggleSelection(position)
selectedItems.add(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() {
lastClickPosition = -1
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 onDetach(view: View) {
destroyActionModeIfNeeded()
super.onDetach(view)
}
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 && (snack == null ||
snack?.getText() != view.context.getString(R.string.snack_add_to_library))) {
snack = view.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_add) {
presenter.addToLibrary()
}
addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (snack == transientBottomBar) snack = null
}
})
}
(activity as? MainActivity)?.setUndoSnackBar(snack)
}
}
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
presenter.deleteChapters(chapters)
}
fun onChaptersDeleted(chapters: List<ChapterItem>) {
//this is needed so the downloaded text gets removed from the item
chapters.forEach {
adapter?.updateItem(it)
}
adapter?.notifyDataSetChanged()
}
fun onChaptersDeletedError(error: Throwable) {
Timber.e(error)
}
// OVERFLOW MENU DIALOGS
private fun setDisplayMode(id: Int) {
presenter.setDisplayMode(id)
adapter?.notifyDataSetChanged()
}
private fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
private fun downloadChapters(choice: Int) {
val chaptersToDownload = when (choice) {
R.id.download_next -> getUnreadChaptersSorted().take(1)
R.id.download_next_5 -> getUnreadChaptersSorted().take(5)
R.id.download_next_10 -> getUnreadChaptersSorted().take(10)
R.id.download_custom -> {
showCustomDownloadDialog()
return
}
R.id.download_unread -> presenter.chapters.filter { !it.read }
R.id.download_all -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
private fun showCustomDownloadDialog() {
DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router)
}
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
}

View File

@ -1,443 +0,0 @@
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.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
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
var isLockedFromSearch = false
fun updateLockStatus() {
val lastCheck = isLockedFromSearch
isLockedFromSearch = SecureActivityDelegate.shouldBeLocked()
if (lastCheck && lastCheck != isLockedFromSearch) {
chapters.forEach {
it.isLocked = false
}
chaptersRelay.call(chapters)
}
}
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
isLockedFromSearch = SecureActivityDelegate.shouldBeLocked()
// 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)
model.isLocked = isLockedFromSearch
// 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
chapter.pages_left = 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(chapters)
}, 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()
}
fun getFirstUnreadIndex(): Int? {
if (!manga.favorite) {
return null
}
val index = chapters.sortedByDescending { it.source_order }.indexOfFirst { !it.read }
return if (sortDescending()) (chapters.size - 1) - index
else index
}
}

View File

@ -1,31 +0,0 @@
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(activity!!).show {
message(R.string.confirm_delete_chapters)
positiveButton(android.R.string.yes) {
(targetController as? Listener)?.deleteChapters()
}
negativeButton(android.R.string.no)
}
}
interface Listener {
fun deleteChapters()
}
}

View File

@ -1,76 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.DialogCustomDownloadView
/**
* Dialog used to let user select amount of chapters to download.
*/
class DownloadCustomChaptersDialog<T> : DialogController
where T : Controller, T : DownloadCustomChaptersDialog.Listener {
/**
* Maximum number of chapters to download in download chooser.
*/
private val maxChapters: Int
/**
* Initialize dialog.
* @param maxChapters maximal number of chapters that user can download.
*/
constructor(target: T, maxChapters: Int) : super(Bundle().apply {
// Add maximum number of chapters to download value to bundle.
putInt(KEY_ITEM_MAX, maxChapters)
}) {
targetController = target
this.maxChapters = maxChapters
}
/**
* Restore dialog.
* @param bundle bundle containing data from state restore.
*/
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
// Get maximum chapters to download from bundle
val maxChapters = bundle.getInt(KEY_ITEM_MAX, 0)
this.maxChapters = maxChapters
}
/**
* Called when dialog is being created.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
// Initialize view that lets user select number of chapters to download.
val view = DialogCustomDownloadView(activity).apply {
setMinMax(0, maxChapters)
}
// Build dialog.
// when positive dialog is pressed call custom listener.
return MaterialDialog(activity)
.title(R.string.custom_download)
.customView(view = view, scrollable = true)
.positiveButton(android.R.string.ok) {
(targetController as? Listener)?.downloadCustomChapters(view.amount)
}
.negativeButton(android.R.string.cancel)
}
interface Listener {
fun downloadCustomChapters(amount: Int)
}
private companion object {
// Key to retrieve max chapters from bundle on process death.
const val KEY_ITEM_MAX = "DownloadCustomChaptersDialog.int.maxChapters"
}
}

View File

@ -1,860 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.app.Activity
import android.app.PendingIntent
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.os.Build
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.transition.ChangeBounds
import androidx.transition.ChangeImageTransform
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.bumptech.glide.signature.ObjectKey
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.view.clicks
import com.jakewharton.rxbinding.view.longClicks
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.ChooseShapeDialog
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets
import eu.kanade.tachiyomi.util.view.marginBottom
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.updateLayoutParams
import eu.kanade.tachiyomi.util.view.updatePaddingRelative
import jp.wasabeef.glide.transformations.CropSquareTransformation
import jp.wasabeef.glide.transformations.MaskTransformation
import kotlinx.android.synthetic.main.manga_info_controller.*
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.text.DateFormat
import java.text.DecimalFormat
import java.util.Date
import kotlin.math.max
/**
* Fragment that shows manga information.
* Uses R.layout.manga_info_controller.
* UI related actions should be called from here.
*/
class MangaInfoController : NucleusController<MangaInfoPresenter>(),
ChangeMangaCategoriesDialog.Listener {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* Snackbar containing an error message when a request fails.
*/
private var snack: Snackbar? = null
private var container:View? = null
// Hold a reference to the current animator,
// so that it can be canceled mid-way.
private var currentAnimator: Animator? = null
// The system "short" animation time duration, in milliseconds. This
// duration is ideal for subtle animations or animations that occur
// very frequently.
private var shortAnimationDuration: Int = 0
private var setUpFullCover = false
var fullRes:Drawable? = null
private val dateFormat: DateFormat by lazy {
preferences.dateFormat().getOrDefault()
}
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
override fun createPresenter(): MangaInfoPresenter {
val ctrl = parentController as MangaController
return MangaInfoPresenter(ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.manga_info_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
setUpFullCover = false
// Set onclickListener to toggle favorite when FAB clicked.
fab_favorite.clicks().subscribeUntilDestroy { onFabClick() }
// Set onLongClickListener to manage categories when FAB is clicked.
fab_favorite.longClicks().subscribeUntilDestroy { onFabLongClick() }
// Set SwipeRefresh to refresh manga data.
swipe_refresh.refreshes().subscribeUntilDestroy { fetchMangaFromSource() }
manga_full_title.longClicks().subscribeUntilDestroy {
copyToClipboard(view.context.getString(R.string.title), manga_full_title.text
.toString(), R.string.manga_info_full_title_label)
}
manga_full_title.clicks().subscribeUntilDestroy {
performGlobalSearch(manga_full_title.text.toString())
}
manga_artist.longClicks().subscribeUntilDestroy {
copyToClipboard(manga_artist_label.text.toString(), manga_artist.text.toString(), R
.string.manga_info_artist_label)
}
manga_artist.clicks().subscribeUntilDestroy {
performGlobalSearch(manga_artist.text.toString())
}
manga_author.longClicks().subscribeUntilDestroy {
copyToClipboard(manga_author.text.toString(), manga_author.text.toString(), R.string
.manga_info_author_label)
}
manga_author.clicks().subscribeUntilDestroy {
performGlobalSearch(manga_author.text.toString())
}
manga_summary.longClicks().subscribeUntilDestroy {
copyToClipboard(view.context.getString(R.string.description), manga_summary.text
.toString(), R.string.description)
}
manga_genres_tags.setOnTagClickListener { tag -> performLocalSearch(tag) }
manga_cover.clicks().subscribeUntilDestroy {
if (manga_cover.drawable != null) zoomImageFromThumb(manga_cover, manga_cover.drawable)
}
// Retrieve and cache the system's default "short" animation time.
shortAnimationDuration = resources?.getInteger(android.R.integer.config_shortAnimTime) ?: 0
manga_cover.longClicks().subscribeUntilDestroy {
copyToClipboard(view.context.getString(R.string.title), presenter.manga.currentTitle(), R.string
.manga_info_full_title_label)
}
container = (view as ViewGroup).findViewById(R.id.manga_info_layout) as? View
val bottomM = manga_genres_tags.marginBottom
val fabBaseMarginBottom = fab_favorite.marginBottom
val mangaCoverMarginBottom = manga_cover.marginBottom
val fullMarginBottom = manga_cover_full?.marginBottom ?: 0
manga_cover.viewTreeObserver.addOnGlobalLayoutListener {
setFullCoverToThumb()
}
container?.setOnApplyWindowInsetsListener { _, insets ->
if (resources?.configuration?.orientation == Configuration.ORIENTATION_LANDSCAPE) {
fab_favorite?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = fabBaseMarginBottom + insets.systemWindowInsetBottom
}
manga_cover?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = mangaCoverMarginBottom + insets.systemWindowInsetBottom
}
} else {
manga_genres_tags?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = bottomM + insets.systemWindowInsetBottom
}
}
manga_cover_full?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = fullMarginBottom + insets.systemWindowInsetBottom
}
insets
}
info_scrollview.doOnApplyWindowInsets { v, insets, padding ->
if (resources?.configuration?.orientation == Configuration.ORIENTATION_LANDSCAPE) {
v.updatePaddingRelative(
bottom = max(padding.bottom, insets.systemWindowInsetBottom)
)
}
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.manga_info, menu)
val editItem = menu.findItem(R.id.action_edit)
editItem.isVisible = presenter.manga.favorite &&
!(parentController as MangaController).isLockedFromSearch
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
//R.id.action_edit -> EditMangaDialog(this, presenter.manga).showDialog(router)
R.id.action_open_in_web_view -> openInWebView()
R.id.action_share -> prepareToShareManga()
R.id.action_add_to_home_screen -> addToHomeScreen()
}
return super.onOptionsItemSelected(item)
}
/**
* Check if manga is initialized.
* If true update view with manga information,
* if false fetch manga information
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun onNextManga(manga: Manga, source: Source) {
if (manga.initialized) {
// Update view.
setMangaInfo(manga, source)
} else {
// Initialize manga.
fetchMangaFromSource()
}
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo(manga: Manga, source: Source?) {
val view = view ?: return
//update full title TextView.
manga_full_title.text = if (manga.currentTitle().isBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.currentTitle()
}
// Update artist TextView.
manga_artist.text = if (manga.currentArtist().isNullOrBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.currentArtist()
}
// Update author TextView.
manga_author.text = if (manga.currentAuthor().isNullOrBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.currentAuthor()
}
// If manga source is known update source TextView.
manga_source.text = source?.toString() ?: view.context.getString(R.string.unknown)
// Update genres list
if (manga.currentGenres().isNullOrBlank().not()) {
manga_genres_tags.setTags(manga.currentGenres()?.split(", "))
}
else manga_genres_tags.setTags(emptyList())
// Update description TextView.
manga_summary.text = if (manga.currentDesc().isNullOrBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.currentDesc()
}
// Update status TextView.
manga_status.setText(when (manga.status) {
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
SManga.LICENSED -> R.string.licensed
else -> R.string.unknown
})
// Set the favorite drawable to the correct one.
setFavoriteDrawable(manga.favorite)
activity?.invalidateOptionsMenu()
// Set cover if it wasn't already.
if (!manga.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(view.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.signature(ObjectKey(MangaImpl.getLastCoverFetch(manga.id!!).toString()))
.transition(DrawableTransitionOptions.withCrossFade())
//.centerCrop()
.into(manga_cover)
if (manga_cover_full != null) {
GlideApp.with(view.context).asDrawable().load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.signature(ObjectKey(MangaImpl.getLastCoverFetch(manga.id!!).toString()))
.override(CustomTarget.SIZE_ORIGINAL, CustomTarget.SIZE_ORIGINAL)
.into(object : CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable,
transition: Transition<in Drawable>?
) {
fullRes = resource
}
override fun onLoadCleared(placeholder: Drawable?) { }
})
}
if (backdrop != null) {
GlideApp.with(view.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.signature(ObjectKey(MangaImpl.getLastCoverFetch(manga.id!!).toString()))
.transition(DrawableTransitionOptions.withCrossFade())
.centerCrop()
.into(backdrop)
}
}
}
override fun onActivityResumed(activity: Activity) {
super.onActivityResumed(activity)
setFavoriteDrawable(presenter.manga.favorite)
}
override fun onDestroyView(view: View) {
manga_genres_tags.setOnTagClickListener(null)
snack?.dismiss()
super.onDestroyView(view)
}
/**
* Update chapter count TextView.
*
* @param count number of chapters.
*/
fun setChapterCount(count: Float) {
if (count > 0f) {
manga_chapters?.text = DecimalFormat("#.#").format(count)
} else {
manga_chapters?.text = resources?.getString(R.string.unknown)
}
}
fun setLastUpdateDate(date: Date) {
if (date.time != 0L) {
manga_status?.text = dateFormat.format(date)
} else {
manga_status?.text = resources?.getString(R.string.unknown)
}
}
/**
* Toggles the favorite status and asks for confirmation to delete downloaded chapters.
*/
private fun toggleFavorite() {
presenter.toggleFavorite()
}
private fun openInWebView() {
val source = presenter.source as? HttpSource ?: return
val url = try {
source.mangaDetailsRequest(presenter.manga).url.toString()
} catch (e: Exception) {
return
}
val activity = activity ?: return
val intent = WebViewActivity.newIntent(activity.applicationContext, source.id, url, presenter.manga
.originalTitle())
startActivity(intent)
}
/**
* Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
*/
private fun prepareToShareManga() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && manga_cover.drawable != null)
GlideApp.with(activity!!).asBitmap().load(presenter.manga).into(object :
CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
presenter.shareManga(resource)
}
override fun onLoadCleared(placeholder: Drawable?) {}
override fun onLoadFailed(errorDrawable: Drawable?) {
shareManga()
}
})
else shareManga()
}
/**
* Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
*/
fun shareManga(cover: File? = null) {
val context = view?.context ?: return
val source = presenter.source as? HttpSource ?: return
val stream = cover?.getUriCompat(context)
try {
val url = source.mangaDetailsRequest(presenter.manga).url.toString()
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/*"
putExtra(Intent.EXTRA_TEXT, url)
putExtra(Intent.EXTRA_TITLE, presenter.manga.currentTitle())
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
if (stream != null) {
clipData = ClipData.newRawUri(null, stream)
}
}
startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
} catch (e: Exception) {
context.toast(e.message)
}
}
/**
* Update FAB with correct drawable.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteDrawable(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
fab_favorite?.setImageResource(
when {
(parentController as MangaController).isLockedFromSearch -> R.drawable.ic_lock_white_24dp
isFavorite -> R.drawable.ic_bookmark_white_24dp
else -> R.drawable.ic_add_to_library_24dp
}
)
}
/**
* Start fetching manga information from source.
*/
private fun fetchMangaFromSource() {
setRefreshing(true)
// Call presenter and start fetching manga information
presenter.fetchMangaFromSource()
}
/**
* Update swipe refresh to stop showing refresh in progress spinner.
*/
fun onFetchMangaDone() {
setRefreshing(false)
}
/**
* Update swipe refresh to start showing refresh in progress spinner.
*/
fun onFetchMangaError(error: Throwable) {
setRefreshing(false)
activity?.toast(error.message)
}
/**
* Set swipe refresh status.
*
* @param value whether it should be refreshing or not.
*/
private fun setRefreshing(value: Boolean) {
swipe_refresh?.isRefreshing = value
}
/**
* Called when the fab is clicked.
*/
private fun onFabClick() {
if ((parentController as MangaController).isLockedFromSearch) {
SecureActivityDelegate.promptLockIfNeeded(activity)
return
}
val manga = presenter.manga
toggleFavorite()
if (manga.favorite) {
val categories = presenter.getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
when {
defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory)
defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category
presenter.moveMangaToCategory(manga, null)
else -> {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
}
showAddedSnack()
} else {
showRemovedSnack()
}
}
private fun showAddedSnack() {
val view = container
snack?.dismiss()
snack = view?.snack(view.context.getString(R.string.manga_added_library))
}
private fun showRemovedSnack() {
val view = container
snack?.dismiss()
if (view != null) {
snack = view.snack(view.context.getString(R.string.manga_removed_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_undo) {
presenter.setFavorite(true)
}
addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (!presenter.manga.favorite)
presenter.confirmDeletion()
}
})
}
(activity as? MainActivity)?.setUndoSnackBar(snack, fab_favorite)
}
}
/**
* Called when the fab is long clicked.
*/
private fun onFabLongClick() {
val manga = presenter.manga
if (!manga.favorite) {
toggleFavorite()
showAddedSnack()
}
val categories = presenter.getCategories()
if (categories.isEmpty()) {
// no categories exist, display a message about adding categories
snack = container?.snack(R.string.action_add_category)
} else {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
val manga = mangas.firstOrNull() ?: return
presenter.moveMangaToCategories(manga, categories)
}
/**
* Add a shortcut of the manga to the home screen
*/
private fun addToHomeScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// TODO are transformations really unsupported or is it just the Pixel Launcher?
createShortcutForShape()
} else {
ChooseShapeDialog(this).showDialog(router)
}
}
/**
* Retrieves the bitmap of the shortcut with the requested shape and calls [createShortcut] when
* the resource is available.
*
* @param i The shape index to apply. Defaults to circle crop transformation.
*/
fun createShortcutForShape(i: Int = 0) {
if (activity == null) return
GlideApp.with(activity!!)
.asBitmap()
.load(presenter.manga)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.apply {
when (i) {
0 -> circleCrop()
1 -> transform(RoundedCorners(5))
2 -> transform(CropSquareTransformation())
3 -> centerCrop().transform(MaskTransformation(R.drawable.mask_star))
}
}
.into(object : CustomTarget<Bitmap>(96, 96) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
createShortcut(resource)
}
override fun onLoadCleared(placeholder: Drawable?) { }
override fun onLoadFailed(errorDrawable: Drawable?) {
activity?.toast(R.string.icon_creation_fail)
}
})
}
/**
* Copies a string to clipboard
*
* @param label Label to show to the user describing the content
* @param content the actual text to copy to the board
*/
private fun copyToClipboard(label: String, content: String, resId: Int) {
if (content.isBlank()) return
val activity = activity ?: return
val view = view ?: return
val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(label, content))
snack = container?.snack(view.context.getString(R.string.copied_to_clipboard, view.context
.getString(resId)))
}
/**
* Perform a global search using the provided query.
*
* @param query the search query to pass to the search controller
*/
private fun performGlobalSearch(query: String) {
if ((parentController as MangaController).isLockedFromSearch)
return
val router = parentController?.router ?: return
router.pushController(CatalogueSearchController(query).withFadeTransaction())
}
/**
* Perform a local search using the provided query.
*
* @param query the search query to pass to the library controller
*/
private fun performLocalSearch(query: String) {
val router = parentController?.router ?: return
val firstController = router.backstack.first()?.controller()
if (firstController is LibraryController && router.backstack.size == 2) {
router.handleBack()
firstController.search(query)
}
}
/**
* Create shortcut using ShortcutManager.
*
* @param icon The image of the shortcut.
*/
private fun createShortcut(icon: Bitmap) {
val activity = activity ?: return
val mangaControllerArgs = parentController?.args ?: return
// Create the shortcut intent.
val shortcutIntent = activity.intent
.setAction(MainActivity.SHORTCUT_MANGA)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(MangaController.MANGA_EXTRA,
mangaControllerArgs.getLong(MangaController.MANGA_EXTRA))
// Check if shortcut placement is supported
if (ShortcutManagerCompat.isRequestPinShortcutSupported(activity)) {
val shortcutId = "manga-shortcut-${presenter.manga.originalTitle()}-${presenter.source.name}"
// Create shortcut info
val shortcutInfo = ShortcutInfoCompat.Builder(activity, shortcutId)
.setShortLabel(presenter.manga.currentTitle())
.setIcon(IconCompat.createWithBitmap(icon))
.setIntent(shortcutIntent)
.build()
val successCallback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create the CallbackIntent.
val intent = ShortcutManagerCompat.createShortcutResultIntent(activity, shortcutInfo)
// Configure the intent so that the broadcast receiver gets the callback successfully.
PendingIntent.getBroadcast(activity, 0, intent, 0)
} else {
NotificationReceiver.shortcutCreatedBroadcast(activity)
}
// Request shortcut.
ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfo,
successCallback.intentSender)
}
}
fun updateTitle() {
setMangaInfo(presenter.manga, presenter.source)
(parentController as? MangaController)?.updateTitle(presenter.manga)
}
private fun setFullCoverToThumb() {
if (setUpFullCover) return
val expandedImageView = manga_cover_full ?: return
val thumbView = manga_cover
expandedImageView.pivotX = 0f
expandedImageView.pivotY = 0f
val layoutParams = expandedImageView.layoutParams
layoutParams.height = thumbView.height
layoutParams.width = thumbView.width
expandedImageView.layoutParams = layoutParams
expandedImageView.scaleType = ImageView.ScaleType.FIT_CENTER
setUpFullCover = expandedImageView.height > 0
}
override fun handleBack(): Boolean {
if (manga_cover_full?.visibility == View.VISIBLE &&
(parentController as? MangaController)?.tabLayout()?.selectedTabPosition == 0)
{
manga_cover_full?.performClick()
return true
}
return super.handleBack()
}
private fun zoomImageFromThumb(thumbView: ImageView, cover: Drawable) {
// If there's an animation in progress, cancel it immediately and proceed with this one.
currentAnimator?.cancel()
// Load the high-resolution "zoomed-in" image.
val expandedImageView = manga_cover_full ?: return
val fullBackdrop = full_backdrop
val image = fullRes ?: return
expandedImageView.setImageDrawable(image)
// Hide the thumbnail and show the zoomed-in view. When the animation
// begins, it will position the zoomed-in view in the place of the
// thumbnail.
thumbView.alpha = 0f
expandedImageView.visibility = View.VISIBLE
fullBackdrop.visibility = View.VISIBLE
// Set the pivot point to 0 to match thumbnail
swipe_refresh.isEnabled = false
val layoutParams = expandedImageView.layoutParams
layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
expandedImageView.layoutParams = layoutParams
// TransitionSet for the full cover because using animation for this SUCKS
val transitionSet = TransitionSet()
val bound = ChangeBounds()
transitionSet.addTransition(bound)
val changeImageTransform = ChangeImageTransform()
transitionSet.addTransition(changeImageTransform)
transitionSet.duration = shortAnimationDuration.toLong()
TransitionManager.beginDelayedTransition(manga_info_layout, transitionSet)
// AnimationSet for backdrop because idk how to use TransitionSet
currentAnimator = AnimatorSet().apply {
play(
ObjectAnimator.ofFloat(fullBackdrop, View.ALPHA, 0f, 0.5f)
)
duration = shortAnimationDuration.toLong()
interpolator = DecelerateInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
TransitionManager.endTransitions(manga_info_layout)
currentAnimator = null
}
override fun onAnimationCancel(animation: Animator) {
TransitionManager.endTransitions(manga_info_layout)
currentAnimator = null
}
})
start()
}
expandedImageView.setOnClickListener {
currentAnimator?.cancel()
val layoutParams = expandedImageView.layoutParams
layoutParams.height = thumbView.height
layoutParams.width = thumbView.width
expandedImageView.layoutParams = layoutParams
// Zoom out back to tc thumbnail
val transitionSet = TransitionSet()
val bound = ChangeBounds()
transitionSet.addTransition(bound)
val changeImageTransform = ChangeImageTransform()
transitionSet.addTransition(changeImageTransform)
transitionSet.duration = shortAnimationDuration.toLong()
TransitionManager.beginDelayedTransition(manga_info_layout, transitionSet)
// Animation to remove backdrop and hide the full cover
currentAnimator = AnimatorSet().apply {
play(ObjectAnimator.ofFloat(fullBackdrop, View.ALPHA, 0f))
duration = shortAnimationDuration.toLong()
interpolator = DecelerateInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
thumbView.alpha = 1f
expandedImageView.visibility = View.GONE
fullBackdrop.visibility = View.GONE
swipe_refresh.isEnabled = true
currentAnimator = null
}
override fun onAnimationCancel(animation: Animator) {
thumbView.alpha = 1f
expandedImageView.visibility = View.GONE
fullBackdrop.visibility = View.GONE
swipe_refresh.isEnabled = true
currentAnimator = null
}
})
start()
}
}
}
}

View File

@ -1,290 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.app.Application
import android.graphics.Bitmap
import android.net.Uri
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.database.models.MangaImpl
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.storage.DiskUtil
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.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.util.Date
/**
* 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()
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
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
db.insertManga(manga).executeAsBlocking()
sendMangaToView()
return manga.favorite
}
fun confirmDeletion() {
coverCache.deleteFromCache(manga.thumbnail_url)
db.resetMangaInfo(manga).executeAsBlocking()
downloadManager.deleteManga(manga, source)
}
fun setFavorite(favorite: Boolean) {
if (manga.favorite == favorite) {
return
}
toggleFavorite()
}
fun shareManga(cover: Bitmap) {
val context = Injekt.get<Application>()
val destDir = File(context.cacheDir, "shared_image")
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(cover, destDir, manga) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.shareManga(file) },
{ view, error -> view.shareManga() }
)
}
private fun saveImage(cover:Bitmap, directory: File, manga: Manga): File? {
directory.mkdirs()
// Build destination file.
val filename = DiskUtil.buildValidFilename("${manga.originalTitle()} - Cover.jpg")
val destFile = File(directory, filename)
val stream: OutputStream = FileOutputStream(destFile)
cover.compress(Bitmap.CompressFormat.JPEG, 75, stream)
stream.flush()
stream.close()
return destFile
}
/**
* 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))
}
fun updateManga(title:String?, author:String?, artist: String?, uri: Uri?,
description: String?, tags: Array<String>?) {
if (manga.source == LocalSource.ID) {
manga.title = if (title.isNullOrBlank()) manga.url else title.trim()
manga.author = author?.trim()
manga.artist = artist?.trim()
manga.description = description?.trim()
val tagsString = tags?.joinToString(", ") { it.capitalize() }
manga.genre = if (tags.isNullOrEmpty()) null else tagsString?.trim()
LocalSource(downloadManager.context).updateMangaInfo(manga)
db.updateMangaInfo(manga).executeAsBlocking()
}
else {
var changed = false
val title = title?.trim()
if (!title.isNullOrBlank() && manga.originalTitle().isBlank()) {
manga.title = title
changed = true
}
else if (title.isNullOrBlank() && manga.currentTitle() != manga.originalTitle()) {
manga.title = manga.originalTitle()
changed = true
} else if (!title.isNullOrBlank() && title != manga.currentTitle()) {
manga.title = "${title}${SManga.splitter}${manga.originalTitle()}"
changed = true
}
val author = author?.trim()
if (author.isNullOrBlank() && manga.currentAuthor() != manga.originalAuthor()) {
manga.author = manga.originalAuthor()
changed = true
} else if (!author.isNullOrBlank() && author != manga.currentAuthor()) {
manga.author = "${author}${SManga.splitter}${manga.originalAuthor() ?: ""}"
changed = true
}
val artist = artist?.trim()
if (artist.isNullOrBlank() && manga.currentArtist() != manga.originalArtist()) {
manga.artist = manga.originalArtist()
changed = true
} else if (!artist.isNullOrBlank() && artist != manga.currentArtist()) {
manga.artist = "${artist}${SManga.splitter}${manga.originalArtist() ?: ""}"
changed = true
}
val description = description?.trim()
if (description.isNullOrBlank() && manga.currentDesc() != manga.originalDesc()) {
manga.description = manga.originalDesc()
changed = true
} else if (!description.isNullOrBlank() && description != manga.currentDesc()) {
manga.description = "${description}${SManga.splitter}${manga.originalDesc() ?: ""}"
changed = true
}
var tagsString = tags?.joinToString(", ")
if ((tagsString.isNullOrBlank() && manga.currentGenres() != manga.originalGenres())
|| tagsString == manga.originalGenres()) {
manga.genre = manga.originalGenres()
changed = true
} else if (!tagsString.isNullOrBlank() && tagsString != manga.currentGenres()) {
tagsString = tags?.joinToString(", ") { it.capitalize() }
manga.genre = "${tagsString}${SManga.splitter}${manga.originalGenres() ?: ""}"
changed = true
}
if (changed) db.updateMangaInfo(manga).executeAsBlocking()
}
if (uri != null) editCoverWithStream(uri)
}
private fun editCoverWithStream(uri: Uri): Boolean {
val inputStream = downloadManager.context.contentResolver.openInputStream(uri) ?:
return false
if (manga.source == LocalSource.ID) {
LocalSource.updateCover(downloadManager.context, manga, inputStream)
return true
}
if (manga.thumbnail_url != null && manga.favorite) {
Injekt.get<PreferencesHelper>().refreshCoversToo().set(false)
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
return true
}
return false
}
}

View File

@ -6,7 +6,6 @@ import android.widget.NumberPicker
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.customview.getCustomView import com.afollestad.materialdialogs.customview.getCustomView
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
@ -15,14 +14,15 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class SetTrackChaptersDialog<T> : DialogController class SetTrackChaptersDialog<T> : DialogController
where T : Controller, T : SetTrackChaptersDialog.Listener { where T : SetTrackChaptersDialog.Listener {
private val item: TrackItem private val item: TrackItem
private lateinit var listener: Listener
constructor(target: T, item: TrackItem) : super(Bundle().apply { constructor(target: T, item: TrackItem) : super(Bundle().apply {
putSerializable(KEY_ITEM_TRACK, item.track) putSerializable(KEY_ITEM_TRACK, item.track)
}) { }) {
targetController = target listener = target
this.item = item this.item = item
} }
@ -45,7 +45,7 @@ class SetTrackChaptersDialog<T> : DialogController
// Remove focus to update selected number // Remove focus to update selected number
val np: NumberPicker = view.findViewById(R.id.chapters_picker) val np: NumberPicker = view.findViewById(R.id.chapters_picker)
np.clearFocus() np.clearFocus()
(targetController as? Listener)?.setChaptersRead(item, np.value) listener.setChaptersRead(item, np.value)
} }
val view = dialog.getCustomView() val view = dialog.getCustomView()

View File

@ -15,14 +15,15 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class SetTrackScoreDialog<T> : DialogController class SetTrackScoreDialog<T> : DialogController
where T : Controller, T : SetTrackScoreDialog.Listener { where T : SetTrackScoreDialog.Listener {
private val item: TrackItem private val item: TrackItem
private lateinit var listener: Listener
constructor(target: T, item: TrackItem) : super(Bundle().apply { constructor(target: T, item: TrackItem) : super(Bundle().apply {
putSerializable(KEY_ITEM_TRACK, item.track) putSerializable(KEY_ITEM_TRACK, item.track)
}) { }) {
targetController = target listener = target
this.item = item this.item = item
} }
@ -46,8 +47,7 @@ class SetTrackScoreDialog<T> : DialogController
val np: NumberPicker = view.findViewById(R.id.score_picker) val np: NumberPicker = view.findViewById(R.id.score_picker)
np.clearFocus() np.clearFocus()
(targetController as? Listener)?.setScore(item, np.value) listener.setScore(item, np.value)
} }

View File

@ -4,7 +4,6 @@ import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
@ -13,14 +12,16 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class SetTrackStatusDialog<T> : DialogController class SetTrackStatusDialog<T> : DialogController
where T : Controller, T : SetTrackStatusDialog.Listener { where T : SetTrackStatusDialog.Listener {
private val item: TrackItem private val item: TrackItem
private lateinit var listener: Listener
constructor(target: T, item: TrackItem) : super(Bundle().apply { constructor(target: T, item: TrackItem) : super(Bundle().apply {
putSerializable(KEY_ITEM_TRACK, item.track) putSerializable(KEY_ITEM_TRACK, item.track)
}) { }) {
targetController = target listener = target
// targetController = target
this.item = item this.item = item
} }
@ -43,7 +44,7 @@ class SetTrackStatusDialog<T> : DialogController
.listItemsSingleChoice(items = statusString, initialSelection = selectedIndex, .listItemsSingleChoice(items = statusString, initialSelection = selectedIndex,
waitForPositiveButton = false) waitForPositiveButton = false)
{ dialog, position, _ -> { dialog, position, _ ->
(targetController as? Listener)?.setStatus(item, position) listener.setStatus(item, position)
dialog.dismiss() dialog.dismiss()
} }
} }

View File

@ -1,11 +1,12 @@
package eu.kanade.tachiyomi.ui.manga.track package eu.kanade.tachiyomi.ui.manga.track
import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.util.view.inflate import eu.kanade.tachiyomi.util.view.inflate
class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHolder>() { class TrackAdapter(controller: OnClickListener) : RecyclerView.Adapter<TrackHolder>() {
var items = emptyList<TrackItem>() var items = emptyList<TrackItem>()
set(value) { set(value) {
@ -34,9 +35,13 @@ class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHold
holder.bind(items[position]) holder.bind(items[position])
} }
fun indexOf(item: TrackService?):Int {
return items.indexOfFirst { item?.id == it.service.id }
}
interface OnClickListener { interface OnClickListener {
fun onLogoClick(position: Int) fun onLogoClick(position: Int)
fun onTitleClick(position: Int) fun onSetClick(position: Int)
fun onStatusClick(position: Int) fun onStatusClick(position: Int)
fun onChaptersClick(position: Int) fun onChaptersClick(position: Int)
fun onScoreClick(position: Int) fun onScoreClick(position: Int)

View File

@ -1,168 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
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.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.invisible
import eu.kanade.tachiyomi.util.view.visible
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)
if ((parentController as MangaController).isLockedFromSearch) {
swipe_refresh.invisible()
unlock_button.visible()
unlock_button.setOnClickListener {
SecureActivityDelegate.promptLockIfNeeded(activity)
}
}
adapter = TrackAdapter(this)
track_recycler.layoutManager = LinearLayoutManager(view.context)
track_recycler.adapter = adapter
track_recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener)
swipe_refresh.isEnabled = false
swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() }
}
private fun showTracking() {
swipe_refresh.visible()
unlock_button.gone()
}
override fun onActivityResumed(activity: Activity) {
super.onActivityResumed(activity)
if (!(parentController as MangaController).isLockedFromSearch) {
showTracking()
}
}
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, item.track != null).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

@ -2,8 +2,8 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.View import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
import eu.kanade.tachiyomi.util.view.visibleIf
import kotlinx.android.synthetic.main.track_item.* import kotlinx.android.synthetic.main.track_item.*
class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
@ -11,32 +11,28 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
init { init {
val listener = adapter.rowClickListener val listener = adapter.rowClickListener
logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) } logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) }
title_container.setOnClickListener { listener.onTitleClick(adapterPosition) } track_set.setOnClickListener { listener.onSetClick(adapterPosition) }
status_container.setOnClickListener { listener.onStatusClick(adapterPosition) } status_container.setOnClickListener { listener.onStatusClick(adapterPosition) }
chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) } chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) }
score_container.setOnClickListener { listener.onScoreClick(adapterPosition) } score_container.setOnClickListener { listener.onScoreClick(adapterPosition) }
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
@Suppress("DEPRECATION")
fun bind(item: TrackItem) { fun bind(item: TrackItem) {
val track = item.track val track = item.track
track_logo.setImageResource(item.service.getLogo()) track_logo.setImageResource(item.service.getLogo())
logo_container.setBackgroundColor(item.service.getLogoColor()) logo_container.setBackgroundColor(item.service.getLogoColor())
track_group.visibleIf(track != null)
if (track != null) { if (track != null) {
track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary)
track_title.isAllCaps = false
track_title.text = track.title
track_chapters.text = "${track.last_chapter_read}/" + track_chapters.text = "${track.last_chapter_read}/" +
if (track.total_chapters > 0) track.total_chapters else "-" if (track.total_chapters > 0) track.total_chapters else "-"
track_status.text = item.service.getStatus(track.status) track_status.text = item.service.getStatus(track.status)
track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track) 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 = ""
} }
} }
fun setProgress(enabled: Boolean) {
progress.visibleIf(enabled)
track_logo.visibleIf(!enabled)
}
} }

View File

@ -1,130 +0,0 @@
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.system.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())
.subscribeFirst({ view, _ -> view.onRefreshDone() },
TrackController::onRefreshError))
} 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

@ -15,11 +15,10 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
import kotlinx.android.synthetic.main.track_controller.* import eu.kanade.tachiyomi.ui.manga.MangaDetailsPresenter
import eu.kanade.tachiyomi.ui.manga.TrackingBottomSheet
import eu.kanade.tachiyomi.util.lang.plusAssign import eu.kanade.tachiyomi.util.lang.plusAssign
import kotlinx.android.synthetic.main.track_search_dialog.view.progress import kotlinx.android.synthetic.main.track_search_dialog.view.*
import kotlinx.android.synthetic.main.track_search_dialog.view.track_search
import kotlinx.android.synthetic.main.track_search_dialog.view.track_search_list
import rx.Subscription import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
@ -41,17 +40,22 @@ class TrackSearchDialog : DialogController {
private var searchTextSubscription: Subscription? = null private var searchTextSubscription: Subscription? = null
private val trackController private lateinit var bottomSheet: TrackingBottomSheet
get() = targetController as TrackController //private val trackController
// get() = targetController as TrackController
private var wasPreviouslyTracked:Boolean = false private var wasPreviouslyTracked:Boolean = false
private lateinit var presenter:MangaDetailsPresenter
constructor(target: TrackController, service: TrackService, wasTracked:Boolean) : super(Bundle() constructor(target: TrackingBottomSheet, service: TrackService, wasTracked:Boolean) : super(Bundle()
.apply { .apply {
putInt(KEY_SERVICE, service.id) putInt(KEY_SERVICE, service.id)
}) { }) {
wasPreviouslyTracked = wasTracked wasPreviouslyTracked = wasTracked
targetController = target bottomSheet = target
presenter = target.presenter
this.service = service this.service = service
} }
@ -97,7 +101,7 @@ class TrackSearchDialog : DialogController {
// Do an initial search based on the manga's title // Do an initial search based on the manga's title
if (savedState == null) { if (savedState == null) {
val title = trackController.presenter.manga.originalTitle() val title = presenter.manga.originalTitle()
view.track_search.append(title) view.track_search.append(title)
search(title) search(title)
} }
@ -129,7 +133,7 @@ class TrackSearchDialog : DialogController {
val view = dialogView ?: return val view = dialogView ?: return
view.progress.visibility = View.VISIBLE view.progress.visibility = View.VISIBLE
view.track_search_list.visibility = View.INVISIBLE view.track_search_list.visibility = View.INVISIBLE
trackController.presenter.search(query, service) presenter.trackSearch(query, service)
} }
fun onSearchResults(results: List<TrackSearch>) { fun onSearchResults(results: List<TrackSearch>) {
@ -153,8 +157,10 @@ class TrackSearchDialog : DialogController {
} }
private fun onPositiveButtonClick() { private fun onPositiveButtonClick() {
trackController.swipe_refresh.isRefreshing = true // trackController.swipe_refresh.isRefreshing = true
trackController.presenter.registerTracking(selectedItem, service) bottomSheet.refreshTrack(service)
presenter.registerTracking(selectedItem,
service)
} }
private companion object { private companion object {

View File

@ -24,6 +24,11 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
@ -231,9 +236,11 @@ class ReaderPresenter(
.andThen(Observable.fromCallable { .andThen(Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter) val chapterPos = chapterList.indexOf(chapter)
ViewerChapters(chapter, ViewerChapters(
chapter,
chapterList.getOrNull(chapterPos - 1), chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1)) chapterList.getOrNull(chapterPos + 1)
)
}) })
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters -> .doOnNext { newChapters ->
@ -464,9 +471,11 @@ class ReaderPresenter(
notifier.onClear() notifier.onClear()
// Pictures directory. // Pictures directory.
val destDir = File(Environment.getExternalStorageDirectory().absolutePath + val destDir = File(
Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES + File.separator + Environment.DIRECTORY_PICTURES +
File.separator + "Tachiyomi") File.separator + "Tachiyomi"
)
// Copy file in background. // Copy file in background.
Observable.fromCallable { saveImage(page, destDir, manga) } Observable.fromCallable { saveImage(page, destDir, manga) }
@ -568,27 +577,24 @@ class ReaderPresenter(
val trackManager = Injekt.get<TrackManager>() val trackManager = Injekt.get<TrackManager>()
db.getTracks(manga).asRxSingle() // We wan't these to execute even if the presenter is destroyed so launch on GlobalScope
.flatMapCompletable { trackList -> GlobalScope.launch {
Completable.concat(trackList.map { track -> withContext(Dispatchers.IO) {
val trackList = db.getTracks(manga).executeAsBlocking()
trackList.map { track ->
val service = trackManager.getService(track.sync_id) val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) { if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
try {
track.last_chapter_read = chapterRead track.last_chapter_read = chapterRead
service.update(track)
// We wan't these to execute even if the presenter is destroyed and leaks db.insertTrack(track).executeAsBlocking()
// for a while. The view can still be garbage collected. } catch (e: Exception) {
Observable.defer { service.update(track) } Timber.e(e)
.map { db.insertTrack(track).executeAsBlocking() } }
.toCompletable() }
.onErrorComplete() }
} else {
Completable.complete()
} }
})
} }
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
} }
/** /**
@ -629,5 +635,4 @@ class ReaderPresenter(
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
} }

View File

@ -5,12 +5,12 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialog
import androidx.core.widget.NestedScrollView
import android.widget.CompoundButton import android.widget.CompoundButton
import android.widget.Spinner import android.widget.Spinner
import androidx.core.widget.NestedScrollView
import com.f2prateek.rx.preferences.Preference import com.f2prateek.rx.preferences.Preference
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -115,7 +115,7 @@ class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDia
show_page_number.bindToPreference(preferences.showPageNumber()) show_page_number.bindToPreference(preferences.showPageNumber())
fullscreen.bindToPreference(preferences.fullscreen()) fullscreen.bindToPreference(preferences.fullscreen())
keepscreen.bindToPreference(preferences.keepScreenOn()) keepscreen.bindToPreference(preferences.keepScreenOn())
long_tap.bindToPreference(preferences.readWithLongTap()) always_show_chapter_transition.bindToPreference(preferences.alwaysShowChapterTransition())
} }
/** /**

View File

@ -46,6 +46,9 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe
var readerTheme = 0 var readerTheme = 0
private set private set
var alwaysShowChapterTransition = true
private set
init { init {
preferences.readWithTapping() preferences.readWithTapping()
.register({ tappingEnabled = it }) .register({ tappingEnabled = it })
@ -76,6 +79,9 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe
preferences.readerTheme() preferences.readerTheme()
.register({ readerTheme = it }) .register({ readerTheme = it })
preferences.alwaysShowChapterTransition()
.register({ alwaysShowChapterTransition = it })
} }
fun unsubscribe() { fun unsubscribe() {

View File

@ -144,8 +144,10 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
Timber.d("onReaderPageSelected: ${page.number}/${pages.size}") Timber.d("onReaderPageSelected: ${page.number}/${pages.size}")
activity.onPageSelected(page) activity.onPageSelected(page)
if (page === pages.last()) { // Preload next chapter once we're within the last 3 pages of the current chapter
Timber.d("Request preload next chapter because we're at the last page") val inPreloadRange = pages.size - page.number < 3
if (inPreloadRange) {
Timber.d("Request preload next chapter because we're at page ${page.number} of ${pages.size}")
adapter.nextTransition?.to?.let { adapter.nextTransition?.to?.let {
activity.requestPreloadChapter(it) activity.requestPreloadChapter(it)
} }
@ -185,7 +187,8 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
*/ */
private fun setChaptersInternal(chapters: ViewerChapters) { private fun setChaptersInternal(chapters: ViewerChapters) {
Timber.d("setChaptersInternal") Timber.d("setChaptersInternal")
adapter.setChapters(chapters) var forceTransition = config.alwaysShowChapterTransition || adapter.items.getOrNull(pager.currentItem) is ChapterTransition
adapter.setChapters(chapters, forceTransition)
// Layout the pager once a chapter is being set // Layout the pager once a chapter is being set
if (pager.visibility == View.GONE) { if (pager.visibility == View.GONE) {

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.widget.ViewPagerAdapter import eu.kanade.tachiyomi.widget.ViewPagerAdapter
@ -27,7 +28,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
* next/previous chapter to allow seamless transitions and inverting the pages if the viewer * next/previous chapter to allow seamless transitions and inverting the pages if the viewer
* has R2L direction. * has R2L direction.
*/ */
fun setChapters(chapters: ViewerChapters) { fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
val newItems = mutableListOf<Any>() val newItems = mutableListOf<Any>()
// Add previous chapter pages and transition. // Add previous chapter pages and transition.
@ -39,7 +40,11 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
newItems.addAll(prevPages.takeLast(2)) newItems.addAll(prevPages.takeLast(2))
} }
} }
// Skip transition page if the chapter is loaded & current page is not a transition page
if (forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter)) newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
}
// Add current chapter. // Add current chapter.
val currPages = chapters.currChapter.pages val currPages = chapters.currChapter.pages
@ -49,7 +54,13 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
// Add next chapter transition and pages. // Add next chapter transition and pages.
nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter) nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)
.also { newItems.add(it) } .also {
if (forceTransition ||
chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(it)
}
}
if (chapters.nextChapter != null) { if (chapters.nextChapter != null) {
// Add at most two pages, because this chapter will be selected before the user can // Add at most two pages, because this chapter will be selected before the user can
// swap more pages. // swap more pages.

View File

@ -6,6 +6,7 @@ import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
@ -24,7 +25,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R
* Updates this adapter with the given [chapters]. It handles setting a few pages of the * Updates this adapter with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions. * next/previous chapter to allow seamless transitions.
*/ */
fun setChapters(chapters: ViewerChapters) { fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
val newItems = mutableListOf<Any>() val newItems = mutableListOf<Any>()
// Add previous chapter pages and transition. // Add previous chapter pages and transition.
@ -36,7 +37,11 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R
newItems.addAll(prevPages.takeLast(2)) newItems.addAll(prevPages.takeLast(2))
} }
} }
// Skip transition page if the chapter is loaded & current page is not a transition page
if (forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter)) newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
}
// Add current chapter. // Add current chapter.
val currPages = chapters.currChapter.pages val currPages = chapters.currChapter.pages
@ -45,7 +50,10 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R
} }
// Add next chapter transition and pages. // Add next chapter transition and pages.
if (forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)) newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
}
if (chapters.nextChapter != null) { if (chapters.nextChapter != null) {
// Add at most two pages, because this chapter will be selected before the user can // Add at most two pages, because this chapter will be selected before the user can
// swap more pages. // swap more pages.

View File

@ -34,6 +34,9 @@ class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) {
var doubleTapAnimDuration = 500 var doubleTapAnimDuration = 500
private set private set
var alwaysShowChapterTransition = true
private set
init { init {
preferences.readWithTapping() preferences.readWithTapping()
.register({ tappingEnabled = it }) .register({ tappingEnabled = it })
@ -52,6 +55,9 @@ class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) {
preferences.readWithVolumeKeysInverted() preferences.readWithVolumeKeysInverted()
.register({ volumeKeysInverted = it }) .register({ volumeKeysInverted = it })
preferences.alwaysShowChapterTransition()
.register({ alwaysShowChapterTransition = it })
} }
fun unsubscribe() { fun unsubscribe() {

View File

@ -142,9 +142,11 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
Timber.d("onPageSelected: ${page.number}/${pages.size}") Timber.d("onPageSelected: ${page.number}/${pages.size}")
activity.onPageSelected(page) activity.onPageSelected(page)
if (page === pages.last()) { // Preload next chapter once we're within the last 3 pages of the current chapter
Timber.d("Request preload next chapter because we're at the last page") val inPreloadRange = pages.size - page.number < 3
val transition = adapter.items.getOrNull(position + 1) as? ChapterTransition.Next if (inPreloadRange) {
Timber.d("Request preload next chapter because we're at page ${page.number} of ${pages.size}")
val transition = adapter.items.getOrNull(pages.size + 1) as? ChapterTransition.Next
if (transition?.to != null) { if (transition?.to != null) {
activity.requestPreloadChapter(transition.to) activity.requestPreloadChapter(transition.to)
} }
@ -172,7 +174,8 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
*/ */
override fun setChapters(chapters: ViewerChapters) { override fun setChapters(chapters: ViewerChapters) {
Timber.d("setChapters") Timber.d("setChapters")
adapter.setChapters(chapters) var forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition
adapter.setChapters(chapters, forceTransition)
if (recycler.visibility == View.GONE) { if (recycler.visibility == View.GONE) {
Timber.d("Recycler first layout") Timber.d("Recycler first layout")

View File

@ -11,7 +11,6 @@ import androidx.core.content.ContextCompat
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.bluelinelabs.conductor.RouterTransaction
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
@ -34,6 +33,13 @@ class SettingsDownloadController : SettingsController() {
override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) {
titleRes = R.string.pref_category_downloads titleRes = R.string.pref_category_downloads
preference {
titleRes = R.string.label_download_queue
onClick {
router.pushController(DownloadController().withFadeTransaction())
}
}
preference { preference {
key = Keys.downloadsDirectory key = Keys.downloadsDirectory
titleRes = R.string.pref_download_directory titleRes = R.string.pref_download_directory

View File

@ -45,11 +45,11 @@ class SettingsGeneralController : SettingsController() {
intListPreference(activity) { intListPreference(activity) {
key = Keys.theme key = Keys.theme
titleRes = R.string.pref_theme titleRes = R.string.pref_theme
entriesRes = arrayOf(R.string.light_theme, R.string.white_theme, R.string.dark_theme, entriesRes = arrayOf(R.string.white_theme, R.string.light_theme, R.string.dark_theme,
R.string.amoled_theme, R.string.darkblue_theme, R.string.amoled_theme, R.string.darkblue_theme,
R.string.system_theme, R.string.sysyem_white_theme, R.string.system_amoled_theme, R.string R.string.sysyem_white_theme, R.string.system_theme, R.string.system_amoled_theme,
.system_darkblue_theme) R.string.system_darkblue_theme)
entryValues = listOf(1, 8, 2, 3, 4, 5, 9, 6, 7) entryValues = listOf(8, 1, 2, 3, 4, 9, 5, 6, 7)
defaultValue = 9 defaultValue = 9
onChange { onChange {

View File

@ -8,7 +8,6 @@ import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.extension.ExtensionController
import eu.kanade.tachiyomi.ui.migration.MigrationController import eu.kanade.tachiyomi.ui.migration.MigrationController
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
@ -24,13 +23,6 @@ class SettingsMainController : SettingsController() {
val tintColor = context.getResourceColor(R.attr.colorAccent) val tintColor = context.getResourceColor(R.attr.colorAccent)
extensionPreference {
iconRes = R.drawable.ic_extension_black_24dp
iconTint = tintColor
titleRes = R.string.label_extensions
onClick { navigateTo(ExtensionController()) }
}
preference { preference {
iconRes = R.drawable.ic_tune_white_24dp iconRes = R.drawable.ic_tune_white_24dp
iconTint = tintColor iconTint = tintColor

View File

@ -87,6 +87,12 @@ class SettingsReaderController : SettingsController() {
defaultValue = false defaultValue = false
} }
} }
switchPreference {
key = Keys.alwaysShowChapterTransition
titleRes = R.string.pref_always_show_chapter_transition
defaultValue = true
}
preferenceCategory { preferenceCategory {
titleRes = R.string.pager_viewer titleRes = R.string.pager_viewer

View File

@ -50,7 +50,7 @@ class SettingsTrackingController : SettingsController(),
} }
trackPreference(trackManager.kitsu) { trackPreference(trackManager.kitsu) {
onClick { onClick {
val dialog = TrackLoginDialog(trackManager.kitsu) val dialog = TrackLoginDialog(trackManager.kitsu, context.getString(R.string.email))
dialog.targetController = this@SettingsTrackingController dialog.targetController = this@SettingsTrackingController
dialog.showDialog(router) dialog.showDialog(router)
} }

View File

@ -2,21 +2,25 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import rx.android.schedulers.AndroidSchedulers import kotlinx.coroutines.CoroutineScope
import rx.schedulers.Schedulers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class AnilistLoginActivity : AppCompatActivity() { class AnilistLoginActivity : AppCompatActivity() {
private val trackManager: TrackManager by injectLazy() private val trackManager: TrackManager by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -26,14 +30,10 @@ class AnilistLoginActivity : AppCompatActivity() {
val regex = "(?:access_token=)(.*?)(?:&)".toRegex() val regex = "(?:access_token=)(.*?)(?:&)".toRegex()
val matchResult = regex.find(intent.data?.fragment.toString()) val matchResult = regex.find(intent.data?.fragment.toString())
if (matchResult?.groups?.get(1) != null) { if (matchResult?.groups?.get(1) != null) {
scope.launch {
trackManager.aniList.login(matchResult.groups[1]!!.value) trackManager.aniList.login(matchResult.groups[1]!!.value)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
returnToSettings() returnToSettings()
}, { }
returnToSettings()
})
} else { } else {
trackManager.aniList.logout() trackManager.aniList.logout()
returnToSettings() returnToSettings()
@ -47,5 +47,4 @@ class AnilistLoginActivity : AppCompatActivity() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)
} }
} }

View File

@ -2,13 +2,18 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -17,6 +22,8 @@ class BangumiLoginActivity : AppCompatActivity() {
private val trackManager: TrackManager by injectLazy() private val trackManager: TrackManager by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -25,14 +32,10 @@ class BangumiLoginActivity : AppCompatActivity() {
val code = intent.data?.getQueryParameter("code") val code = intent.data?.getQueryParameter("code")
if (code != null) { if (code != null) {
scope.launch {
trackManager.bangumi.login(code) trackManager.bangumi.login(code)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
returnToSettings() returnToSettings()
}, { }
returnToSettings()
})
} else { } else {
trackManager.bangumi.logout() trackManager.bangumi.logout()
returnToSettings() returnToSettings()
@ -46,5 +49,4 @@ class BangumiLoginActivity : AppCompatActivity() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)
} }
} }

View File

@ -2,21 +2,25 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import rx.android.schedulers.AndroidSchedulers import kotlinx.coroutines.CoroutineScope
import rx.schedulers.Schedulers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class ShikimoriLoginActivity : AppCompatActivity() { class ShikimoriLoginActivity : AppCompatActivity() {
private val trackManager: TrackManager by injectLazy() private val trackManager: TrackManager by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -25,14 +29,10 @@ class ShikimoriLoginActivity : AppCompatActivity() {
val code = intent.data?.getQueryParameter("code") val code = intent.data?.getQueryParameter("code")
if (code != null) { if (code != null) {
scope.launch {
trackManager.shikimori.login(code) trackManager.shikimori.login(code)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
returnToSettings() returnToSettings()
}, { }
returnToSettings()
})
} else { } else {
trackManager.shikimori.logout() trackManager.shikimori.logout()
returnToSettings() returnToSettings()
@ -46,5 +46,4 @@ class ShikimoriLoginActivity : AppCompatActivity() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)
} }
} }

View File

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.util.system
import android.webkit.WebView
private val WEBVIEW_UA_VERSION_REGEX by lazy {
Regex(""".*Chrome/(\d+)\..*""")
}
private const val MINIMUM_WEBVIEW_VERSION = 70
fun WebView.isOutdated(): Boolean {
return getWebviewMajorVersion(this) < MINIMUM_WEBVIEW_VERSION
}
// Based on https://stackoverflow.com/a/29218966
private fun getWebviewMajorVersion(webview: WebView): Int {
val originalUA: String = webview.settings.userAgentString
// Next call to getUserAgentString() will get us the default
webview.settings.userAgentString = null
val uaRegexMatch = WEBVIEW_UA_VERSION_REGEX.matchEntire(webview.settings.userAgentString)
val webViewVersion: Int = if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) {
uaRegexMatch.groupValues[1].toInt()
} else {
0
}
// Revert to original UA string
webview.settings.userAgentString = originalUA
return webViewVersion
}

View File

@ -169,7 +169,7 @@ inline val View.marginLeft: Int
object RecyclerWindowInsetsListener : View.OnApplyWindowInsetsListener { object RecyclerWindowInsetsListener : View.OnApplyWindowInsetsListener {
override fun onApplyWindowInsets(v: View, insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(v: View, insets: WindowInsets): WindowInsets {
v.setPadding(0,0,0,insets.systemWindowInsetBottom) v.updatePaddingRelative(bottom = insets.systemWindowInsetBottom)
//v.updatePaddingRelative(bottom = v.paddingBottom + insets.systemWindowInsetBottom) //v.updatePaddingRelative(bottom = v.paddingBottom + insets.systemWindowInsetBottom)
return insets return insets
} }
@ -294,10 +294,12 @@ data class ViewPaddingState(
) )
fun Controller.setOnQueryTextChangeListener(searchView: SearchView, f: (text: String?) -> Boolean) { fun Controller.setOnQueryTextChangeListener(searchView: SearchView, onlyOnSubmit:Boolean = false,
f: (text: String?) -> Boolean) {
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
if (router.backstack.lastOrNull()?.controller() == this@setOnQueryTextChangeListener) { if (!onlyOnSubmit && router.backstack.lastOrNull()?.controller() ==
this@setOnQueryTextChangeListener) {
return f(newText) return f(newText)
} }
return false return false
@ -312,36 +314,40 @@ fun Controller.setOnQueryTextChangeListener(searchView: SearchView, f: (text: St
}) })
} }
fun Controller.scrollViewWith(recycler: RecyclerView, fun Controller.scrollViewWith(
recycler: RecyclerView,
padBottom: Boolean = false, padBottom: Boolean = false,
swipeRefreshLayout: SwipeRefreshLayout? = null, swipeRefreshLayout: SwipeRefreshLayout? = null,
f: ((WindowInsets) -> Unit)? = null) { afterInsets: ((WindowInsets) -> Unit)? = null) {
var statusBarHeight = -1 var statusBarHeight = -1
activity!!.appbar.y = 0f activity?.appbar?.y = 0f
recycler.doOnApplyWindowInsets { view, insets, _ ->
val attrsArray = intArrayOf(android.R.attr.actionBarSize) val attrsArray = intArrayOf(android.R.attr.actionBarSize)
val array = view.context.obtainStyledAttributes(attrsArray) val array = recycler.context.obtainStyledAttributes(attrsArray)
val headerHeight = insets.systemWindowInsetTop + array.getDimensionPixelSize(0, 0) val appBarHeight = array.getDimensionPixelSize(0, 0)
array.recycle()
recycler.doOnApplyWindowInsets { view, insets, _ ->
val headerHeight = insets.systemWindowInsetTop + appBarHeight
view.updatePaddingRelative( view.updatePaddingRelative(
top = headerHeight, top = headerHeight,
bottom = if (padBottom) insets.systemWindowInsetBottom else 0 bottom = if (padBottom) insets.systemWindowInsetBottom else view.paddingBottom
)
swipeRefreshLayout?.setProgressViewOffset(
false, headerHeight + (-60).dpToPx, headerHeight
) )
swipeRefreshLayout?.setProgressViewOffset(false, headerHeight + (-60).dpToPx,
headerHeight + 10.dpToPx)
statusBarHeight = insets.systemWindowInsetTop statusBarHeight = insets.systemWindowInsetTop
array.recycle() afterInsets?.invoke(insets)
f?.invoke(insets)
} }
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
if (router.backstack.lastOrNull()?.controller() == this@scrollViewWith && if (router?.backstack?.lastOrNull()?.controller() == this@scrollViewWith &&
statusBarHeight > -1 && statusBarHeight > -1 &&
activity != null &&
activity!!.appbar.height > 0) { activity!!.appbar.height > 0) {
activity!!.appbar.y -= dy activity!!.appbar.y -= dy
activity!!.appbar.y = clamp( activity!!.appbar.y = clamp(
activity!!.appbar.y, activity!!.appbar.y,
-activity!!.appbar.height.toFloat(),// + statusBarHeight, -activity!!.appbar.height.toFloat(),
0f 0f
) )
} }
@ -350,8 +356,8 @@ fun Controller.scrollViewWith(recycler: RecyclerView,
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState) super.onScrollStateChanged(recyclerView, newState)
if (newState == RecyclerView.SCROLL_STATE_IDLE) { if (newState == RecyclerView.SCROLL_STATE_IDLE) {
if (router.backstack.lastOrNull()?.controller() == this@scrollViewWith && if (router?.backstack?.lastOrNull()?.controller() == this@scrollViewWith &&
statusBarHeight > -1 && statusBarHeight > -1 && activity != null &&
activity!!.appbar.height > 0) { activity!!.appbar.height > 0) {
val halfWay = abs((-activity!!.appbar.height.toFloat()) / 2) val halfWay = abs((-activity!!.appbar.height.toFloat()) / 2)
val shortAnimationDuration = resources?.getInteger( val shortAnimationDuration = resources?.getInteger(

View File

@ -28,7 +28,7 @@ class ExtensionPreference @JvmOverloads constructor(context: Context, attrs: Att
val updates = Injekt.get<PreferencesHelper>().extensionUpdatesCount().getOrDefault() val updates = Injekt.get<PreferencesHelper>().extensionUpdatesCount().getOrDefault()
if (updates > 0) { if (updates > 0) {
extUpdateText.text = context.resources.getQuantityString(R.plurals extUpdateText.text = context.resources.getQuantityString(R.plurals
.extensions_updates_available, updates, updates) .updates_available, updates, updates)
extUpdateText.visible() extUpdateText.visible()
} }
else { else {

View File

@ -13,11 +13,18 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.SimpleTextWatcher import eu.kanade.tachiyomi.widget.SimpleTextWatcher
import kotlinx.android.synthetic.main.pref_account_login.view.* import kotlinx.android.synthetic.main.pref_account_login.view.login
import kotlinx.android.synthetic.main.pref_account_login.view.password
import kotlinx.android.synthetic.main.pref_account_login.view.show_password
import kotlinx.android.synthetic.main.pref_account_login.view.username_label
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import rx.Subscription import rx.Subscription
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
abstract class LoginDialogPreference(bundle: Bundle? = null) : DialogController(bundle) { abstract class LoginDialogPreference(private val usernameLabel: String? = null, bundle: Bundle? = null) :
DialogController(bundle), CoroutineScope {
var v: View? = null var v: View? = null
private set private set
@ -53,6 +60,10 @@ abstract class LoginDialogPreference(bundle: Bundle? = null) : DialogController(
password.transformationMethod = PasswordTransformationMethod() password.transformationMethod = PasswordTransformationMethod()
} }
if (!usernameLabel.isNullOrEmpty()) {
username_label.text = usernameLabel
}
login.setMode(ActionProcessButton.Mode.ENDLESS) login.setMode(ActionProcessButton.Mode.ENDLESS)
login.setOnClickListener { checkLogin() } login.setOnClickListener { checkLogin() }

View File

@ -16,7 +16,7 @@ import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class SourceLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) { class SourceLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle = bundle) {
private val source = Injekt.get<SourceManager>().get(args.getLong("key")) as LoginSource private val source = Injekt.get<SourceManager>().get(args.getLong("key")) as LoginSource

View File

@ -6,22 +6,25 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.android.synthetic.main.pref_account_login.view.dialog_title import kotlinx.android.synthetic.main.pref_account_login.view.*
import kotlinx.android.synthetic.main.pref_account_login.view.login import kotlinx.coroutines.Dispatchers
import kotlinx.android.synthetic.main.pref_account_login.view.password import kotlinx.coroutines.launch
import kotlinx.android.synthetic.main.pref_account_login.view.username import kotlinx.coroutines.withContext
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.coroutines.CoroutineContext
class TrackLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) { class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) :
LoginDialogPreference(usernameLabel, bundle) {
private val service = Injekt.get<TrackManager>().getService(args.getInt("key"))!! private val service = Injekt.get<TrackManager>().getService(args.getInt("key"))!!
override var canLogout = true override var canLogout = true
constructor(service: TrackService) : this(Bundle().apply { putInt("key", service.id) }) constructor(service: TrackService) : this(service, null)
constructor(service: TrackService, usernameLabel: String?) :
this(usernameLabel, Bundle().apply { putInt("key", service.id) })
override fun setCredentialsOnView(view: View) = with(view) { override fun setCredentialsOnView(view: View) = with(view) {
dialog_title.text = context.getString(R.string.login_title, service.name) dialog_title.text = context.getString(R.string.login_title, service.name)
@ -29,6 +32,9 @@ class TrackLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) {
password.setText(service.getPassword()) password.setText(service.getPassword())
} }
override val coroutineContext: CoroutineContext
get() = TODO("Not yet implemented")
override fun checkLogin() { override fun checkLogin() {
requestSubscription?.unsubscribe() requestSubscription?.unsubscribe()
@ -40,17 +46,21 @@ class TrackLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) {
val user = username.text.toString() val user = username.text.toString()
val pass = password.text.toString() val pass = password.text.toString()
requestSubscription = service.login(user, pass) launch {
.subscribeOn(Schedulers.io()) try {
.observeOn(AndroidSchedulers.mainThread()) withContext(Dispatchers.IO) {
.subscribe({ service.login(user, pass)
}
withContext(Dispatchers.Main) {
dialog?.dismiss() dialog?.dismiss()
context.toast(R.string.login_success) context.toast(R.string.login_success)
}, { error -> }
} catch (error: Exception) {
login.progress = -1 login.progress = -1
login.setText(R.string.unknown_error) login.setText(R.string.unknown_error)
error.message?.let { context.toast(it) } error.message?.let { context.toast(it) }
}) }
}
} }
} }
@ -69,5 +79,4 @@ class TrackLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) {
interface Listener { interface Listener {
fun trackDialogClosed(service: TrackService) fun trackDialogClosed(service: TrackService)
} }
} }

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false"
android:alpha="0.12" android:color="?attr/colorOnSurface" />
<item android:color="?colorPrimary" />
</selector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false"
android:alpha="0.12" android:color="?attr/colorOnSurface" />
<item android:color="?colorAccent" />
</selector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@android:color/white" android:state_enabled="true"/>
<item android:alpha="0.38" android:color="?attr/colorOnSurface"/>
</selector>

View File

@ -2,6 +2,7 @@
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24.0" android:viewportWidth="24.0"
android:tint="?actionBarTintColor"
android:viewportHeight="24.0"> android:viewportHeight="24.0">
<path <path
android:fillColor="#FF000000" android:fillColor="#FF000000"

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/auto_checkbox"
android:layout_marginStart="8dp"
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_auto_check_extensions">
</com.google.android.material.checkbox.MaterialCheckBox>

View File

@ -1,15 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<FrameLayout
android:id="@+id/frame_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler" android:id="@+id/recycler"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:clipToPadding="false" android:clipToPadding="false"
android:layout_marginBottom="30dp"
android:paddingBottom="20dp"
tools:listitem="@layout/catalogue_main_controller_card" /> tools:listitem="@layout/catalogue_main_controller_card" />
</FrameLayout> </FrameLayout>
<View
android:id="@+id/shadow"
android:layout_width="match_parent"
android:layout_height="24dp"
android:alpha="0.5"
android:background="@drawable/shape_gradient_top_shadow"
android:paddingBottom="10dp"
app:layout_anchorGravity="top"
app:layout_anchor="@id/ext_bottom_sheet" />
<!-- Adding bottom sheet after main content -->
<include layout="@layout/extensions_bottom_sheet"/>
<View
android:id="@+id/shadow2"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_gravity="bottom"
android:alpha="0.25"
android:background="@drawable/shape_gradient_top_shadow" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
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:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="@dimen/material_component_lists_two_line_height"
android:background="?attr/selectable_list_drawable">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:layout_marginStart="12dp"
android:paddingStart="0dp"
android:paddingEnd="8dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/source_browse"
tools:text="Source title" />
<Button
android:id="@+id/source_browse"
style="@style/Theme.Widget.Button.Borderless.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/update_check_look_for_updates"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View File

@ -2,13 +2,14 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/chapter_layout"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectable_list_drawable"> android:background="?attr/selectable_list_drawable">
<TextView <TextView
android:id="@+id/chapter_title" android:id="@+id/chapter_title"
style="@style/TextAppearance.Regular.Body1" style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
@ -23,7 +24,7 @@
<TextView <TextView
android:id="@+id/chapter_scanlator" android:id="@+id/chapter_scanlator"
style="@style/TextAppearance.Regular.Caption.Hint" style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"

Some files were not shown because too many files have changed in this diff Show More