mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-23 20:51:48 +01:00
merge md2 stuff in
initial changes for tracking
This commit is contained in:
commit
f83a6bd489
@ -21,14 +21,14 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
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.util.storage.DiskUtil
|
||||
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.toast
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
@ -405,7 +405,7 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
val newIntent =
|
||||
Intent(context, MainActivity::class.java).setAction(MainActivity.SHORTCUT_MANGA)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra(MangaController.MANGA_EXTRA, manga.id)
|
||||
.putExtra(MangaDetailsController.MANGA_EXTRA, manga.id)
|
||||
.putExtra("notificationId", manga.id.hashCode())
|
||||
.putExtra("groupId", groupId)
|
||||
return PendingIntent.getActivity(
|
||||
|
@ -145,6 +145,8 @@ object PreferenceKeys {
|
||||
|
||||
const val keepCatSort = "keep_cat_sort"
|
||||
|
||||
const val alwaysShowChapterTransition = "always_show_chapter_transition"
|
||||
|
||||
@Deprecated("Use the preferences of the source")
|
||||
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
|
||||
|
||||
@ -153,6 +155,7 @@ object PreferenceKeys {
|
||||
|
||||
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
|
||||
|
||||
|
||||
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||
|
||||
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||
|
@ -248,4 +248,6 @@ class PreferencesHelper(val context: Context) {
|
||||
fun keepCatSort() = rxPrefs.getInteger(Keys.keepCatSort, 0)
|
||||
|
||||
fun hideFiltersAtStart() = rxPrefs.getBoolean("hide_filters_at_start", false)
|
||||
|
||||
fun alwaysShowChapterTransition() = rxPrefs.getBoolean(Keys.alwaysShowChapterTransition, true)
|
||||
}
|
||||
|
@ -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.bangumi.Bangumi
|
||||
|
||||
class TrackManager(private val context: Context) {
|
||||
class TrackManager(context: Context) {
|
||||
|
||||
companion object {
|
||||
const val MYANIMELIST = 1
|
||||
|
@ -7,8 +7,6 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import okhttp3.OkHttpClient
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
abstract class TrackService(val id: Int) {
|
||||
@ -39,17 +37,17 @@ abstract class TrackService(val id: Int) {
|
||||
|
||||
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
|
||||
open fun logout() {
|
||||
|
@ -8,8 +8,6 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||
@ -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)
|
||||
}
|
||||
|
||||
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) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
// If user was using API v1 fetch library_id
|
||||
if (track.library_id == null || track.library_id!! == 0L){
|
||||
return api.findLibManga(track, getUsername().toInt()).flatMap {
|
||||
if (it == null) {
|
||||
throw Exception("$track not found on user library")
|
||||
}
|
||||
track.library_id = it.library_id
|
||||
api.updateLibManga(track)
|
||||
if (track.library_id == null || track.library_id!! == 0L) {
|
||||
val libManga = api.findLibManga(track, getUsername().toInt())
|
||||
|
||||
if (libManga == null) {
|
||||
throw Exception("$track not found on user library")
|
||||
}
|
||||
track.library_id = libManga.library_id
|
||||
}
|
||||
|
||||
return api.updateLibManga(track)
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track, getUsername().toInt())
|
||||
.flatMap { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.library_id = remoteTrack.library_id
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
override suspend fun bind(track: Track): Track {
|
||||
val remoteTrack = api.findLibManga(track, getUsername().toInt())
|
||||
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.library_id = remoteTrack.library_id
|
||||
return update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
return add(track)
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
override suspend fun search(query: String): List<TrackSearch> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.getLibManga(track, getUsername().toInt())
|
||||
.map { remoteTrack ->
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
track
|
||||
}
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val remoteTrack = api.getLibManga(track, getUsername().toInt())
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
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)
|
||||
interceptor.setAuth(oauth)
|
||||
return api.getCurrentUser().map { (username, scoreType) ->
|
||||
scorePreference.set(scoreType)
|
||||
saveCredentials(username.toString(), oauth.access_token)
|
||||
}.doOnError{
|
||||
|
||||
try {
|
||||
val currentUser = api.getCurrentUser()
|
||||
scorePreference.set(currentUser.second)
|
||||
saveCredentials(currentUser.first.toString(), oauth.access_token)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
logout()
|
||||
}.toCompletable()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun logout() {
|
||||
|
@ -11,24 +11,19 @@ import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import okhttp3.MediaType
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import rx.Observable
|
||||
import java.util.Calendar
|
||||
|
||||
|
||||
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|
||||
private val parser = JsonParser()
|
||||
private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
fun addLibManga(track: Track): Observable<Track> {
|
||||
suspend fun addLibManga(track: Track): Track {
|
||||
val query = """
|
||||
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|
||||
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
|
||||
@ -38,34 +33,33 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|}
|
||||
|""".trimMargin()
|
||||
val variables = jsonObject(
|
||||
"mangaId" to track.media_id,
|
||||
"progress" to track.last_chapter_read,
|
||||
"status" to track.toAnilistStatus()
|
||||
"mangaId" to track.media_id,
|
||||
"progress" to track.last_chapter_read,
|
||||
"status" to track.toAnilistStatus()
|
||||
)
|
||||
val payload = jsonObject(
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
)
|
||||
val body = payload.toString().toRequestBody(jsonMime)
|
||||
val request = Request.Builder()
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
netResponse.close()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = parser.parse(responseBody).obj
|
||||
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
|
||||
track
|
||||
}
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
val netResponse = authClient.newCall(request).await()
|
||||
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
netResponse.close()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = JsonParser().parse(responseBody).obj
|
||||
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track): Observable<Track> {
|
||||
suspend fun updateLibManga(track: Track): Track {
|
||||
val query = """
|
||||
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|
||||
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|
||||
@ -76,28 +70,25 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|}
|
||||
|""".trimMargin()
|
||||
val variables = jsonObject(
|
||||
"listId" to track.library_id,
|
||||
"progress" to track.last_chapter_read,
|
||||
"status" to track.toAnilistStatus(),
|
||||
"score" to track.score.toInt()
|
||||
"listId" to track.library_id,
|
||||
"progress" to track.last_chapter_read,
|
||||
"status" to track.toAnilistStatus(),
|
||||
"score" to track.score.toInt()
|
||||
)
|
||||
val payload = jsonObject(
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
)
|
||||
val body = payload.toString().toRequestBody(jsonMime)
|
||||
val request = Request.Builder()
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
track
|
||||
}
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
authClient.newCall(request).execute()
|
||||
return track
|
||||
}
|
||||
|
||||
fun search(search: String): Observable<List<TrackSearch>> {
|
||||
suspend fun search(search: String): List<TrackSearch> {
|
||||
val query = """
|
||||
|query Search(${'$'}query: String) {
|
||||
|Page (perPage: 50) {
|
||||
@ -123,35 +114,31 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|}
|
||||
|""".trimMargin()
|
||||
val variables = jsonObject(
|
||||
"query" to search
|
||||
"query" to search
|
||||
)
|
||||
val payload = jsonObject(
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
)
|
||||
val body = payload.toString().toRequestBody(jsonMime)
|
||||
val request = Request.Builder()
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = parser.parse(responseBody).obj
|
||||
val data = response["data"]!!.obj
|
||||
val page = data["Page"].obj
|
||||
val media = page["media"].array
|
||||
val entries = media.map { jsonToALManga(it.obj) }
|
||||
entries.map { it.toTrack() }
|
||||
}
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
val netResponse = authClient.newCall(request).await()
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = JsonParser().parse(responseBody).obj
|
||||
val data = response["data"]!!.obj
|
||||
val page = data["Page"].obj
|
||||
val media = page["media"].array
|
||||
val entries = media.map { jsonToALManga(it.obj) }
|
||||
return entries.map { it.toTrack() }
|
||||
}
|
||||
|
||||
|
||||
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
|
||||
suspend fun findLibManga(track: Track, userid: Int): Track? {
|
||||
val query = """
|
||||
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|
||||
|Page {
|
||||
@ -183,45 +170,47 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|}
|
||||
|""".trimMargin()
|
||||
val variables = jsonObject(
|
||||
"id" to userid,
|
||||
"manga_id" to track.media_id
|
||||
"id" to userid,
|
||||
"manga_id" to track.media_id
|
||||
)
|
||||
val payload = jsonObject(
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
)
|
||||
val body = payload.toString().toRequestBody(jsonMime)
|
||||
val request = Request.Builder()
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = parser.parse(responseBody).obj
|
||||
val data = response["data"]!!.obj
|
||||
val page = data["Page"].obj
|
||||
val media = page["mediaList"].array
|
||||
val entries = media.map { jsonToALUserManga(it.obj) }
|
||||
entries.firstOrNull()?.toTrack()
|
||||
|
||||
}
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
val result = authClient.newCall(request).await()
|
||||
return result.let { resp ->
|
||||
val responseBody = resp.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = JsonParser().parse(responseBody).obj
|
||||
val data = response["data"]!!.obj
|
||||
val page = data["Page"].obj
|
||||
val media = page["mediaList"].array
|
||||
val entries = media.map { jsonToALUserManga(it.obj) }
|
||||
entries.firstOrNull()?.toTrack()
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibManga(track: Track, userid: Int): Observable<Track> {
|
||||
return findLibManga(track, userid)
|
||||
.map { it ?: throw Exception("Could not find manga") }
|
||||
suspend fun getLibManga(track: Track, userid: Int): Track {
|
||||
val track = findLibManga(track, userid)
|
||||
if (track == null) {
|
||||
throw Exception("Could not find manga")
|
||||
} else {
|
||||
return track
|
||||
}
|
||||
}
|
||||
|
||||
fun createOAuth(token: String): OAuth {
|
||||
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||
}
|
||||
|
||||
fun getCurrentUser(): Observable<Pair<Int, String>> {
|
||||
suspend fun getCurrentUser(): Pair<Int, String> {
|
||||
val query = """
|
||||
|query User {
|
||||
|Viewer {
|
||||
@ -233,49 +222,62 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|}
|
||||
|""".trimMargin()
|
||||
val payload = jsonObject(
|
||||
"query" to query
|
||||
"query" to query
|
||||
)
|
||||
val body = payload.toString().toRequestBody(jsonMime)
|
||||
val request = Request.Builder()
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = parser.parse(responseBody).obj
|
||||
val data = response["data"]!!.obj
|
||||
val viewer = data["Viewer"].obj
|
||||
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
|
||||
}
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
val netResponse = authClient.newCall(request).await()
|
||||
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = JsonParser().parse(responseBody).obj
|
||||
val data = response["data"]!!.obj
|
||||
val viewer = data["Viewer"].obj
|
||||
return Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
|
||||
}
|
||||
|
||||
private fun jsonToALManga(struct: JsonObject): ALManga {
|
||||
val date = try {
|
||||
val date = Calendar.getInstance()
|
||||
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
|
||||
struct["startDate"]["day"].nullInt ?: 0)
|
||||
date.set(
|
||||
struct["startDate"]["year"].nullInt ?: 0,
|
||||
(struct["startDate"]["month"].nullInt ?: 0) - 1,
|
||||
struct["startDate"]["day"].nullInt ?: 0
|
||||
)
|
||||
date.timeInMillis
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
|
||||
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
|
||||
date, struct["chapters"].nullInt ?: 0)
|
||||
return ALManga(
|
||||
struct["id"].asInt,
|
||||
struct["title"]["romaji"].asString,
|
||||
struct["coverImage"]["large"].asString,
|
||||
struct["description"].nullString.orEmpty(),
|
||||
struct["type"].asString,
|
||||
struct["status"].asString,
|
||||
date,
|
||||
struct["chapters"].nullInt ?: 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
|
||||
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj))
|
||||
return ALUserManga(
|
||||
struct["id"].asLong,
|
||||
struct["status"].asString,
|
||||
struct["scoreRaw"].asInt,
|
||||
struct["progress"].asInt,
|
||||
jsonToALManga(struct["media"].obj)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val clientId = "385"
|
||||
private const val clientUrl = "tachiyomi://anilist-auth"
|
||||
private const val apiUrl = "https://graphql.anilist.co/"
|
||||
private const val baseUrl = "https://anilist.co/api/v2/"
|
||||
private const val baseMangaUrl = "https://anilist.co/manga/"
|
||||
@ -285,9 +287,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
}
|
||||
|
||||
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("response_type", "token")
|
||||
.build()
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("response_type", "token")
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,18 +1,13 @@
|
||||
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.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
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 java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Locale
|
||||
|
||||
data class ALManga(
|
||||
val media_id: Int,
|
||||
@ -45,12 +40,11 @@ data class ALManga(
|
||||
}
|
||||
|
||||
data class ALUserManga(
|
||||
val library_id: Long,
|
||||
val list_status: String,
|
||||
val score_raw: Int,
|
||||
val chapters_read: Int,
|
||||
val manga: ALManga,
|
||||
val context: Context = Injekt.get<PreferencesHelper>().context
|
||||
val library_id: Long,
|
||||
val list_status: String,
|
||||
val score_raw: Int,
|
||||
val chapters_read: Int,
|
||||
val manga: ALManga
|
||||
) {
|
||||
|
||||
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
|
||||
@ -62,16 +56,14 @@ data class ALUserManga(
|
||||
total_chapters = manga.total_chapters
|
||||
}
|
||||
|
||||
fun toTrackStatus() = with(context) {
|
||||
when (list_status) {
|
||||
getString(R.string.reading) -> Anilist.READING
|
||||
getString(R.string.completed) -> Anilist.COMPLETED
|
||||
getString(R.string.paused) -> Anilist.PAUSED
|
||||
getString(R.string.dropped) -> Anilist.DROPPED
|
||||
getString(R.string.plan_to_read) -> Anilist.PLANNING
|
||||
getString(R.string.repeating)-> Anilist.REPEATING
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
fun toTrackStatus() = when (list_status) {
|
||||
"CURRENT" -> Anilist.READING
|
||||
"COMPLETED" -> Anilist.COMPLETED
|
||||
"PAUSED" -> Anilist.PAUSED
|
||||
"DROPPED" -> Anilist.DROPPED
|
||||
"PLANNING" -> Anilist.PLANNING
|
||||
"REPEATING" -> Anilist.REPEATING
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi
|
||||
|
||||
data class Avatar(
|
||||
val large: String? = "",
|
||||
val medium: String? = "",
|
||||
val small: String? = ""
|
||||
)
|
@ -7,8 +7,7 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
override suspend fun add(track: Track): 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) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
return api.updateLibManga(track)
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.statusLibManga(track)
|
||||
.flatMap {
|
||||
api.findLibManga(track).flatMap { remoteTrack ->
|
||||
if (remoteTrack != null && it != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.library_id = remoteTrack.library_id
|
||||
track.status = remoteTrack.status
|
||||
track.last_chapter_read = remoteTrack.last_chapter_read
|
||||
refresh(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
update(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
override suspend fun bind(track: Track): Track {
|
||||
val statusTrack = api.statusLibManga(track)
|
||||
val remoteTrack = api.findLibManga(track)
|
||||
if (statusTrack != null && remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.library_id = remoteTrack.library_id
|
||||
track.status = remoteTrack.status
|
||||
track.last_chapter_read = remoteTrack.last_chapter_read
|
||||
refresh(track)
|
||||
} else {
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(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)
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.statusLibManga(track)
|
||||
.flatMap {
|
||||
track.copyPersonalFrom(it!!)
|
||||
api.findLibManga(track)
|
||||
.map { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
track.status = remoteTrack.status
|
||||
}
|
||||
track
|
||||
}
|
||||
}
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val statusTrack = api.statusLibManga(track)
|
||||
track.copyPersonalFrom(statusTrack!!)
|
||||
val remoteTrack = api.findLibManga(track)
|
||||
if(remoteTrack != null){
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
track.status = remoteTrack.status
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
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 {
|
||||
return api.accessToken(code).map { oauth: OAuth? ->
|
||||
suspend fun login(code: String): Boolean {
|
||||
try {
|
||||
|
||||
val oauth = api.accessToken(code)
|
||||
interceptor.newAuth(oauth)
|
||||
if (oauth != null) {
|
||||
saveCredentials(oauth.user_id.toString(), oauth.access_token)
|
||||
}
|
||||
}.doOnError {
|
||||
saveCredentials(oauth.user_id.toString(), oauth.access_token)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
logout()
|
||||
}.toCompletable()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun saveToken(oauth: OAuth?) {
|
||||
@ -128,7 +123,7 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
|
||||
override fun logout() {
|
||||
super.logout()
|
||||
preferences.trackToken(this).set(null)
|
||||
interceptor.newAuth(null)
|
||||
interceptor.clearOauth()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -10,91 +10,86 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
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.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLEncoder
|
||||
|
||||
class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) {
|
||||
|
||||
private val gson: Gson by injectLazy()
|
||||
private val parser = JsonParser()
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
fun addLibManga(track: Track): Observable<Track> {
|
||||
suspend fun addLibManga(track: Track): Track {
|
||||
val body = FormBody.Builder()
|
||||
.add("rating", track.score.toInt().toString())
|
||||
.add("status", track.toBangumiStatus())
|
||||
.build()
|
||||
.add("rating", track.score.toInt().toString())
|
||||
.add("status", track.toBangumiStatus())
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/collection/${track.media_id}/update")
|
||||
.post(body)
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
track
|
||||
}
|
||||
.url("$apiUrl/collection/${track.media_id}/update")
|
||||
.post(body)
|
||||
.build()
|
||||
val response = authClient.newCall(request).await()
|
||||
return track
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track): Observable<Track> {
|
||||
suspend fun updateLibManga(track: Track): Track {
|
||||
// chapter update
|
||||
val body = FormBody.Builder()
|
||||
return withContext(Dispatchers.IO) {
|
||||
val body = FormBody.Builder()
|
||||
.add("watched_eps", track.last_chapter_read.toString())
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/subject/${track.media_id}/update/watched_eps")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
// read status update
|
||||
val sbody = FormBody.Builder()
|
||||
// read status update
|
||||
val sbody = FormBody.Builder()
|
||||
.add("status", track.toBangumiStatus())
|
||||
.build()
|
||||
val srequest = Request.Builder()
|
||||
val srequest = Request.Builder()
|
||||
.url("$apiUrl/collection/${track.media_id}/update")
|
||||
.post(sbody)
|
||||
.build()
|
||||
return authClient.newCall(srequest)
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
track
|
||||
}.flatMap {
|
||||
authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
track
|
||||
}
|
||||
}
|
||||
authClient.newCall(srequest).execute()
|
||||
authClient.newCall(request).execute()
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
fun search(search: String): Observable<List<TrackSearch>> {
|
||||
val url = Uri.parse(
|
||||
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon()
|
||||
suspend fun search(search: String): List<TrackSearch> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = Uri.parse(
|
||||
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}"
|
||||
).buildUpon()
|
||||
.appendQueryParameter("max_results", "20")
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
val request = Request.Builder()
|
||||
.url(url.toString())
|
||||
.get()
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
var responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
if (responseBody.contains("\"code\":404")) {
|
||||
responseBody = "{\"results\":0,\"list\":[]}"
|
||||
}
|
||||
val response = parser.parse(responseBody).obj["list"]?.array
|
||||
response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) }
|
||||
}
|
||||
|
||||
val netResponse = authClient.newCall(request).await()
|
||||
var responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
if (responseBody.contains("\"code\":404")) {
|
||||
responseBody = "{\"results\":0,\"list\":[]}"
|
||||
}
|
||||
val response = JsonParser.parseString(responseBody).obj["list"]?.array
|
||||
if (response != null) {
|
||||
response.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) }
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
||||
@ -119,60 +114,56 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
|
||||
}
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track): Observable<Track?> {
|
||||
val urlMangas = "$apiUrl/subject/${track.media_id}"
|
||||
val requestMangas = Request.Builder()
|
||||
suspend fun findLibManga(track: Track): Track? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val urlMangas = "$apiUrl/subject/${track.media_id}"
|
||||
val requestMangas = Request.Builder()
|
||||
.url(urlMangas)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return authClient.newCall(requestMangas)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
// get comic info
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
jsonToTrack(parser.parse(responseBody).obj)
|
||||
}
|
||||
val netResponse = authClient.newCall(requestMangas).execute()
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
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 requestUserRead = Request.Builder()
|
||||
.url(urlUserRead)
|
||||
.cacheControl(CacheControl.FORCE_NETWORK)
|
||||
.get()
|
||||
.build()
|
||||
.url(urlUserRead)
|
||||
.cacheControl(CacheControl.FORCE_NETWORK)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
// todo get user readed chapter here
|
||||
return authClient.newCall(requestUserRead)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
val resp = netResponse.body?.string()
|
||||
val coll = gson.fromJson(resp, Collection::class.java)
|
||||
track.status = coll.status?.id!!
|
||||
track.last_chapter_read = coll.ep_status!!
|
||||
track
|
||||
}
|
||||
val response = authClient.newCall(requestUserRead).await()
|
||||
val resp = response.body?.toString()
|
||||
val coll = gson.fromJson(resp, Collection::class.java)
|
||||
track.status = coll.status?.id!!
|
||||
track.last_chapter_read = coll.ep_status!!
|
||||
return track
|
||||
}
|
||||
|
||||
fun accessToken(code: String): Observable<OAuth> {
|
||||
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
|
||||
suspend fun accessToken(code: String): OAuth {
|
||||
return withContext(Dispatchers.IO){
|
||||
val netResponse = client.newCall(accessTokenRequest(code)).execute()
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
if(responseBody.isEmpty()){
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
gson.fromJson(responseBody, OAuth::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun accessTokenRequest(code: String) = POST(oauthUrl,
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("code", code)
|
||||
.add("redirect_uri", redirectUrl)
|
||||
.build()
|
||||
private fun accessTokenRequest(code: String) = POST(
|
||||
oauthUrl,
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("code", code)
|
||||
.add("redirect_uri", redirectUrl)
|
||||
.build()
|
||||
)
|
||||
|
||||
companion object {
|
||||
@ -192,20 +183,21 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
|
||||
}
|
||||
|
||||
fun authUrl() =
|
||||
Uri.parse(loginUrl).buildUpon()
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.appendQueryParameter("redirect_uri", redirectUrl)
|
||||
.build()
|
||||
Uri.parse(loginUrl).buildUpon()
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.appendQueryParameter("redirect_uri", redirectUrl)
|
||||
.build()
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST(oauthUrl,
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.add("redirect_uri", redirectUrl)
|
||||
.build())
|
||||
fun refreshTokenRequest(token: String) = POST(
|
||||
oauthUrl,
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.add("redirect_uri", redirectUrl)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -47,8 +47,8 @@ class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
fun newAuth(oauth: OAuth?) {
|
||||
this.oauth = if (oauth == null) null else OAuth(
|
||||
fun newAuth(oauth: OAuth) {
|
||||
this.oauth = OAuth(
|
||||
oauth.access_token,
|
||||
oauth.token_type,
|
||||
System.currentTimeMillis() / 1000,
|
||||
@ -58,4 +58,8 @@ class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
|
||||
|
||||
bangumi.saveToken(oauth)
|
||||
}
|
||||
|
||||
fun clearOauth(){
|
||||
bangumi.saveToken(null)
|
||||
}
|
||||
}
|
||||
|
@ -11,3 +11,39 @@ data class Collection(
|
||||
val user: User? = User(),
|
||||
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? = ""
|
||||
)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi
|
||||
|
||||
data class Status(
|
||||
val id: Int? = 0,
|
||||
val name: String? = "",
|
||||
val type: String? = ""
|
||||
)
|
@ -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? = ""
|
||||
)
|
@ -7,8 +7,7 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@ -70,11 +69,11 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
return df.format(track.score)
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
override suspend fun add(track: Track): Track {
|
||||
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) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
@ -82,41 +81,41 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
return api.updateLibManga(track)
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track, getUserId())
|
||||
.flatMap { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.media_id = remoteTrack.media_id
|
||||
update(track)
|
||||
} else {
|
||||
track.score = DEFAULT_SCORE
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
override suspend fun bind(track: Track): Track {
|
||||
val remoteTrack = api.findLibManga(track, getUserId())
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.media_id = remoteTrack.media_id
|
||||
return update(track)
|
||||
} else {
|
||||
track.score = DEFAULT_SCORE
|
||||
track.status = DEFAULT_STATUS
|
||||
return add(track)
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
override suspend fun search(query: String): List<TrackSearch> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.getLibManga(track)
|
||||
.map { remoteTrack ->
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
track
|
||||
}
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val remoteTrack = api.getLibManga(track)
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
return track
|
||||
}
|
||||
|
||||
override fun login(username: String, password: String): Completable {
|
||||
return api.login(username, password)
|
||||
.doOnNext { interceptor.newAuth(it) }
|
||||
.flatMap { api.getCurrentUser() }
|
||||
.doOnNext { userId -> saveCredentials(username, userId) }
|
||||
.doOnError { logout() }
|
||||
.toCompletable()
|
||||
override suspend fun login(username: String, password: String): Boolean {
|
||||
try {
|
||||
val oauth = api.login(username, password)
|
||||
interceptor.newAuth(oauth)
|
||||
val userId = api.getCurrentUser()
|
||||
saveCredentials(username, userId)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun logout() {
|
||||
@ -140,5 +139,4 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,6 +1,11 @@
|
||||
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.JsonObject
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
@ -11,238 +16,231 @@ import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
import rx.Observable
|
||||
import retrofit2.http.Body
|
||||
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) {
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
private val rest = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(authClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.Rest::class.java)
|
||||
.baseUrl(baseUrl)
|
||||
.client(authClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.Rest::class.java)
|
||||
|
||||
private val searchRest = Retrofit.Builder()
|
||||
.baseUrl(algoliaKeyUrl)
|
||||
.client(authClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.SearchKeyRest::class.java)
|
||||
.baseUrl(algoliaKeyUrl)
|
||||
.client(authClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.SearchKeyRest::class.java)
|
||||
|
||||
private val algoliaRest = Retrofit.Builder()
|
||||
.baseUrl(algoliaUrl)
|
||||
.baseUrl(algoliaUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.AgoliaSearchRest::class.java)
|
||||
|
||||
suspend fun addLibManga(track: Track, userId: String): Track {
|
||||
// @formatter:off
|
||||
val data = jsonObject(
|
||||
"type" to "libraryEntries",
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read
|
||||
),
|
||||
"relationships" to jsonObject(
|
||||
"user" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to userId,
|
||||
"type" to "users"
|
||||
)
|
||||
),
|
||||
"media" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to track.media_id,
|
||||
"type" to "manga"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val json = rest.addLibManga(jsonObject("data" to data))
|
||||
track.media_id = json["data"]["id"].int
|
||||
return track
|
||||
}
|
||||
|
||||
suspend fun updateLibManga(track: Track): Track {
|
||||
// @formatter:off
|
||||
val data = jsonObject(
|
||||
"type" to "libraryEntries",
|
||||
"id" to track.media_id,
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read,
|
||||
"ratingTwenty" to track.toKitsuScore()
|
||||
)
|
||||
)
|
||||
// @formatter:on
|
||||
|
||||
rest.updateLibManga(track.media_id, jsonObject("data" to data))
|
||||
return track
|
||||
}
|
||||
|
||||
suspend fun search(query: String): List<TrackSearch> {
|
||||
val key = searchRest.getKey()["media"].asJsonObject["key"].string
|
||||
return algoliaSearch(key, query)
|
||||
}
|
||||
|
||||
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
|
||||
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
|
||||
val json = algoliaRest.getSearchQuery(algoliaAppId, key, jsonObject)
|
||||
val data = json["hits"].array
|
||||
return data.map { KitsuSearchManga(it.obj) }
|
||||
.filter { it.subType != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
|
||||
suspend fun findLibManga(track: Track, userId: String): Track? {
|
||||
val json = rest.findLibManga(track.media_id, userId)
|
||||
val data = json["data"].array
|
||||
return if (data.size() > 0) {
|
||||
val manga = json["included"].array[0].obj
|
||||
KitsuLibManga(data[0].obj, manga).toTrack()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibManga(track: Track): Track {
|
||||
val json = rest.getLibManga(track.media_id)
|
||||
val data = json["data"].array
|
||||
if (data.size() > 0) {
|
||||
val manga = json["included"].array[0].obj
|
||||
return KitsuLibManga(data[0].obj, manga).toTrack()
|
||||
} else {
|
||||
throw Exception("Could not find manga")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String): OAuth {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(loginUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.AgoliaSearchRest::class.java)
|
||||
|
||||
fun addLibManga(track: Track, userId: String): Observable<Track> {
|
||||
return Observable.defer {
|
||||
// @formatter:off
|
||||
val data = jsonObject(
|
||||
"type" to "libraryEntries",
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read
|
||||
),
|
||||
"relationships" to jsonObject(
|
||||
"user" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to userId,
|
||||
"type" to "users"
|
||||
)
|
||||
),
|
||||
"media" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to track.media_id,
|
||||
"type" to "manga"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
rest.addLibManga(jsonObject("data" to data))
|
||||
.map { json ->
|
||||
track.media_id = json["data"]["id"].int
|
||||
track
|
||||
}
|
||||
}
|
||||
.create(KitsuApi.LoginRest::class.java)
|
||||
.requestAccessToken(username, password)
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track): Observable<Track> {
|
||||
return Observable.defer {
|
||||
// @formatter:off
|
||||
val data = jsonObject(
|
||||
"type" to "libraryEntries",
|
||||
"id" to track.media_id,
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read,
|
||||
"ratingTwenty" to track.toKitsuScore()
|
||||
)
|
||||
)
|
||||
// @formatter:on
|
||||
|
||||
rest.updateLibManga(track.media_id, jsonObject("data" to data))
|
||||
.map { track }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return searchRest
|
||||
.getKey().map { json ->
|
||||
json["media"].asJsonObject["key"].string
|
||||
}.flatMap { key ->
|
||||
algoliaSearch(key, query)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
|
||||
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
|
||||
return algoliaRest
|
||||
.getSearchQuery(algoliaAppId, key, jsonObject)
|
||||
.map { json ->
|
||||
val data = json["hits"].array
|
||||
data.map { KitsuSearchManga(it.obj) }
|
||||
.filter { it.subType != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track, userId: String): Observable<Track?> {
|
||||
return rest.findLibManga(track.media_id, userId)
|
||||
.map { json ->
|
||||
val data = json["data"].array
|
||||
if (data.size() > 0) {
|
||||
val manga = json["included"].array[0].obj
|
||||
KitsuLibManga(data[0].obj, manga).toTrack()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibManga(track: Track): Observable<Track> {
|
||||
return rest.getLibManga(track.media_id)
|
||||
.map { json ->
|
||||
val data = json["data"].array
|
||||
if (data.size() > 0) {
|
||||
val manga = json["included"].array[0].obj
|
||||
KitsuLibManga(data[0].obj, manga).toTrack()
|
||||
} else {
|
||||
throw Exception("Could not find manga")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login(username: String, password: String): Observable<OAuth> {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(loginUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.LoginRest::class.java)
|
||||
.requestAccessToken(username, password)
|
||||
}
|
||||
|
||||
fun getCurrentUser(): Observable<String> {
|
||||
return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
|
||||
suspend fun getCurrentUser(): String {
|
||||
val currentUser = rest.getCurrentUser()
|
||||
return currentUser["data"].array[0]["id"].string
|
||||
}
|
||||
|
||||
private interface Rest {
|
||||
|
||||
@Headers("Content-Type: application/vnd.api+json")
|
||||
@POST("library-entries")
|
||||
fun addLibManga(
|
||||
@Body data: JsonObject
|
||||
): Observable<JsonObject>
|
||||
suspend fun addLibManga(
|
||||
@Body data: JsonObject
|
||||
): JsonObject
|
||||
|
||||
@Headers("Content-Type: application/vnd.api+json")
|
||||
@PATCH("library-entries/{id}")
|
||||
fun updateLibManga(
|
||||
@Path("id") remoteId: Int,
|
||||
@Body data: JsonObject
|
||||
): Observable<JsonObject>
|
||||
|
||||
suspend fun updateLibManga(
|
||||
@Path("id") remoteId: Int,
|
||||
@Body data: JsonObject
|
||||
): JsonObject
|
||||
|
||||
@GET("library-entries")
|
||||
fun findLibManga(
|
||||
@Query("filter[manga_id]", encoded = true) remoteId: Int,
|
||||
@Query("filter[user_id]", encoded = true) userId: String,
|
||||
@Query("include") includes: String = "manga"
|
||||
): Observable<JsonObject>
|
||||
suspend fun findLibManga(
|
||||
@Query("filter[manga_id]", encoded = true) remoteId: Int,
|
||||
@Query("filter[user_id]", encoded = true) userId: String,
|
||||
@Query("include") includes: String = "manga"
|
||||
): JsonObject
|
||||
|
||||
@GET("library-entries")
|
||||
fun getLibManga(
|
||||
@Query("filter[id]", encoded = true) remoteId: Int,
|
||||
@Query("include") includes: String = "manga"
|
||||
): Observable<JsonObject>
|
||||
suspend fun getLibManga(
|
||||
@Query("filter[id]", encoded = true) remoteId: Int,
|
||||
@Query("include") includes: String = "manga"
|
||||
): JsonObject
|
||||
|
||||
@GET("users")
|
||||
fun getCurrentUser(
|
||||
@Query("filter[self]", encoded = true) self: Boolean = true
|
||||
): Observable<JsonObject>
|
||||
|
||||
suspend fun getCurrentUser(
|
||||
@Query("filter[self]", encoded = true) self: Boolean = true
|
||||
): JsonObject
|
||||
}
|
||||
|
||||
private interface SearchKeyRest {
|
||||
@GET("media/")
|
||||
fun getKey(): Observable<JsonObject>
|
||||
suspend fun getKey(): JsonObject
|
||||
}
|
||||
|
||||
private interface AgoliaSearchRest {
|
||||
@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 {
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("oauth/token")
|
||||
fun requestAccessToken(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String,
|
||||
@Field("grant_type") grantType: String = "password",
|
||||
@Field("client_id") client_id: String = clientId,
|
||||
@Field("client_secret") client_secret: String = clientSecret
|
||||
): Observable<OAuth>
|
||||
|
||||
suspend fun requestAccessToken(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String,
|
||||
@Field("grant_type") grantType: String = "password",
|
||||
@Field("client_id") client_id: String = clientId,
|
||||
@Field("client_secret") client_secret: String = clientSecret
|
||||
): OAuth
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
||||
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||
private const val clientId =
|
||||
"dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
||||
private const val clientSecret =
|
||||
"54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||
private const val baseUrl = "https://kitsu.io/api/edge/"
|
||||
private const val loginUrl = "https://kitsu.io/api/"
|
||||
private const val baseMangaUrl = "https://kitsu.io/manga/"
|
||||
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 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 {
|
||||
return baseMangaUrl + remoteId
|
||||
}
|
||||
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.build())
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST(
|
||||
"${loginUrl}oauth/token",
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
override suspend fun add(track: Track): 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) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
@ -74,42 +71,42 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
return api.updateLibManga(track)
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track)
|
||||
.flatMap { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
override suspend fun bind(track: Track): Track {
|
||||
val remoteTrack = api.findLibManga(track)
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
override suspend fun search(query: String): List<TrackSearch> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.getLibManga(track)
|
||||
.map { remoteTrack ->
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
track
|
||||
}
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val remoteTrack = api.getLibManga(track)
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
return track
|
||||
}
|
||||
|
||||
override fun login(username: String, password: String): Completable {
|
||||
override suspend fun login(username: String, password: String): Boolean {
|
||||
logout()
|
||||
|
||||
return Observable.fromCallable { api.login(username, password) }
|
||||
.doOnNext { csrf -> saveCSRF(csrf) }
|
||||
.doOnNext { saveCredentials(username, password) }
|
||||
.doOnError { logout() }
|
||||
.toCompletable()
|
||||
try {
|
||||
val csrf = api.login(username, password)
|
||||
saveCSRF(csrf)
|
||||
saveCredentials(username, password)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshLogin() {
|
||||
@ -143,8 +140,8 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
val isAuthorized: Boolean
|
||||
get() = super.isLogged &&
|
||||
getCSRF().isNotEmpty() &&
|
||||
checkCookies()
|
||||
getCSRF().isNotEmpty() &&
|
||||
checkCookies()
|
||||
|
||||
fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
|
||||
|
||||
@ -160,5 +157,4 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
return ckCount == 2
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -6,8 +6,9 @@ import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.network.consumeBody
|
||||
import eu.kanade.tachiyomi.network.consumeXmlBody
|
||||
import eu.kanade.tachiyomi.util.selectInt
|
||||
import eu.kanade.tachiyomi.util.selectText
|
||||
import okhttp3.FormBody
|
||||
@ -15,98 +16,84 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
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) {
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return if (query.startsWith(PREFIX_MY)) {
|
||||
suspend fun search(query: String): List<TrackSearch> {
|
||||
if (query.startsWith(PREFIX_MY)) {
|
||||
val realQuery = query.removePrefix(PREFIX_MY)
|
||||
getList()
|
||||
.flatMap { Observable.from(it) }
|
||||
.filter { it.title.contains(realQuery, true) }
|
||||
.toList()
|
||||
return getList().filter { it.title.contains(realQuery, true) }.toList()
|
||||
} else {
|
||||
client.newCall(GET(searchUrl(query)))
|
||||
.asObservable()
|
||||
.flatMap { response ->
|
||||
Observable.from(Jsoup.parse(response.consumeBody())
|
||||
.select("div.js-categories-seasonal.js-block-list.list")
|
||||
.select("table").select("tbody")
|
||||
.select("tr").drop(1))
|
||||
}
|
||||
.filter { row ->
|
||||
row.select(TD)[2].text() != "Novel"
|
||||
}
|
||||
.map { row ->
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = row.searchTitle()
|
||||
media_id = row.searchMediaId()
|
||||
total_chapters = row.searchTotalChapters()
|
||||
summary = row.searchSummary()
|
||||
cover_url = row.searchCoverUrl()
|
||||
tracking_url = mangaUrl(media_id)
|
||||
publishing_status = row.searchPublishingStatus()
|
||||
publishing_type = row.searchPublishingType()
|
||||
start_date = row.searchStartDate()
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
val realQuery = query.take(100)
|
||||
val response = client.newCall(GET(searchUrl(realQuery))).await()
|
||||
val matches = Jsoup.parse(response.consumeBody())
|
||||
.select("div.js-categories-seasonal.js-block-list.list")
|
||||
.select("table").select("tbody")
|
||||
.select("tr").drop(1)
|
||||
|
||||
fun addLibManga(track: Track): Observable<Track> {
|
||||
return Observable.defer {
|
||||
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
|
||||
.asObservableSuccess()
|
||||
.map { track }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track): Observable<Track> {
|
||||
return Observable.defer {
|
||||
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track)))
|
||||
.asObservableSuccess()
|
||||
.map { track }
|
||||
}
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track): Observable<Track?> {
|
||||
return authClient.newCall(GET(url = listEntryUrl(track.media_id)))
|
||||
.asObservable()
|
||||
.map {response ->
|
||||
var libTrack: Track? = null
|
||||
response.use {
|
||||
if (it.priorResponse?.isRedirect != true) {
|
||||
val trackForm = Jsoup.parse(it.consumeBody())
|
||||
|
||||
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
|
||||
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
|
||||
total_chapters = trackForm.select("#totalChap").text().toInt()
|
||||
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
|
||||
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f
|
||||
}
|
||||
}
|
||||
return matches.filter { row -> row.select(TD)[2].text() != "Novel" }
|
||||
.map { row ->
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = row.searchTitle()
|
||||
media_id = row.searchMediaId()
|
||||
total_chapters = row.searchTotalChapters()
|
||||
summary = row.searchSummary()
|
||||
cover_url = row.searchCoverUrl()
|
||||
tracking_url = mangaUrl(media_id)
|
||||
publishing_status = row.searchPublishingStatus()
|
||||
publishing_type = row.searchPublishingType()
|
||||
start_date = row.searchStartDate()
|
||||
}
|
||||
libTrack
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibManga(track: Track): Observable<Track> {
|
||||
return findLibManga(track)
|
||||
.map { it ?: throw Exception("Could not find manga") }
|
||||
suspend fun addLibManga(track: Track): Track {
|
||||
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))).await()
|
||||
return track
|
||||
}
|
||||
|
||||
suspend fun updateLibManga(track: Track): Track {
|
||||
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await()
|
||||
return track
|
||||
}
|
||||
|
||||
suspend fun findLibManga(track: Track): Track? {
|
||||
val response = authClient.newCall(GET(url = listEntryUrl(track.media_id))).await()
|
||||
var libTrack: Track? = null
|
||||
response.use {
|
||||
if (it.priorResponse?.isRedirect != true) {
|
||||
val trackForm = Jsoup.parse(it.consumeBody())
|
||||
|
||||
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
|
||||
last_chapter_read =
|
||||
trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
|
||||
total_chapters = trackForm.select("#totalChap").text().toInt()
|
||||
status =
|
||||
trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
|
||||
score = trackForm.select("#add_manga_score > option[selected]").`val`()
|
||||
.toFloatOrNull() ?: 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
return libTrack
|
||||
}
|
||||
|
||||
suspend fun getLibManga(track: Track): Track {
|
||||
val result = findLibManga(track)
|
||||
if (result == null) {
|
||||
throw Exception("Could not find manga")
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fun login(username: String, password: String): String {
|
||||
@ -121,77 +108,50 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
||||
val response = client.newCall(GET(loginUrl())).execute()
|
||||
|
||||
return Jsoup.parse(response.consumeBody())
|
||||
.select("meta[name=csrf_token]")
|
||||
.attr("content")
|
||||
.select("meta[name=csrf_token]")
|
||||
.attr("content")
|
||||
}
|
||||
|
||||
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 {
|
||||
if (response.priorResponse?.code != 302) throw Exception("Authentication error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getList(): Observable<List<TrackSearch>> {
|
||||
return getListUrl()
|
||||
.flatMap { url ->
|
||||
getListXml(url)
|
||||
}
|
||||
.flatMap { doc ->
|
||||
Observable.from(doc.select("manga"))
|
||||
}
|
||||
.map {
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = it.selectText("manga_title")!!
|
||||
media_id = it.selectInt("manga_mangadb_id")
|
||||
last_chapter_read = it.selectInt("my_read_chapters")
|
||||
status = getStatus(it.selectText("my_status")!!)
|
||||
score = it.selectInt("my_score").toFloat()
|
||||
total_chapters = it.selectInt("manga_chapters")
|
||||
tracking_url = mangaUrl(media_id)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
private suspend fun getList(): List<TrackSearch> {
|
||||
val results = getListXml(getListUrl()).select("manga")
|
||||
|
||||
private fun getListUrl(): Observable<String> {
|
||||
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
|
||||
.asObservable()
|
||||
.map {response ->
|
||||
baseUrl + Jsoup.parse(response.consumeBody())
|
||||
.select("div.goodresult")
|
||||
.select("a")
|
||||
.attr("href")
|
||||
return results.map {
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = it.selectText("manga_title")!!
|
||||
media_id = it.selectInt("manga_mangadb_id")
|
||||
last_chapter_read = it.selectInt("my_read_chapters")
|
||||
status = getStatus(it.selectText("my_status")!!)
|
||||
score = it.selectInt("my_score").toFloat()
|
||||
total_chapters = it.selectInt("manga_chapters")
|
||||
tracking_url = mangaUrl(media_id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getListXml(url: String): Observable<Document> {
|
||||
return authClient.newCall(GET(url))
|
||||
.asObservable()
|
||||
.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()
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private suspend fun getListUrl(): String {
|
||||
val response =
|
||||
authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())).await()
|
||||
|
||||
return baseUrl + Jsoup.parse(response.consumeBody())
|
||||
.select("div.goodresult")
|
||||
.select("a")
|
||||
.attr("href")
|
||||
}
|
||||
|
||||
private suspend fun getListXml(url: String): Document {
|
||||
val response = authClient.newCall(GET(url)).await()
|
||||
return Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
|
||||
}
|
||||
|
||||
companion object {
|
||||
@ -206,88 +166,91 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
||||
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
|
||||
|
||||
private fun loginUrl() = Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("login.php")
|
||||
.toString()
|
||||
.appendPath("login.php")
|
||||
.toString()
|
||||
|
||||
private fun searchUrl(query: String): String {
|
||||
val col = "c[]"
|
||||
return Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("manga.php")
|
||||
.appendQueryParameter("q", query)
|
||||
.appendQueryParameter(col, "a")
|
||||
.appendQueryParameter(col, "b")
|
||||
.appendQueryParameter(col, "c")
|
||||
.appendQueryParameter(col, "d")
|
||||
.appendQueryParameter(col, "e")
|
||||
.appendQueryParameter(col, "g")
|
||||
.toString()
|
||||
.appendPath("manga.php")
|
||||
.appendQueryParameter("q", query)
|
||||
.appendQueryParameter(col, "a")
|
||||
.appendQueryParameter(col, "b")
|
||||
.appendQueryParameter(col, "c")
|
||||
.appendQueryParameter(col, "d")
|
||||
.appendQueryParameter(col, "e")
|
||||
.appendQueryParameter(col, "g")
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun exportListUrl() = Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("panel.php")
|
||||
.appendQueryParameter("go", "export")
|
||||
.toString()
|
||||
.appendPath("panel.php")
|
||||
.appendQueryParameter("go", "export")
|
||||
.toString()
|
||||
|
||||
private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||
.appendPath("edit.json")
|
||||
.toString()
|
||||
.appendPath("edit.json")
|
||||
.toString()
|
||||
|
||||
private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||
.appendPath( "add.json")
|
||||
.toString()
|
||||
.appendPath("add.json")
|
||||
.toString()
|
||||
|
||||
private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
|
||||
.appendPath(mediaId.toString())
|
||||
.appendPath("edit")
|
||||
.toString()
|
||||
.appendPath(mediaId.toString())
|
||||
.appendPath("edit")
|
||||
.toString()
|
||||
|
||||
private fun loginPostBody(username: String, password: String, csrf: String): RequestBody {
|
||||
return FormBody.Builder()
|
||||
.add("user_name", username)
|
||||
.add("password", password)
|
||||
.add("cookie", "1")
|
||||
.add("sublogin", "Login")
|
||||
.add("submit", "1")
|
||||
.add(CSRF, csrf)
|
||||
.build()
|
||||
.add("user_name", username)
|
||||
.add("password", password)
|
||||
.add("cookie", "1")
|
||||
.add("sublogin", "Login")
|
||||
.add("submit", "1")
|
||||
.add(CSRF, csrf)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun exportPostBody(): RequestBody {
|
||||
return FormBody.Builder()
|
||||
.add("type", "2")
|
||||
.add("subexport", "Export My List")
|
||||
.build()
|
||||
.add("type", "2")
|
||||
.add("subexport", "Export My List")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun mangaPostPayload(track: Track): RequestBody {
|
||||
val body = JSONObject()
|
||||
.put("manga_id", track.media_id)
|
||||
.put("status", track.status)
|
||||
.put("score", track.score)
|
||||
.put("num_read_chapters", track.last_chapter_read)
|
||||
.put("manga_id", track.media_id)
|
||||
.put("status", track.status)
|
||||
.put("score", track.score)
|
||||
.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.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")
|
||||
.attr("data-src")
|
||||
.split("\\?")[0]
|
||||
.replace("/r/50x70/", "/")
|
||||
.attr("data-src")
|
||||
.split("\\?")[0]
|
||||
.replace("/r/50x70/", "/")
|
||||
|
||||
private fun Element.searchMediaId() = select("div.picSurround")
|
||||
.select("a").attr("id")
|
||||
.replace("sarea", "")
|
||||
.toInt()
|
||||
.select("a").attr("id")
|
||||
.replace("sarea", "")
|
||||
.toInt()
|
||||
|
||||
private fun Element.searchSummary() = select("div.pt4")
|
||||
.first()
|
||||
.ownText()!!
|
||||
.first()
|
||||
.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()!!
|
||||
|
||||
@ -300,6 +263,6 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
||||
"Dropped" -> 4
|
||||
"Plan to Read" -> 6
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,7 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
|
||||
@ -21,46 +20,45 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
|
||||
return track.score.toInt().toString()
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
override suspend fun add(track: Track): Track {
|
||||
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) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
return api.updateLibManga(track, getUsername())
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track, getUsername())
|
||||
.flatMap { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.library_id = remoteTrack.library_id
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
override suspend fun bind(track: Track): Track {
|
||||
val remoteTrack = api.findLibManga(track, getUsername())
|
||||
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.library_id = remoteTrack.library_id
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
override suspend fun search(query: String): List<TrackSearch> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track, getUsername())
|
||||
.map { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
}
|
||||
track
|
||||
}
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val remoteTrack = api.findLibManga(track, getUsername())
|
||||
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
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)
|
||||
if (oauth != null) {
|
||||
val user = api.getCurrentUser()
|
||||
saveCredentials(user.toString(), oauth.access_token)
|
||||
}
|
||||
}.doOnError {
|
||||
val user = api.getCurrentUser()
|
||||
saveCredentials(user.toString(), oauth.access_token)
|
||||
return true
|
||||
} catch (e: java.lang.Exception) {
|
||||
Timber.e(e)
|
||||
logout()
|
||||
}.toCompletable()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToken(oauth: OAuth?) {
|
||||
|
@ -14,68 +14,67 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
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.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
|
||||
|
||||
private val gson: Gson by injectLazy()
|
||||
private val parser = JsonParser()
|
||||
private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
fun addLibManga(track: Track, user_id: String): Observable<Track> {
|
||||
val payload = jsonObject(
|
||||
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(
|
||||
"user_rate" to jsonObject(
|
||||
"user_id" to user_id,
|
||||
"target_id" to track.media_id,
|
||||
"target_type" to "Manga",
|
||||
"chapters" to track.last_chapter_read,
|
||||
"score" to track.score.toInt(),
|
||||
"status" to track.toShikimoriStatus()
|
||||
"user_id" to user_id,
|
||||
"target_id" to track.media_id,
|
||||
"target_type" to "Manga",
|
||||
"chapters" to track.last_chapter_read,
|
||||
"score" to track.score.toInt(),
|
||||
"status" to track.toShikimoriStatus()
|
||||
)
|
||||
)
|
||||
val body = payload.toString().toRequestBody(jsonime)
|
||||
val request = Request.Builder()
|
||||
)
|
||||
val body = payload.toString().toRequestBody(jsonime)
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/v2/user_rates")
|
||||
.post(body)
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
track
|
||||
}
|
||||
authClient.newCall(request).execute()
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track, user_id: String): Observable<Track> = addLibManga(track, user_id)
|
||||
|
||||
fun search(search: String): Observable<List<TrackSearch>> {
|
||||
val url = Uri.parse("$apiUrl/mangas").buildUpon()
|
||||
suspend fun search(search: String): List<TrackSearch> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = Uri.parse("$apiUrl/mangas").buildUpon()
|
||||
.appendQueryParameter("order", "popularity")
|
||||
.appendQueryParameter("search", search)
|
||||
.appendQueryParameter("limit", "20")
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
val request = Request.Builder()
|
||||
.url(url.toString())
|
||||
.get()
|
||||
.build()
|
||||
return authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = parser.parse(responseBody).array
|
||||
response.map { jsonToSearch(it.obj) }
|
||||
}
|
||||
val netResponse = authClient.newCall(request).execute()
|
||||
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = JsonParser.parseString(responseBody).array
|
||||
|
||||
response.map { jsonToSearch(it.obj) }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
||||
@ -104,56 +103,55 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
||||
}
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track, user_id: String): Observable<Track?> {
|
||||
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon()
|
||||
suspend fun findLibManga(track: Track, user_id: String): Track? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon()
|
||||
.appendQueryParameter("user_id", user_id)
|
||||
.appendQueryParameter("target_id", track.media_id.toString())
|
||||
.appendQueryParameter("target_type", "Manga")
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
val request = Request.Builder()
|
||||
.url(url.toString())
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val urlMangas = Uri.parse("$apiUrl/mangas").buildUpon()
|
||||
val urlMangas = Uri.parse("$apiUrl/mangas").buildUpon()
|
||||
.appendPath(track.media_id.toString())
|
||||
.build()
|
||||
val requestMangas = Request.Builder()
|
||||
val requestMangas = Request.Builder()
|
||||
.url(urlMangas.toString())
|
||||
.get()
|
||||
.build()
|
||||
return authClient.newCall(requestMangas)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
parser.parse(responseBody).obj
|
||||
}.flatMap { mangas ->
|
||||
authClient.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map { netResponse ->
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = parser.parse(responseBody).array
|
||||
if (response.size() > 1) {
|
||||
throw Exception("Too much mangas in response")
|
||||
}
|
||||
val entry = response.map {
|
||||
jsonToTrack(it.obj, mangas)
|
||||
}
|
||||
entry.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
val requestMangasResponse = authClient.newCall(requestMangas).execute()
|
||||
val requestMangasBody = requestMangasResponse.body?.string().orEmpty()
|
||||
val mangas = JsonParser.parseString(requestMangasBody).obj
|
||||
|
||||
val requestResponse = authClient.newCall(request).execute()
|
||||
val requestResponseBody = requestResponse.body?.string().orEmpty()
|
||||
|
||||
if (requestResponseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
val response = JsonParser.parseString(requestResponseBody).array
|
||||
if (response.size() > 1) {
|
||||
throw Exception("Too much mangas in response")
|
||||
}
|
||||
val entry = response.map {
|
||||
jsonToTrack(it.obj, mangas)
|
||||
}
|
||||
entry.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentUser(): Int {
|
||||
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> {
|
||||
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
|
||||
suspend fun accessToken(code: String): OAuth {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val netResponse= client.newCall(accessTokenRequest(code)).execute()
|
||||
val responseBody = netResponse.body?.string().orEmpty()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
@ -162,20 +160,22 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
||||
}
|
||||
}
|
||||
|
||||
private fun accessTokenRequest(code: String) = POST(oauthUrl,
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("code", code)
|
||||
.add("redirect_uri", redirectUrl)
|
||||
.build()
|
||||
private fun accessTokenRequest(code: String) = POST(
|
||||
oauthUrl,
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("code", code)
|
||||
.add("redirect_uri", redirectUrl)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
companion object {
|
||||
private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
|
||||
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
|
||||
private const val clientId =
|
||||
"1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
|
||||
private const val clientSecret =
|
||||
"229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
|
||||
|
||||
private const val baseUrl = "https://shikimori.one"
|
||||
private const val apiUrl = "https://shikimori.one/api"
|
||||
@ -190,21 +190,20 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
||||
}
|
||||
|
||||
fun authUrl() =
|
||||
Uri.parse(loginUrl).buildUpon()
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("redirect_uri", redirectUrl)
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.build()
|
||||
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST(oauthUrl,
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.build())
|
||||
Uri.parse(loginUrl).buildUpon()
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("redirect_uri", redirectUrl)
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.build()
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST(
|
||||
oauthUrl,
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,20 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.updater.devrepo.DevRepoUpdateChecker
|
||||
import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker
|
||||
import rx.Observable
|
||||
|
||||
abstract class UpdateChecker {
|
||||
|
||||
companion object {
|
||||
fun getUpdateChecker(): UpdateChecker {
|
||||
return if (BuildConfig.DEBUG) {
|
||||
DevRepoUpdateChecker()
|
||||
} else {
|
||||
GithubUpdateChecker()
|
||||
}
|
||||
}
|
||||
fun getUpdateChecker(): UpdateChecker = GithubUpdateChecker()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -23,7 +23,7 @@ class ExtensionUpdateJob : Job() {
|
||||
|
||||
override fun onRunJob(params: Params): Result {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val pendingUpdates = ExtensionGithubApi().checkforUpdates(context)
|
||||
val pendingUpdates = ExtensionGithubApi().checkForUpdates(context)
|
||||
if (pendingUpdates.isNotEmpty()) {
|
||||
val names = pendingUpdates.map { it.name }
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
|
@ -7,18 +7,16 @@ import com.github.salomonbrys.kotson.int
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonArray
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.lang.Exception
|
||||
|
||||
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) {
|
||||
val call = GET("$REPO_URL/index.json")
|
||||
val response = network.client.newCall(call).await()
|
||||
|
@ -7,21 +7,23 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import eu.kanade.tachiyomi.util.system.isOutdated
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
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 {
|
||||
|
||||
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val networkHelper: NetworkHelper by injectLazy()
|
||||
@ -43,59 +45,75 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
|
||||
val response = chain.proceed(originalRequest)
|
||||
|
||||
// Check if Cloudflare anti-bot is on
|
||||
if (response.code == 503 && response.header("Server") in serverCheck) {
|
||||
try {
|
||||
response.close()
|
||||
networkHelper.cookieManager.remove(originalRequest.url, listOf("__cfduid", "cf_clearance"), 0)
|
||||
val oldCookie = networkHelper.cookieManager.get(originalRequest.url)
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
return if (resolveWithWebView(originalRequest, oldCookie)) {
|
||||
chain.proceed(originalRequest)
|
||||
} else {
|
||||
throw IOException("Failed to bypass Cloudflare!")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||
// we don't crash the entire app
|
||||
throw IOException(e)
|
||||
}
|
||||
if (response.code != 503 || response.header("Server") !in SERVER_CHECK) {
|
||||
return response
|
||||
}
|
||||
|
||||
return response
|
||||
try {
|
||||
response.close()
|
||||
networkHelper.cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0)
|
||||
val oldCookie = networkHelper.cookieManager.get(originalRequest.url)
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
resolveWithWebView(originalRequest, oldCookie)
|
||||
|
||||
// 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 {
|
||||
chain.proceed(originalRequest)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||
// we don't crash the entire app
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
// OkHttp doesn't support asynchronous interceptors.
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
var webView: WebView? = null
|
||||
|
||||
var challengeFound = false
|
||||
var cloudflareBypassed = false
|
||||
var isWebviewOutdated = false
|
||||
|
||||
val origRequestUrl = request.url.toString()
|
||||
val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
|
||||
val withUserAgent = request.header("User-Agent").isNullOrEmpty()
|
||||
|
||||
handler.post {
|
||||
val view = WebView(context.applicationContext)
|
||||
webView = view
|
||||
view.settings.javaScriptEnabled = true
|
||||
view.settings.userAgentString = request.header("User-Agent")
|
||||
view.webViewClient = object : WebViewClientCompat() {
|
||||
val webview = WebView(context)
|
||||
webView = webview
|
||||
webview.settings.javaScriptEnabled = true
|
||||
|
||||
// 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) {
|
||||
fun isCloudFlareBypassed(): Boolean {
|
||||
return networkHelper.cookieManager.get(origRequestUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
.let { it != null && it != oldCookie }
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
.let { it != null && (it != oldCookie || withUserAgent) }
|
||||
}
|
||||
|
||||
if (isCloudFlareBypassed()) {
|
||||
cloudflareBypassed = true
|
||||
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 &&
|
||||
url == origRequestUrl && !challengeFound
|
||||
) {
|
||||
@ -105,11 +123,11 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
|
||||
}
|
||||
|
||||
override fun onReceivedErrorCompat(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
isMainFrame: Boolean
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
isMainFrame: Boolean
|
||||
) {
|
||||
if (isMainFrame) {
|
||||
if (errorCode == 503) {
|
||||
@ -122,6 +140,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webView?.loadUrl(origRequestUrl, headers)
|
||||
}
|
||||
|
||||
@ -130,10 +149,28 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
|
||||
latch.await(12, TimeUnit.SECONDS)
|
||||
|
||||
handler.post {
|
||||
if (!cloudflareBypassed) {
|
||||
isWebviewOutdated = webView?.isOutdated() == true
|
||||
}
|
||||
|
||||
webView?.stopLoading()
|
||||
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)"
|
||||
}
|
||||
}
|
@ -5,8 +5,11 @@ import okhttp3.*
|
||||
import rx.Observable
|
||||
import rx.Producer
|
||||
import rx.Subscription
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.zip.GZIPInputStream
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@ -94,3 +97,23 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,10 @@ class CenteredToolbar@JvmOverloads constructor(context: Context, attrs: Attribut
|
||||
}
|
||||
else {
|
||||
toolbar_title.text = context.getString(resId)
|
||||
post {
|
||||
toolbar_title.text = context.getString(resId)
|
||||
requestLayout()
|
||||
}
|
||||
super.setTitle(null)
|
||||
}
|
||||
}
|
||||
@ -31,6 +35,10 @@ class CenteredToolbar@JvmOverloads constructor(context: Context, attrs: Attribut
|
||||
}
|
||||
else {
|
||||
toolbar_title.text = title
|
||||
post {
|
||||
toolbar_title.text = title
|
||||
requestLayout()
|
||||
}
|
||||
super.setTitle(null)
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import com.bluelinelabs.conductor.ControllerChangeHandler
|
||||
import com.bluelinelabs.conductor.ControllerChangeType
|
||||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
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.items.IFlexible
|
||||
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.global_search.CatalogueSearchController
|
||||
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.setting.SettingsSourcesController
|
||||
import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener
|
||||
import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController
|
||||
import eu.kanade.tachiyomi.util.view.scrollViewWith
|
||||
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
|
||||
import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.android.synthetic.main.catalogue_main_controller.*
|
||||
import kotlinx.android.synthetic.main.extensions_bottom_sheet.*
|
||||
import kotlinx.android.synthetic.main.main_activity.*
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* This controller shows and manages the different catalogues enabled by the user.
|
||||
@ -50,6 +53,7 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
FlexibleAdapter.OnItemClickListener,
|
||||
CatalogueAdapter.OnBrowseClickListener,
|
||||
RootSearchInterface,
|
||||
|
||||
CatalogueAdapter.OnLatestClickListener {
|
||||
|
||||
/**
|
||||
@ -62,6 +66,13 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
*/
|
||||
private var adapter: CatalogueAdapter? = null
|
||||
|
||||
var extQuery = ""
|
||||
private set
|
||||
|
||||
var headerHeight = 0
|
||||
|
||||
var customTitle = ""
|
||||
|
||||
/**
|
||||
* Called when controller is initialized.
|
||||
*/
|
||||
@ -76,7 +87,9 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
* @return title.
|
||||
*/
|
||||
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.adapter = adapter
|
||||
recycler.addItemDecoration(SourceDividerItemDecoration(view.context))
|
||||
recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener)
|
||||
|
||||
scrollViewWith(recycler)
|
||||
val attrsArray = intArrayOf(android.R.attr.actionBarSize)
|
||||
val array = view.context.obtainStyledAttributes(attrsArray)
|
||||
val appBarHeight = array.getDimensionPixelSize(0, 0)
|
||||
array.recycle()
|
||||
scrollViewWith(recycler) {
|
||||
headerHeight = it.systemWindowInsetTop + appBarHeight
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -129,6 +180,7 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
|
||||
super.onChangeStarted(handler, type)
|
||||
if (!type.isPush && handler is SettingsSourcesFadeChangeHandler) {
|
||||
ext_bottom_sheet.updateExtTitle()
|
||||
presenter.updateSources()
|
||||
}
|
||||
}
|
||||
@ -192,20 +244,41 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
* @param inflater used to load the menu xml.
|
||||
*/
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
// Inflate menu
|
||||
inflater.inflate(R.menu.catalogue_main, menu)
|
||||
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
|
||||
// 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.action_global_search_hint)
|
||||
// Change hint to show global search.
|
||||
searchView.queryHint = applicationContext?.getString(R.string.search_extensions)
|
||||
|
||||
// Create query listener which opens the global search view.
|
||||
searchView.queryTextChangeEvents()
|
||||
.filter { it.isSubmitted }
|
||||
.subscribeUntilDestroy { performGlobalSearch(it.queryText().toString()) }
|
||||
// Create query listener which opens the global search view.
|
||||
setOnQueryTextChangeListener(searchView) {
|
||||
extQuery = it ?: ""
|
||||
ext_bottom_sheet.drawExtensions()
|
||||
true
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Inflate menu
|
||||
inflater.inflate(R.menu.catalogue_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.action_global_search_hint)
|
||||
|
||||
// Create query listener which opens the global search view.
|
||||
setOnQueryTextChangeListener(searchView, true) {
|
||||
if (!it.isNullOrBlank()) performGlobalSearch(it)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performGlobalSearch(query: String){
|
||||
@ -222,9 +295,18 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
when (item.itemId) {
|
||||
// Initialize option to open catalogue settings.
|
||||
R.id.action_filter -> {
|
||||
router.pushController((RouterTransaction.with(SettingsSourcesController()))
|
||||
.popChangeHandler(SettingsSourcesFadeChangeHandler())
|
||||
.pushChangeHandler(FadeChangeHandler()))
|
||||
val controller =
|
||||
if (ext_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED)
|
||||
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)
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
import java.util.TreeMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@ -101,4 +101,4 @@ class CataloguePresenter(
|
||||
.sortedBy { "(${it.lang}) ${it.name}" } +
|
||||
sourceManager.get(LocalSource.ID) as LocalSource
|
||||
}
|
||||
}
|
||||
}
|
@ -3,17 +3,19 @@ package eu.kanade.tachiyomi.ui.extension
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.extension.ExtensionAdapter.OnButtonClickListener
|
||||
import eu.kanade.tachiyomi.util.system.getResourceColor
|
||||
|
||||
/**
|
||||
* Adapter that holds the catalogue cards.
|
||||
*
|
||||
* @param controller instance of [ExtensionController].
|
||||
* @param listener instance of [OnButtonClickListener].
|
||||
*/
|
||||
class ExtensionAdapter(val controller: ExtensionController) :
|
||||
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
|
||||
class ExtensionAdapter(val listener: OnButtonClickListener) :
|
||||
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 {
|
||||
setDisplayHeadersAtStartUp(true)
|
||||
@ -22,7 +24,7 @@ class ExtensionAdapter(val controller: ExtensionController) :
|
||||
/**
|
||||
* Listener for browse item clicks.
|
||||
*/
|
||||
val buttonClickListener: ExtensionAdapter.OnButtonClickListener = controller
|
||||
val buttonClickListener: ExtensionAdapter.OnButtonClickListener = listener
|
||||
|
||||
interface OnButtonClickListener {
|
||||
fun onButtonClick(position: Int)
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
@ -17,7 +17,7 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private typealias ExtensionTuple
|
||||
typealias ExtensionTuple
|
||||
= Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
|
||||
|
||||
/**
|
||||
|
@ -3,18 +3,18 @@ package eu.kanade.tachiyomi.ui.extension
|
||||
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 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 {
|
||||
putString(SIGNATURE_KEY, signatureHash)
|
||||
putString(PKGNAME_KEY, pkgName)
|
||||
}) {
|
||||
targetController = target
|
||||
listener = target
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
||||
@ -22,10 +22,10 @@ class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
||||
.title(R.string.untrusted_extension)
|
||||
.message(R.string.untrusted_extension_message)
|
||||
.positiveButton(R.string.ext_trust) {
|
||||
(targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!)
|
||||
listener.trustSignature(args.getString(SIGNATURE_KEY)!!)
|
||||
}
|
||||
.negativeButton(R.string.ext_uninstall) {
|
||||
(targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!)
|
||||
listener.uninstallExtension(args.getString(PKGNAME_KEY)!!)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -183,5 +183,6 @@ class LibraryCategoryAdapter(val libraryListener: LibraryListener) :
|
||||
fun selectAll(position: Int)
|
||||
fun allSelected(position: Int): Boolean
|
||||
fun showCategories(position: Int, view: View)
|
||||
fun recyclerIsScrolling(): Boolean
|
||||
}
|
||||
}
|
||||
|
@ -399,4 +399,5 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
override fun selectAll(position: Int) { }
|
||||
override fun allSelected(position: Int): Boolean = false
|
||||
override fun showCategories(position: Int, view: View) { }
|
||||
override fun recyclerIsScrolling() = false
|
||||
}
|
||||
|
@ -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.TabbedController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.download.DownloadController
|
||||
import eu.kanade.tachiyomi.ui.library.filter.SortFilterBottomSheet
|
||||
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.ui.main.RootSearchInterface
|
||||
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.reader.ReaderActivity
|
||||
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.setOnQueryTextChangeListener
|
||||
import eu.kanade.tachiyomi.util.view.snack
|
||||
@ -192,26 +191,22 @@ open class LibraryController(
|
||||
|
||||
bottom_sheet.onGroupClicked = {
|
||||
when (it) {
|
||||
SortFilterBottomSheet.ACTION_REFRESH -> onRefresh()
|
||||
SortFilterBottomSheet.ACTION_FILTER -> onFilterChanged()
|
||||
SortFilterBottomSheet.ACTION_SORT -> onSortChanged()
|
||||
SortFilterBottomSheet.ACTION_DISPLAY -> reattachAdapter()
|
||||
SortFilterBottomSheet.ACTION_DOWNLOAD_BADGE -> presenter.requestDownloadBadgesUpdate()
|
||||
SortFilterBottomSheet.ACTION_UNREAD_BADGE -> presenter.requestUnreadBadgesUpdate()
|
||||
SortFilterBottomSheet.ACTION_CAT_SORT -> onCatSortChanged()
|
||||
FilterBottomSheet.ACTION_REFRESH -> onRefresh()
|
||||
FilterBottomSheet.ACTION_FILTER -> onFilterChanged()
|
||||
FilterBottomSheet.ACTION_HIDE_FILTER_TIP -> activity?.toast(R.string.hide_filters_tip)
|
||||
}
|
||||
}
|
||||
|
||||
fab.setOnClickListener {
|
||||
/* fab.setOnClickListener {
|
||||
router.pushController(DownloadController().withFadeTransaction())
|
||||
}
|
||||
}*/
|
||||
|
||||
if (presenter.isDownloading()) {
|
||||
/* if (presenter.isDownloading()) {
|
||||
fab.scaleY = 1f
|
||||
fab.scaleX = 1f
|
||||
fab.isClickable = true
|
||||
fab.isFocusable = true
|
||||
}
|
||||
}*/
|
||||
|
||||
val config = resources?.configuration
|
||||
phoneLandscape = (config?.orientation == Configuration.ORIENTATION_LANDSCAPE &&
|
||||
@ -290,14 +285,14 @@ open class LibraryController(
|
||||
}
|
||||
|
||||
override fun downloadStatusChanged(downloading: Boolean) {
|
||||
launchUI {
|
||||
/* launchUI {
|
||||
val scale = if (downloading) 1f else 0f
|
||||
val fab = fab ?: return@launchUI
|
||||
fab.animate().scaleX(scale).scaleY(scale).setDuration(200).start()
|
||||
fab.isClickable = downloading
|
||||
fab.isFocusable = downloading
|
||||
bottom_sheet?.adjustFiltersMargin(downloading)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
override fun onUpdateManga(manga: LibraryManga) {
|
||||
|
@ -144,6 +144,7 @@ class LibraryHeaderItem(private val categoryF: (Int) -> Category, val catId: Int
|
||||
}
|
||||
}
|
||||
private fun showCatSortOptions() {
|
||||
if (adapter.libraryListener.recyclerIsScrolling()) return
|
||||
val category =
|
||||
(adapter.getItem(adapterPosition) as? LibraryHeaderItem)?.category ?: return
|
||||
// Create a PopupMenu, giving it the clicked view for an anchor
|
||||
|
@ -50,4 +50,9 @@ abstract class LibraryHolder(
|
||||
super.onItemReleased(position)
|
||||
(adapter as? LibraryCategoryAdapter)?.libraryListener?.onItemReleased(position)
|
||||
}
|
||||
|
||||
override fun onLongClick(view: View?): Boolean {
|
||||
super.onLongClick(view)
|
||||
return false // !adapter.libraryListener.recyclerIsScrolling()
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,21 @@
|
||||
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.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
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.LinearLayoutManager
|
||||
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.library.LibraryUpdateService
|
||||
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.SwipeGestureInterface
|
||||
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.scrollViewWith
|
||||
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.filter_bottom_sheet.*
|
||||
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.spinner_title.view.*
|
||||
import kotlinx.coroutines.delay
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
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),
|
||||
FlexibleAdapter.OnItemClickListener,
|
||||
@ -49,6 +63,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
FlexibleAdapter.OnItemMoveListener,
|
||||
LibraryCategoryAdapter.LibraryListener,
|
||||
SpinnerTitleInterface,
|
||||
OnTouchEventInterface,
|
||||
SwipeGestureInterface {
|
||||
|
||||
private lateinit var adapter: LibraryCategoryAdapter
|
||||
@ -66,6 +81,18 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
|
||||
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.
|
||||
*/
|
||||
@ -73,7 +100,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
|
||||
override fun contentView():View = recycler_layout
|
||||
|
||||
/* override fun getTitle(): String? {
|
||||
override fun getTitle(): String? {
|
||||
return if (::customTitleSpinner.isInitialized) customTitleSpinner.category_title.text.toString()
|
||||
else super.getTitle()
|
||||
// when {
|
||||
@ -81,7 +108,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
// spinnerAdapter?.array?.size == 1 -> return spinnerAdapter?.array?.firstOrNull()
|
||||
// else -> return super.getTitle()
|
||||
// }
|
||||
}*/
|
||||
}
|
||||
|
||||
private var scrollListener = object : RecyclerView.OnScrollListener () {
|
||||
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 {
|
||||
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.adapter = adapter
|
||||
adapter.fastScroller = fast_scroller
|
||||
//adapter.fastScroller = fast_scroller
|
||||
recycler.addOnScrollListener(scrollListener)
|
||||
|
||||
val tv = TypedValue()
|
||||
activity!!.theme.resolveAttribute(R.attr.actionBarTintColor, tv, true)
|
||||
|
||||
customTitleSpinner = library_layout.inflate(R.layout.spinner_title) as ViewGroup
|
||||
// (activity as MainActivity).supportActionBar?.setDisplayShowCustomEnabled(false)
|
||||
spinnerAdapter = SpinnerAdapter(
|
||||
view.context,
|
||||
R.layout.library_spinner_textview,
|
||||
@ -155,7 +335,6 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
scrollToHeader(item.itemId)
|
||||
true
|
||||
}
|
||||
//(activity as MainActivity).supportActionBar?.customView = customTitleSpinner
|
||||
scrollViewWith(recycler) { insets ->
|
||||
fast_scroller.updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
||||
topMargin = insets.systemWindowInsetTop
|
||||
@ -172,21 +351,13 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
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() {
|
||||
// (activity as MainActivity).supportActionBar?.setDisplayShowCustomEnabled(false)
|
||||
super.onDestroy()
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
super.onActivityResumed(activity)
|
||||
if (view == null) return
|
||||
resetScrollingValues()
|
||||
resetRecyclerY()
|
||||
}
|
||||
|
||||
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() ==
|
||||
this
|
||||
// (activity as AppCompatActivity).supportActionBar
|
||||
// ?.setDisplayShowCustomEnabled(isCurrentController && presenter.categories.size > 1)
|
||||
|
||||
customTitleSpinner.category_title.text =
|
||||
/*customTitleSpinner.category_title.text =
|
||||
presenter.categories[clamp(activeCategory,
|
||||
0,
|
||||
presenter.categories.size - 1)].name
|
||||
if (isCurrentController) setTitle()
|
||||
if (isCurrentController) setTitle()*/
|
||||
updateScroll = false
|
||||
if (!freshStart) {
|
||||
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)
|
||||
switchingCategories = true
|
||||
if (headerPosition > -1) {
|
||||
activity?.appbar?.y = 0f
|
||||
val appbar = activity?.appbar
|
||||
//if (headerPosition == 0)
|
||||
//activity?.appbar?.y = 0f
|
||||
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(
|
||||
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)
|
||||
}
|
||||
launchUI {
|
||||
@ -349,6 +538,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
}
|
||||
|
||||
override fun startReading(position: Int) {
|
||||
if (recyclerIsScrolling()) return
|
||||
if (adapter.mode == SelectableAdapter.Mode.MULTI) {
|
||||
toggleSelection(position)
|
||||
return
|
||||
@ -381,7 +571,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
* @return true if the item should be selected, false otherwise.
|
||||
*/
|
||||
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
|
||||
return if (adapter.mode == SelectableAdapter.Mode.MULTI) {
|
||||
lastClickPosition = position
|
||||
@ -399,6 +589,7 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
* @param position the position of the element clicked.
|
||||
*/
|
||||
override fun onItemLongClick(position: Int) {
|
||||
if (recyclerIsScrolling()) return
|
||||
createActionModeIfNeeded()
|
||||
when {
|
||||
lastClickPosition == -1 -> setSelection(position)
|
||||
@ -414,6 +605,8 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
override fun onActionStateChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
val position = viewHolder?.adapterPosition ?: return
|
||||
if (actionState == 2) {
|
||||
isDragging = true
|
||||
activity?.appbar?.y = 0f
|
||||
if (lastItemPosition != null && position != lastItemPosition
|
||||
&& lastItem == adapter.getItem(position)) {
|
||||
// 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()
|
||||
}
|
||||
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)
|
||||
lastItemPosition = null
|
||||
else if (lastItemPosition == null)
|
||||
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) {
|
||||
isDragging = false
|
||||
if (adapter.selectedItemCount > 0) {
|
||||
lastItemPosition = null
|
||||
return
|
||||
@ -508,18 +719,6 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
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 {
|
||||
val category = (adapter.getItem(catId) as? LibraryHeaderItem)?.category ?:
|
||||
return false
|
||||
@ -582,35 +781,53 @@ class LibraryListController(bundle: Bundle? = null) : LibraryController(bundle),
|
||||
if (sheetRect.contains(x.toInt(), y.toInt()))
|
||||
showFiltersBottomSheet()
|
||||
}
|
||||
override fun onSwipeLeft(x: Float, y: Float) = goToNextCategory(x, y,-1)
|
||||
override fun onSwipeRight(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)
|
||||
|
||||
private fun goToNextCategory(x: Float, y: Float, offset: Int) {
|
||||
val sheetRect = Rect()
|
||||
val recyclerRect = Rect()
|
||||
bottom_sheet.getGlobalVisibleRect(sheetRect)
|
||||
recycler.getGlobalVisibleRect(recyclerRect)
|
||||
private fun goToNextCategory(x: Float) {
|
||||
if (lockedRecycler && abs(x) > 1000f) {
|
||||
val sign = sign(x).roundToInt()
|
||||
if ((sign < 0 && nextCategory == null) || (sign > 0) && prevCategory == null)
|
||||
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
|
||||
}
|
||||
|
||||
if (sheetRect.contains(x.toInt(), y.toInt()) ||
|
||||
!recyclerRect.contains(x.toInt(), y.toInt())) {
|
||||
return
|
||||
}
|
||||
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
|
||||
?.plus(if (offset < 0) 1 else 0)
|
||||
else -> null
|
||||
}
|
||||
if (order != null) {
|
||||
var newOffset = order + offset
|
||||
while (adapter.indexOf(newOffset) == -1 && presenter.categories.any { it.order == newOffset }) {
|
||||
newOffset += offset
|
||||
val translationAlphaAnimator = ValueAnimator.ofFloat(recycler_layout.alpha, 0f)
|
||||
translationAlphaAnimator.duration = duration
|
||||
translationAlphaAnimator.addUpdateListener { animation ->
|
||||
recycler_layout.alpha = animation.animatedValue as Float
|
||||
}
|
||||
set.playTogether(translationXAnimator, translationAlphaAnimator)
|
||||
set.start()
|
||||
set.addListener(object : Animator.AnimatorListener {
|
||||
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
|
||||
}
|
@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
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.util.chapter.syncChaptersWithSource
|
||||
import eu.kanade.tachiyomi.util.lang.removeArticles
|
||||
@ -158,6 +159,8 @@ class LibraryPresenter(
|
||||
|
||||
val filterMangaType by lazy { preferences.filterMangaType().getOrDefault() }
|
||||
|
||||
val filterTrackers = FilterBottomSheet.FILTER_TRACKER
|
||||
|
||||
val filterFn: (LibraryItem) -> Boolean = f@ { item ->
|
||||
// Filter when there isn't unread chapters.
|
||||
if (filterUnread == STATE_INCLUDE &&
|
||||
@ -184,11 +187,18 @@ class LibraryPresenter(
|
||||
if (filterTracked != STATE_IGNORE) {
|
||||
val tracks = db.getTracks(item.manga).executeAsBlocking()
|
||||
|
||||
val trackCount = loggedServices.count { service ->
|
||||
val trackCount = loggedServices.any { service ->
|
||||
tracks.any { it.sync_id == service.id }
|
||||
}
|
||||
if (filterTracked == STATE_INCLUDE && trackCount == 0) return@f false
|
||||
if (filterTracked == STATE_EXCLUDE && trackCount > 0) return@f false
|
||||
if (filterTracked == STATE_INCLUDE && !trackCount) 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.
|
||||
if (filterDownloaded != STATE_IGNORE) {
|
||||
|
@ -33,7 +33,7 @@ import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
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),
|
||||
FilterTagGroupListener {
|
||||
|
||||
@ -50,7 +50,7 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
|
||||
|
||||
private lateinit var tracked: FilterTagGroup
|
||||
|
||||
// private lateinit var categories: FilterTagGroup
|
||||
private var trackers: FilterTagGroup? = null
|
||||
|
||||
private var mangaType: FilterTagGroup? = null
|
||||
|
||||
@ -115,9 +115,6 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
|
||||
else
|
||||
shadow.alpha = 1f
|
||||
pager?.updatePaddingRelative(bottom = sheetBehavior?.peekHeight ?: 0)
|
||||
// snackbarLayout.updatePaddingRelative(bottom = sheetBehavior?.peekHeight ?: 0)
|
||||
if (!phoneLandscape)
|
||||
preferences.hideFiltersAtStart().set(false)
|
||||
}
|
||||
if (state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
top_bar.alpha = 0f
|
||||
@ -129,8 +126,6 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
|
||||
shadow.alpha = 0f
|
||||
pager?.updatePaddingRelative(bottom = 0)
|
||||
// snackbarLayout.updatePaddingRelative(bottom = 0)
|
||||
if (!phoneLandscape)
|
||||
preferences.hideFiltersAtStart().set(true)
|
||||
}
|
||||
//top_bar.isClickable = 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) {
|
||||
shadow2.gone()
|
||||
}
|
||||
hide_filters.isChecked = preferences.hideFiltersAtStart().getOrDefault()
|
||||
hide_filters.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.hideFiltersAtStart().set(isChecked)
|
||||
if (isChecked)
|
||||
onGroupClicked(ACTION_HIDE_FILTER_TIP)
|
||||
}
|
||||
createTags()
|
||||
clearButton.setOnClickListener { clearFilters() }
|
||||
}
|
||||
@ -222,17 +223,19 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
|
||||
launchUI {
|
||||
val mangaType = inflate(R.layout.filter_buttons) as FilterTagGroup
|
||||
mangaType.setup(
|
||||
this@SortFilterBottomSheet,
|
||||
this@FilterBottomSheet,
|
||||
types.first(),
|
||||
types.getOrNull(1),
|
||||
types.getOrNull(2)
|
||||
)
|
||||
this@SortFilterBottomSheet.mangaType = mangaType
|
||||
this@FilterBottomSheet.mangaType = mangaType
|
||||
filter_layout.addView(mangaType)
|
||||
filterItems.remove(tracked)
|
||||
filterItems.add(mangaType)
|
||||
filterItems.add(tracked)
|
||||
}
|
||||
}
|
||||
launchUI {
|
||||
withContext(Dispatchers.Main) {
|
||||
hide_categories.visibleIf(showCategoriesCheckBox)
|
||||
// categories.setState(preferences.hideCategories().getOrDefault())
|
||||
downloaded.setState(preferences.filterDownloaded())
|
||||
@ -243,11 +246,34 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
|
||||
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) {
|
||||
if (updatePreference) {
|
||||
if (view == trackers) {
|
||||
FILTER_TRACKER = view.nameOf(index) ?: ""
|
||||
} else {
|
||||
when (view) {
|
||||
downloaded -> preferences.filterDownloaded()
|
||||
unread -> preferences.filterUnread()
|
||||
@ -256,7 +282,18 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
|
||||
mangaType -> preferences.filterMangaType()
|
||||
else -> null
|
||||
}?.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()
|
||||
if (hasFilters && clearButton.parent == null)
|
||||
@ -275,6 +312,7 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
|
||||
preferences.filterCompleted().set(0)
|
||||
preferences.filterTracked().set(0)
|
||||
preferences.filterMangaType().set(0)
|
||||
FILTER_TRACKER = ""
|
||||
|
||||
val transition = androidx.transition.AutoTransition()
|
||||
transition.duration = 150
|
||||
@ -305,11 +343,9 @@ class SortFilterBottomSheet @JvmOverloads constructor(context: Context, attrs: A
|
||||
|
||||
companion object {
|
||||
const val ACTION_REFRESH = 0
|
||||
const val ACTION_SORT = 1
|
||||
const val ACTION_FILTER = 2
|
||||
const val ACTION_DISPLAY = 3
|
||||
const val ACTION_DOWNLOAD_BADGE = 4
|
||||
const val ACTION_UNREAD_BADGE = 5
|
||||
const val ACTION_CAT_SORT = 6
|
||||
const val ACTION_FILTER = 1
|
||||
const val ACTION_HIDE_FILTER_TIP = 2
|
||||
var FILTER_TRACKER = ""
|
||||
private set
|
||||
}
|
||||
}
|
@ -30,6 +30,8 @@ class FilterTagGroup@JvmOverloads constructor(context: Context, attrs: Attribute
|
||||
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) {
|
||||
val text1 = context.getString(firstText)
|
||||
val text2 = if (secondText != null) context.getString(secondText) else null
|
||||
|
@ -11,6 +11,7 @@ import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.GestureDetector
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
@ -20,7 +21,6 @@ import android.view.WindowManager
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.GestureDetectorCompat
|
||||
@ -300,7 +300,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
|
||||
.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR)
|
||||
}
|
||||
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
|
||||
.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)
|
||||
|
||||
@ -383,7 +383,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
|
||||
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 {
|
||||
val scale = Settings.Global.getFloat(
|
||||
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
|
||||
delay(duration.toLong())
|
||||
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)
|
||||
}*/
|
||||
}
|
||||
|
||||
private fun setExtensionsBadge() {
|
||||
val updates = preferences.extensionUpdatesCount().getOrDefault()
|
||||
@ -422,7 +424,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
try {
|
||||
val pendingUpdates = ExtensionGithubApi().checkforUpdates(this@MainActivity)
|
||||
val pendingUpdates = ExtensionGithubApi().checkForUpdates(this@MainActivity)
|
||||
preferences.extensionUpdatesCount().set(pendingUpdates.size)
|
||||
preferences.lastExtCheck().set(Date().time)
|
||||
} catch (e: java.lang.Exception) { }
|
||||
@ -553,6 +555,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
gestureDetector.onTouchEvent(ev)
|
||||
val controller = router.backstack.lastOrNull()?.controller()
|
||||
if (controller is OnTouchEventInterface)
|
||||
controller.onTouchEvent(ev)
|
||||
if (ev?.action == MotionEvent.ACTION_DOWN) {
|
||||
if (snackBar != null && snackBar!!.isShown) {
|
||||
val sRect = Rect()
|
||||
@ -654,7 +659,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
|
||||
}
|
||||
|
||||
override fun downloadStatusChanged(downloading: Boolean) {
|
||||
val downloadManager = Injekt.get<DownloadManager>()
|
||||
/*val downloadManager = Injekt.get<DownloadManager>()
|
||||
val hasQueue = downloading || downloadManager.hasQueue()
|
||||
launchUI {
|
||||
if (hasQueue) {
|
||||
@ -664,7 +669,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
|
||||
} else {
|
||||
navigationView?.removeBadge(R.id.nav_library)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
|
||||
@ -687,9 +692,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener {
|
||||
&& abs(diffY) <= Companion.SWIPE_THRESHOLD * 0.75f
|
||||
) {
|
||||
if (diffX > 0) {
|
||||
currentGestureDelegate?.onSwipeRight(e1.x, e1.y)
|
||||
currentGestureDelegate?.onSwipeRight(velocityX, e1.y)
|
||||
} else {
|
||||
currentGestureDelegate?.onSwipeLeft(e1.x, e1.y)
|
||||
currentGestureDelegate?.onSwipeLeft(velocityX, e1.y)
|
||||
}
|
||||
result = true
|
||||
}
|
||||
@ -738,9 +743,10 @@ interface BottomNavBarInterface {
|
||||
}
|
||||
|
||||
interface RootSearchInterface
|
||||
interface SpinnerTitleInterface
|
||||
|
||||
interface SpinnerTitleInterface {
|
||||
fun popUpMenu(): PopupMenu
|
||||
interface OnTouchEventInterface {
|
||||
fun onTouchEvent(event: MotionEvent?)
|
||||
}
|
||||
|
||||
interface SwipeGestureInterface {
|
||||
|
@ -47,6 +47,8 @@ class SearchActivity: MainActivity() {
|
||||
toolbar.navigationIcon = drawerArrow
|
||||
drawerArrow?.progress = 1f
|
||||
|
||||
if (to !is SpinnerTitleInterface) toolbar.removeSpinner()
|
||||
|
||||
if (to is NoToolbarElevationController) {
|
||||
appbar.disableElevation()
|
||||
} else {
|
||||
|
@ -6,17 +6,12 @@ import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.list.listItemsSingleChoice
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController
|
||||
|
||||
/**
|
||||
* Dialog to choose a shape for the icon.
|
||||
*/
|
||||
class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) {
|
||||
|
||||
constructor(target: MangaInfoController) : this() {
|
||||
targetController = target
|
||||
}
|
||||
|
||||
constructor(target: MangaDetailsController) : this() {
|
||||
targetController = target
|
||||
}
|
||||
@ -35,7 +30,6 @@ class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) {
|
||||
items = modes.map { activity?.getString(it) as CharSequence },
|
||||
waitForPositiveButton = false)
|
||||
{ _, i, _ ->
|
||||
(targetController as? MangaInfoController)?.createShortcutForShape(i)
|
||||
(targetController as? MangaDetailsController)?.createShortcutForShape(i)
|
||||
dismissDialog()
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -27,6 +27,9 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
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.core.content.pm.ShortcutInfoCompat
|
||||
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.Snackbar
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.SelectableAdapter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
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.glide.GlideApp
|
||||
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.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.BaseController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
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.library.ChangeMangaCategoriesDialog
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryController
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
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.ChapterMatHolder
|
||||
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.track.TrackItem
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
@ -100,22 +105,22 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
|
||||
class MangaDetailsController : BaseController,
|
||||
open class MangaDetailsController : BaseController,
|
||||
FlexibleAdapter.OnItemClickListener,
|
||||
FlexibleAdapter.OnItemLongClickListener,
|
||||
ActionMode.Callback,
|
||||
ChaptersAdapter.MangaHeaderInterface,
|
||||
ChangeMangaCategoriesDialog.Listener,
|
||||
DownloadCustomChaptersDialog.Listener,
|
||||
NoToolbarElevationController {
|
||||
|
||||
constructor(manga: Manga?,
|
||||
fromCatalogue: Boolean = false,
|
||||
smartSearchConfig: CatalogueController.SmartSearchConfig? = null,
|
||||
update: Boolean = false) : super(Bundle().apply {
|
||||
putLong(MangaController.MANGA_EXTRA, manga?.id ?: 0)
|
||||
putLong(MANGA_EXTRA, manga?.id ?: 0)
|
||||
putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue)
|
||||
putParcelable(MangaController.SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig)
|
||||
putBoolean(MangaController.UPDATE_EXTRA, update)
|
||||
putParcelable(SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig)
|
||||
putBoolean(UPDATE_EXTRA, update)
|
||||
}) {
|
||||
this.manga = manga
|
||||
if (manga != null) {
|
||||
@ -126,7 +131,7 @@ class MangaDetailsController : BaseController,
|
||||
constructor(mangaId: Long) : this(
|
||||
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 context = applicationContext ?: return
|
||||
if (notificationId > -1) NotificationReceiver.dismissNotification(
|
||||
@ -143,11 +148,19 @@ class MangaDetailsController : BaseController,
|
||||
private var snack: Snackbar? = null
|
||||
val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false)
|
||||
var coverDrawable:Drawable? = null
|
||||
var trackingBottomSheet: TrackingBottomSheet? = null
|
||||
|
||||
var startingDLChapterPos:Int? = null
|
||||
/**
|
||||
* Adapter containing a list of chapters.
|
||||
*/
|
||||
private var adapter: ChaptersAdapter? = null
|
||||
|
||||
/**
|
||||
* Action mode for selections.
|
||||
*/
|
||||
private var actionMode: ActionMode? = null
|
||||
|
||||
// Hold a reference to the current animator,
|
||||
// so that it can be canceled mid-way.
|
||||
private var currentAnimator: Animator? = null
|
||||
@ -207,6 +220,13 @@ class MangaDetailsController : BaseController,
|
||||
val atTop = !recycler.canScrollVertically(-1)
|
||||
if ((!atTop && !toolbarIsColored) || (atTop && toolbarIsColored)) {
|
||||
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 =
|
||||
coverColor ?: activity!!.getResourceColor(android.R.attr.colorPrimary)
|
||||
val colorFrom =
|
||||
@ -228,9 +248,6 @@ class MangaDetailsController : BaseController,
|
||||
activity?.window?.statusBarColor = (animator.animatedValue as Int)
|
||||
}
|
||||
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) {
|
||||
super.onChangeStarted(handler, type)
|
||||
if (type == ControllerChangeType.PUSH_ENTER || type == ControllerChangeType.POP_ENTER) {
|
||||
if (type == ControllerChangeType.POP_ENTER)
|
||||
return
|
||||
setStatusBar()
|
||||
(activity as MainActivity).appbar.setBackgroundColor(Color.TRANSPARENT)
|
||||
(activity as MainActivity).toolbar.setBackgroundColor(Color.TRANSPARENT)
|
||||
activity?.window?.statusBarColor = Color.TRANSPARENT
|
||||
(activity as MainActivity).toolbar.setBackgroundColor(activity?.window?.statusBarColor
|
||||
?: Color.TRANSPARENT)
|
||||
}
|
||||
else if (type == ControllerChangeType.PUSH_EXIT || type == ControllerChangeType.POP_EXIT) {
|
||||
if (router.backstack.lastOrNull()?.controller() is DialogController)
|
||||
@ -347,7 +363,6 @@ class MangaDetailsController : BaseController,
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
|
||||
fun updateChapters(chapters: List<ChapterItem>) {
|
||||
swipe_refresh?.isRefreshing = presenter.isLoading
|
||||
if (presenter.chapters.isEmpty() && fromCatalogue && !presenter.hasRequested) {
|
||||
@ -363,6 +378,32 @@ class MangaDetailsController : BaseController,
|
||||
override fun onItemClick(view: View?, position: Int): Boolean {
|
||||
val chapter = adapter?.getItem(position)?.chapter ?: 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)
|
||||
return false
|
||||
}
|
||||
@ -444,6 +485,8 @@ class MangaDetailsController : BaseController,
|
||||
override fun onDestroyView(view: View) {
|
||||
snack?.dismiss()
|
||||
presenter.onDestroy()
|
||||
adapter = null
|
||||
trackingBottomSheet = null
|
||||
super.onDestroyView(view)
|
||||
}
|
||||
|
||||
@ -547,7 +590,7 @@ class MangaDetailsController : BaseController,
|
||||
R.id.download_next_5 -> presenter.getUnreadChaptersSorted().take(5)
|
||||
R.id.download_next_10 -> presenter.getUnreadChaptersSorted().take(10)
|
||||
R.id.download_custom -> {
|
||||
showCustomDownloadDialog()
|
||||
createActionModeIfNeeded()
|
||||
return
|
||||
}
|
||||
R.id.download_unread -> presenter.chapters.filter { !it.read }
|
||||
@ -636,7 +679,7 @@ class MangaDetailsController : BaseController,
|
||||
val shortcutIntent = activity.intent
|
||||
.setAction(MainActivity.SHORTCUT_MANGA)
|
||||
.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
|
||||
if (ShortcutManagerCompat.isRequestPinShortcutSupported(activity)) {
|
||||
@ -665,15 +708,9 @@ class MangaDetailsController : BaseController,
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCustomDownloadDialog() {
|
||||
DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router)
|
||||
}
|
||||
|
||||
override fun downloadCustomChapters(amount: Int) {
|
||||
val chaptersToDownload = presenter.getUnreadChaptersSorted().take(amount)
|
||||
if (chaptersToDownload.isNotEmpty()) {
|
||||
downloadChapters(chaptersToDownload)
|
||||
}
|
||||
override fun startDownloadRange(position: Int) {
|
||||
createActionModeIfNeeded()
|
||||
onItemClick(null, position)
|
||||
}
|
||||
|
||||
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
|
||||
@ -869,6 +906,91 @@ class MangaDetailsController : BaseController,
|
||||
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) {
|
||||
// If there's an animation in progress, cancel it immediately and proceed with this one.
|
||||
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"
|
||||
}
|
||||
}
|
@ -20,11 +20,13 @@ import eu.kanade.tachiyomi.data.library.LibraryServiceListener
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
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.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
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.util.chapter.syncChaptersWithSource
|
||||
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 } }
|
||||
var tracks = emptyList<Track>()
|
||||
|
||||
var trackList: List<TrackItem> = emptyList()
|
||||
|
||||
var chapters:List<ChapterItem> = emptyList()
|
||||
private set
|
||||
@ -73,6 +76,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
|
||||
headerItem.isLocked = isLockedFromSearch
|
||||
downloadManager.addListener(this)
|
||||
LibraryUpdateService.setListener(this)
|
||||
tracks = db.getTracks(manga).executeAsBlocking()
|
||||
if (!manga.initialized) {
|
||||
isLoading = true
|
||||
controller.setRefresh(true)
|
||||
@ -81,9 +85,9 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
|
||||
}
|
||||
else {
|
||||
updateChapters()
|
||||
tracks = db.getTracks(manga).executeAsBlocking()
|
||||
controller.updateChapters(this.chapters)
|
||||
}
|
||||
fetchTrackings()
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
@ -94,6 +98,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
|
||||
fun fetchChapters() {
|
||||
launch {
|
||||
getChapters()
|
||||
refreshTracking()
|
||||
withContext(Dispatchers.Main) { controller.updateChapters(chapters) }
|
||||
}
|
||||
}
|
||||
@ -161,7 +166,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
|
||||
}
|
||||
/**
|
||||
* Sets the active display mode.
|
||||
* @param mode the mode to set.
|
||||
* @param hide set title to hidden
|
||||
*/
|
||||
fun hideTitle(hide: Boolean) {
|
||||
manga.displayMode = if (hide) Manga.DISPLAY_NUMBER else Manga.DISPLAY_NAME
|
||||
@ -658,7 +663,124 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
|
||||
return false
|
||||
}
|
||||
|
||||
fun isTracked(): Boolean {
|
||||
return loggedServices.any { service -> tracks.any { it.sync_id == service.id } }
|
||||
fun isTracked(): Boolean = 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)
|
||||
}
|
||||
}
|
@ -32,12 +32,10 @@ class MangaHeaderHolder(
|
||||
startExpanded: Boolean
|
||||
) : MangaChapterHolder(view, adapter) {
|
||||
|
||||
|
||||
|
||||
init {
|
||||
start_reading_button.setOnClickListener { adapter.coverListener?.readNextChapter() }
|
||||
start_reading_button.setOnClickListener { adapter.coverListener.readNextChapter() }
|
||||
top_view.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
height = adapter.coverListener?.topCoverHeight() ?: 0
|
||||
height = adapter.coverListener.topCoverHeight()
|
||||
}
|
||||
more_button.setOnClickListener { expandDesc() }
|
||||
manga_summary.setOnClickListener { expandDesc() }
|
||||
@ -48,29 +46,30 @@ class MangaHeaderHolder(
|
||||
more_button_group.visible()
|
||||
}
|
||||
manga_genres_tags.setOnTagClickListener {
|
||||
adapter.coverListener?.tagClicked(it)
|
||||
adapter.coverListener.tagClicked(it)
|
||||
}
|
||||
filter_button.setOnClickListener { adapter.coverListener?.showChapterFilter() }
|
||||
filters_text.setOnClickListener { adapter.coverListener?.showChapterFilter() }
|
||||
chapters_title.setOnClickListener { adapter.coverListener?.showChapterFilter() }
|
||||
webview_button.setOnClickListener { adapter.coverListener?.openInWebView() }
|
||||
share_button.setOnClickListener { adapter.coverListener?.prepareToShareManga() }
|
||||
filter_button.setOnClickListener { adapter.coverListener.showChapterFilter() }
|
||||
filters_text.setOnClickListener { adapter.coverListener.showChapterFilter() }
|
||||
chapters_title.setOnClickListener { adapter.coverListener.showChapterFilter() }
|
||||
webview_button.setOnClickListener { adapter.coverListener.openInWebView() }
|
||||
share_button.setOnClickListener { adapter.coverListener.prepareToShareManga() }
|
||||
favorite_button.setOnClickListener {
|
||||
adapter.coverListener?.favoriteManga(false)
|
||||
adapter.coverListener.favoriteManga(false)
|
||||
}
|
||||
favorite_button.setOnLongClickListener {
|
||||
adapter.coverListener?.favoriteManga(true)
|
||||
adapter.coverListener.favoriteManga(true)
|
||||
true
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
manga_cover.setOnClickListener { adapter.coverListener?.zoomImageFromThumb(cover_card) }
|
||||
manga_cover.setOnClickListener { adapter.coverListener.zoomImageFromThumb(cover_card) }
|
||||
track_button.setOnClickListener { adapter.coverListener.showTrackingSheet() }
|
||||
if (startExpanded)
|
||||
expandDesc()
|
||||
}
|
||||
@ -144,6 +143,7 @@ class MangaHeaderHolder(
|
||||
val tracked = presenter.isTracked() && !item.isLocked
|
||||
|
||||
with(track_button) {
|
||||
visibleIf(presenter.hasTrackers())
|
||||
text = itemView.context.getString(if (tracked) R.string.action_filter_tracked
|
||||
else R.string.tracking)
|
||||
|
||||
@ -154,18 +154,24 @@ class MangaHeaderHolder(
|
||||
|
||||
with(start_reading_button) {
|
||||
val nextChapter = presenter.getNextUnreadChapter()
|
||||
visibleIf(nextChapter != null && !item.isLocked)
|
||||
visibleIf(presenter.chapters.isNotEmpty() && !item.isLocked)
|
||||
isEnabled = (nextChapter != null)
|
||||
if (nextChapter != null) {
|
||||
val number = adapter.decimalFormat.format(nextChapter.chapter_number.toDouble())
|
||||
text = resources.getString(
|
||||
when {
|
||||
nextChapter.last_page_read > 0 && nextChapter.chapter_number <= 0 ->
|
||||
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
|
||||
text = if (nextChapter.chapter_number > 0) resources.getString(
|
||||
if (nextChapter.last_page_read > 0) R.string.continue_reading_chapter
|
||||
else R.string.start_reading_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)
|
||||
|
||||
top_view.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
height = adapter.coverListener.topCoverHeight() ?: 0
|
||||
height = adapter.coverListener.topCoverHeight()
|
||||
}
|
||||
|
||||
manga_status.text = (itemView.context.getString( when (manga.status) {
|
||||
@ -230,6 +236,19 @@ class MangaHeaderHolder(
|
||||
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 {
|
||||
super.onLongClick(view)
|
||||
return false
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -41,7 +41,7 @@ class ChapterItem(val chapter: Chapter, val manga: Manga) :
|
||||
}
|
||||
|
||||
override fun isSelectable(): Boolean {
|
||||
return chapter.isHeader
|
||||
return !chapter.isHeader
|
||||
}
|
||||
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): MangaChapterHolder {
|
||||
|
@ -20,17 +20,17 @@ class ChapterMatHolder(
|
||||
) : MangaChapterHolder(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) } }
|
||||
download_button.setOnClickListener { downloadOrRemoveMenu() }
|
||||
download_button.setOnLongClickListener {
|
||||
adapter.coverListener.startDownloadRange(adapterPosition)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadOrRemoveMenu() {
|
||||
val chapter = adapter.getItem(adapterPosition) ?: return
|
||||
if (chapter.status == Download.NOT_DOWNLOADED || chapter.status == Download.ERROR) {
|
||||
adapter.coverListener?.downloadChapter(adapterPosition)
|
||||
adapter.coverListener.downloadChapter(adapterPosition)
|
||||
} else {
|
||||
download_button.post {
|
||||
// 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
|
||||
popup.setOnMenuItemClickListener { _ ->
|
||||
adapter.coverListener?.downloadChapter(adapterPosition)
|
||||
adapter.coverListener.downloadChapter(adapterPosition)
|
||||
true
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,13 @@
|
||||
package eu.kanade.tachiyomi.ui.manga.chapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
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.security.SecureActivityDelegate
|
||||
import eu.kanade.tachiyomi.util.system.getResourceColor
|
||||
@ -18,7 +17,7 @@ import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
|
||||
class ChaptersAdapter(
|
||||
val controller: BaseController,
|
||||
val controller: MangaDetailsController,
|
||||
context: Context
|
||||
) : FlexibleAdapter<ChapterItem>(null, controller, true) {
|
||||
|
||||
@ -26,8 +25,7 @@ class ChaptersAdapter(
|
||||
|
||||
var items: List<ChapterItem> = emptyList()
|
||||
|
||||
val menuItemListener: OnMenuItemClickListener? = controller as? OnMenuItemClickListener
|
||||
val coverListener: MangaHeaderInterface? = controller as? MangaHeaderInterface
|
||||
val coverListener: MangaHeaderInterface = controller
|
||||
|
||||
val readColor = context.getResourceColor(android.R.attr.textColorHint)
|
||||
|
||||
@ -54,10 +52,6 @@ class ChaptersAdapter(
|
||||
SecureActivityDelegate.promptLockIfNeeded(activity)
|
||||
}
|
||||
|
||||
interface OnMenuItemClickListener {
|
||||
fun onMenuItemClick(position: Int, item: MenuItem)
|
||||
}
|
||||
|
||||
interface MangaHeaderInterface {
|
||||
fun coverColor(): Int?
|
||||
fun mangaPresenter(): MangaDetailsPresenter
|
||||
@ -71,5 +65,7 @@ class ChaptersAdapter(
|
||||
fun favoriteManga(longPress: Boolean)
|
||||
fun copyToClipboard(content: String, label: Int)
|
||||
fun zoomImageFromThumb(thumbView: View)
|
||||
fun showTrackingSheet()
|
||||
fun startDownloadRange(position: Int)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -6,7 +6,6 @@ import android.widget.NumberPicker
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.customview.customView
|
||||
import com.afollestad.materialdialogs.customview.getCustomView
|
||||
import com.bluelinelabs.conductor.Controller
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
@ -15,14 +14,15 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SetTrackChaptersDialog<T> : DialogController
|
||||
where T : Controller, T : SetTrackChaptersDialog.Listener {
|
||||
where T : SetTrackChaptersDialog.Listener {
|
||||
|
||||
private val item: TrackItem
|
||||
private lateinit var listener: Listener
|
||||
|
||||
constructor(target: T, item: TrackItem) : super(Bundle().apply {
|
||||
putSerializable(KEY_ITEM_TRACK, item.track)
|
||||
}) {
|
||||
targetController = target
|
||||
listener = target
|
||||
this.item = item
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ class SetTrackChaptersDialog<T> : DialogController
|
||||
// Remove focus to update selected number
|
||||
val np: NumberPicker = view.findViewById(R.id.chapters_picker)
|
||||
np.clearFocus()
|
||||
(targetController as? Listener)?.setChaptersRead(item, np.value)
|
||||
listener.setChaptersRead(item, np.value)
|
||||
}
|
||||
|
||||
val view = dialog.getCustomView()
|
||||
|
@ -15,14 +15,15 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SetTrackScoreDialog<T> : DialogController
|
||||
where T : Controller, T : SetTrackScoreDialog.Listener {
|
||||
where T : SetTrackScoreDialog.Listener {
|
||||
|
||||
private val item: TrackItem
|
||||
private lateinit var listener: Listener
|
||||
|
||||
constructor(target: T, item: TrackItem) : super(Bundle().apply {
|
||||
putSerializable(KEY_ITEM_TRACK, item.track)
|
||||
}) {
|
||||
targetController = target
|
||||
listener = target
|
||||
this.item = item
|
||||
}
|
||||
|
||||
@ -46,8 +47,7 @@ class SetTrackScoreDialog<T> : DialogController
|
||||
val np: NumberPicker = view.findViewById(R.id.score_picker)
|
||||
np.clearFocus()
|
||||
|
||||
(targetController as? Listener)?.setScore(item, np.value)
|
||||
|
||||
listener.setScore(item, np.value)
|
||||
}
|
||||
|
||||
|
||||
|
@ -4,7 +4,6 @@ import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.list.listItemsSingleChoice
|
||||
import com.bluelinelabs.conductor.Controller
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
@ -13,14 +12,16 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SetTrackStatusDialog<T> : DialogController
|
||||
where T : Controller, T : SetTrackStatusDialog.Listener {
|
||||
where T : SetTrackStatusDialog.Listener {
|
||||
|
||||
private val item: TrackItem
|
||||
private lateinit var listener: Listener
|
||||
|
||||
constructor(target: T, item: TrackItem) : super(Bundle().apply {
|
||||
putSerializable(KEY_ITEM_TRACK, item.track)
|
||||
}) {
|
||||
targetController = target
|
||||
listener = target
|
||||
// targetController = target
|
||||
this.item = item
|
||||
}
|
||||
|
||||
@ -43,7 +44,7 @@ class SetTrackStatusDialog<T> : DialogController
|
||||
.listItemsSingleChoice(items = statusString, initialSelection = selectedIndex,
|
||||
waitForPositiveButton = false)
|
||||
{ dialog, position, _ ->
|
||||
(targetController as? Listener)?.setStatus(item, position)
|
||||
listener.setStatus(item, position)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
package eu.kanade.tachiyomi.ui.manga.track
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
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>()
|
||||
set(value) {
|
||||
@ -34,9 +35,13 @@ class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHold
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
fun indexOf(item: TrackService?):Int {
|
||||
return items.indexOfFirst { item?.id == it.service.id }
|
||||
}
|
||||
|
||||
interface OnClickListener {
|
||||
fun onLogoClick(position: Int)
|
||||
fun onTitleClick(position: Int)
|
||||
fun onSetClick(position: Int)
|
||||
fun onStatusClick(position: Int)
|
||||
fun onChaptersClick(position: Int)
|
||||
fun onScoreClick(position: Int)
|
||||
|
@ -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"
|
||||
}
|
||||
|
||||
}
|
@ -2,8 +2,8 @@ package eu.kanade.tachiyomi.ui.manga.track
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
|
||||
import eu.kanade.tachiyomi.util.view.visibleIf
|
||||
import kotlinx.android.synthetic.main.track_item.*
|
||||
|
||||
class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
|
||||
@ -11,32 +11,28 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
|
||||
init {
|
||||
val listener = adapter.rowClickListener
|
||||
logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) }
|
||||
title_container.setOnClickListener { listener.onTitleClick(adapterPosition) }
|
||||
track_set.setOnClickListener { listener.onSetClick(adapterPosition) }
|
||||
status_container.setOnClickListener { listener.onStatusClick(adapterPosition) }
|
||||
chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) }
|
||||
score_container.setOnClickListener { listener.onScoreClick(adapterPosition) }
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
@Suppress("DEPRECATION")
|
||||
fun bind(item: TrackItem) {
|
||||
val track = item.track
|
||||
track_logo.setImageResource(item.service.getLogo())
|
||||
logo_container.setBackgroundColor(item.service.getLogoColor())
|
||||
track_group.visibleIf(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}/" +
|
||||
if (track.total_chapters > 0) track.total_chapters else "-"
|
||||
track_status.text = item.service.getStatus(track.status)
|
||||
track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track)
|
||||
} else {
|
||||
track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Medium_Button)
|
||||
track_title.setText(R.string.action_edit)
|
||||
track_chapters.text = ""
|
||||
track_score.text = ""
|
||||
track_status.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
fun setProgress(enabled: Boolean) {
|
||||
progress.visibleIf(enabled)
|
||||
track_logo.visibleIf(!enabled)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -15,11 +15,10 @@ import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import 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 kotlinx.android.synthetic.main.track_search_dialog.view.progress
|
||||
import kotlinx.android.synthetic.main.track_search_dialog.view.track_search
|
||||
import kotlinx.android.synthetic.main.track_search_dialog.view.track_search_list
|
||||
import kotlinx.android.synthetic.main.track_search_dialog.view.*
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
@ -41,17 +40,22 @@ class TrackSearchDialog : DialogController {
|
||||
|
||||
private var searchTextSubscription: Subscription? = null
|
||||
|
||||
private val trackController
|
||||
get() = targetController as TrackController
|
||||
private lateinit var bottomSheet: TrackingBottomSheet
|
||||
//private val trackController
|
||||
// get() = targetController as TrackController
|
||||
|
||||
|
||||
|
||||
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 {
|
||||
putInt(KEY_SERVICE, service.id)
|
||||
}) {
|
||||
putInt(KEY_SERVICE, service.id)
|
||||
}) {
|
||||
wasPreviouslyTracked = wasTracked
|
||||
targetController = target
|
||||
bottomSheet = target
|
||||
presenter = target.presenter
|
||||
this.service = service
|
||||
}
|
||||
|
||||
@ -97,7 +101,7 @@ class TrackSearchDialog : DialogController {
|
||||
|
||||
// Do an initial search based on the manga's title
|
||||
if (savedState == null) {
|
||||
val title = trackController.presenter.manga.originalTitle()
|
||||
val title = presenter.manga.originalTitle()
|
||||
view.track_search.append(title)
|
||||
search(title)
|
||||
}
|
||||
@ -129,7 +133,7 @@ class TrackSearchDialog : DialogController {
|
||||
val view = dialogView ?: return
|
||||
view.progress.visibility = View.VISIBLE
|
||||
view.track_search_list.visibility = View.INVISIBLE
|
||||
trackController.presenter.search(query, service)
|
||||
presenter.trackSearch(query, service)
|
||||
}
|
||||
|
||||
fun onSearchResults(results: List<TrackSearch>) {
|
||||
@ -153,8 +157,10 @@ class TrackSearchDialog : DialogController {
|
||||
}
|
||||
|
||||
private fun onPositiveButtonClick() {
|
||||
trackController.swipe_refresh.isRefreshing = true
|
||||
trackController.presenter.registerTracking(selectedItem, service)
|
||||
// trackController.swipe_refresh.isRefreshing = true
|
||||
bottomSheet.refreshTrack(service)
|
||||
presenter.registerTracking(selectedItem,
|
||||
service)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
@ -24,6 +24,11 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
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.Observable
|
||||
import rx.Subscription
|
||||
@ -40,11 +45,11 @@ import java.util.concurrent.TimeUnit
|
||||
* Presenter used by the activity to perform background operations.
|
||||
*/
|
||||
class ReaderPresenter(
|
||||
private val db: DatabaseHelper = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
private val coverCache: CoverCache = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
private val db: DatabaseHelper = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
private val coverCache: CoverCache = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
) : BasePresenter<ReaderActivity>() {
|
||||
|
||||
/**
|
||||
@ -87,19 +92,19 @@ class ReaderPresenter(
|
||||
val dbChapters = db.getChapters(manga).executeAsBlocking()
|
||||
|
||||
val selectedChapter = dbChapters.find { it.id == chapterId }
|
||||
?: error("Requested chapter of id $chapterId not found in chapter list")
|
||||
?: error("Requested chapter of id $chapterId not found in chapter list")
|
||||
|
||||
val chaptersForReader =
|
||||
if (preferences.skipRead()) {
|
||||
val list = dbChapters.filter { !it.read }.toMutableList()
|
||||
val find = list.find { it.id == chapterId }
|
||||
if (find == null) {
|
||||
list.add(selectedChapter)
|
||||
}
|
||||
list
|
||||
} else {
|
||||
dbChapters
|
||||
if (preferences.skipRead()) {
|
||||
val list = dbChapters.filter { !it.read }.toMutableList()
|
||||
val find = list.find { it.id == chapterId }
|
||||
if (find == null) {
|
||||
list.add(selectedChapter)
|
||||
}
|
||||
list
|
||||
} else {
|
||||
dbChapters
|
||||
}
|
||||
|
||||
when (manga.sorting) {
|
||||
Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader)
|
||||
@ -170,12 +175,12 @@ class ReaderPresenter(
|
||||
if (!needsInit()) return
|
||||
|
||||
db.getManga(mangaId).asRxObservable()
|
||||
.first()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext { init(it, initialChapterId) }
|
||||
.subscribeFirst({ _, _ ->
|
||||
// Ignore onNext event
|
||||
}, ReaderActivity::setInitialChapterError)
|
||||
.first()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext { init(it, initialChapterId) }
|
||||
.subscribeFirst({ _, _ ->
|
||||
// Ignore onNext event
|
||||
}, ReaderActivity::setInitialChapterError)
|
||||
}
|
||||
|
||||
fun init(mangaId: Long, chapterUrl: String) {
|
||||
@ -207,13 +212,13 @@ class ReaderPresenter(
|
||||
// Read chapterList from an io thread because it's retrieved lazily and would block main.
|
||||
activeChapterSubscription?.unsubscribe()
|
||||
activeChapterSubscription = Observable
|
||||
.fromCallable { chapterList.first { chapterId == it.chapter.id } }
|
||||
.flatMap { getLoadObservable(loader!!, it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst({ _, _ ->
|
||||
// Ignore onNext event
|
||||
}, ReaderActivity::setInitialChapterError)
|
||||
.fromCallable { chapterList.first { chapterId == it.chapter.id } }
|
||||
.flatMap { getLoadObservable(loader!!, it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst({ _, _ ->
|
||||
// Ignore onNext event
|
||||
}, ReaderActivity::setInitialChapterError)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -224,27 +229,29 @@ class ReaderPresenter(
|
||||
* Callers must also handle the onError event.
|
||||
*/
|
||||
private fun getLoadObservable(
|
||||
loader: ChapterLoader,
|
||||
chapter: ReaderChapter
|
||||
loader: ChapterLoader,
|
||||
chapter: ReaderChapter
|
||||
): Observable<ViewerChapters> {
|
||||
return loader.loadChapter(chapter)
|
||||
.andThen(Observable.fromCallable {
|
||||
val chapterPos = chapterList.indexOf(chapter)
|
||||
.andThen(Observable.fromCallable {
|
||||
val chapterPos = chapterList.indexOf(chapter)
|
||||
|
||||
ViewerChapters(chapter,
|
||||
chapterList.getOrNull(chapterPos - 1),
|
||||
chapterList.getOrNull(chapterPos + 1))
|
||||
})
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext { newChapters ->
|
||||
val oldChapters = viewerChaptersRelay.value
|
||||
ViewerChapters(
|
||||
chapter,
|
||||
chapterList.getOrNull(chapterPos - 1),
|
||||
chapterList.getOrNull(chapterPos + 1)
|
||||
)
|
||||
})
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext { newChapters ->
|
||||
val oldChapters = viewerChaptersRelay.value
|
||||
|
||||
// Add new references first to avoid unnecessary recycling
|
||||
newChapters.ref()
|
||||
oldChapters?.unref()
|
||||
// Add new references first to avoid unnecessary recycling
|
||||
newChapters.ref()
|
||||
oldChapters?.unref()
|
||||
|
||||
viewerChaptersRelay.call(newChapters)
|
||||
}
|
||||
viewerChaptersRelay.call(newChapters)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -258,10 +265,10 @@ class ReaderPresenter(
|
||||
|
||||
activeChapterSubscription?.unsubscribe()
|
||||
activeChapterSubscription = getLoadObservable(loader, chapter)
|
||||
.toCompletable()
|
||||
.onErrorComplete()
|
||||
.subscribe()
|
||||
.also(::add)
|
||||
.toCompletable()
|
||||
.onErrorComplete()
|
||||
.subscribe()
|
||||
.also(::add)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -276,13 +283,13 @@ class ReaderPresenter(
|
||||
|
||||
activeChapterSubscription?.unsubscribe()
|
||||
activeChapterSubscription = getLoadObservable(loader, chapter)
|
||||
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
|
||||
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
|
||||
.subscribeFirst({ view, _ ->
|
||||
view.moveToPageIndex(0)
|
||||
}, { _, _ ->
|
||||
// Ignore onError event, viewers handle that state
|
||||
})
|
||||
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
|
||||
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
|
||||
.subscribeFirst({ view, _ ->
|
||||
view.moveToPageIndex(0)
|
||||
}, { _, _ ->
|
||||
// Ignore onError event, viewers handle that state
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -299,12 +306,12 @@ class ReaderPresenter(
|
||||
val loader = loader ?: return
|
||||
|
||||
loader.loadChapter(chapter)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Update current chapters whenever a chapter is preloaded
|
||||
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
|
||||
.onErrorComplete()
|
||||
.subscribe()
|
||||
.also(::add)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Update current chapters whenever a chapter is preloaded
|
||||
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
|
||||
.onErrorComplete()
|
||||
.subscribe()
|
||||
.also(::add)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -348,9 +355,9 @@ class ReaderPresenter(
|
||||
*/
|
||||
private fun saveChapterProgress(chapter: ReaderChapter) {
|
||||
db.updateChapterProgress(chapter.chapter).asRxCompletable()
|
||||
.onErrorComplete()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
.onErrorComplete()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -412,18 +419,18 @@ class ReaderPresenter(
|
||||
db.updateMangaViewer(manga).executeAsBlocking()
|
||||
|
||||
Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||
.subscribeFirst({ view, _ ->
|
||||
val currChapters = viewerChaptersRelay.value
|
||||
if (currChapters != null) {
|
||||
// Save current page
|
||||
val currChapter = currChapters.currChapter
|
||||
currChapter.requestedPage = currChapter.chapter.last_page_read
|
||||
.subscribeFirst({ view, _ ->
|
||||
val currChapters = viewerChaptersRelay.value
|
||||
if (currChapters != null) {
|
||||
// Save current page
|
||||
val currChapter = currChapters.currChapter
|
||||
currChapter.requestedPage = currChapter.chapter.last_page_read
|
||||
|
||||
// Emit manga and chapters to the new viewer
|
||||
view.setManga(manga)
|
||||
view.setChapters(currChapters)
|
||||
}
|
||||
})
|
||||
// Emit manga and chapters to the new viewer
|
||||
view.setManga(manga)
|
||||
view.setChapters(currChapters)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -439,7 +446,7 @@ class ReaderPresenter(
|
||||
|
||||
// Build destination file.
|
||||
val filename = DiskUtil.buildValidFilename(
|
||||
"${manga.currentTitle()} - ${chapter.name}".take(225)
|
||||
"${manga.currentTitle()} - ${chapter.name}".take(225)
|
||||
) + " - ${page.number}.${type.extension}"
|
||||
|
||||
val destFile = File(directory, filename)
|
||||
@ -464,23 +471,25 @@ class ReaderPresenter(
|
||||
notifier.onClear()
|
||||
|
||||
// Pictures directory.
|
||||
val destDir = File(Environment.getExternalStorageDirectory().absolutePath +
|
||||
val destDir = File(
|
||||
Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + Environment.DIRECTORY_PICTURES +
|
||||
File.separator + "Tachiyomi")
|
||||
File.separator + "Tachiyomi"
|
||||
)
|
||||
|
||||
// Copy file in background.
|
||||
Observable.fromCallable { saveImage(page, destDir, manga) }
|
||||
.doOnNext { file ->
|
||||
DiskUtil.scanMedia(context, file)
|
||||
notifier.onComplete(file)
|
||||
}
|
||||
.doOnError { notifier.onError(it.message) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
|
||||
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
|
||||
)
|
||||
.doOnNext { file ->
|
||||
DiskUtil.scanMedia(context, file)
|
||||
notifier.onComplete(file)
|
||||
}
|
||||
.doOnError { notifier.onError(it.message) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
|
||||
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -498,13 +507,13 @@ class ReaderPresenter(
|
||||
val destDir = File(context.cacheDir, "shared_image")
|
||||
|
||||
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
|
||||
.map { saveImage(page, destDir, manga) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, file -> view.onShareImageResult(file) },
|
||||
{ _, _ -> /* Empty */ }
|
||||
)
|
||||
.map { saveImage(page, destDir, manga) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, file -> view.onShareImageResult(file) },
|
||||
{ _, _ -> /* Empty */ }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -516,29 +525,29 @@ class ReaderPresenter(
|
||||
val stream = page.stream ?: return
|
||||
|
||||
Observable
|
||||
.fromCallable {
|
||||
if (manga.source == LocalSource.ID) {
|
||||
val context = Injekt.get<Application>()
|
||||
LocalSource.updateCover(context, manga, stream())
|
||||
R.string.cover_updated
|
||||
.fromCallable {
|
||||
if (manga.source == LocalSource.ID) {
|
||||
val context = Injekt.get<Application>()
|
||||
LocalSource.updateCover(context, manga, stream())
|
||||
R.string.cover_updated
|
||||
SetAsCoverResult.Success
|
||||
} else {
|
||||
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
|
||||
if (manga.favorite) {
|
||||
coverCache.copyToCache(thumbUrl, stream())
|
||||
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
|
||||
SetAsCoverResult.Success
|
||||
} else {
|
||||
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
|
||||
if (manga.favorite) {
|
||||
coverCache.copyToCache(thumbUrl, stream())
|
||||
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
|
||||
SetAsCoverResult.Success
|
||||
} else {
|
||||
SetAsCoverResult.AddToLibraryFirst
|
||||
}
|
||||
SetAsCoverResult.AddToLibraryFirst
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, result -> view.onSetAsCoverResult(result) },
|
||||
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
|
||||
)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, result -> view.onSetAsCoverResult(result) },
|
||||
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -568,27 +577,24 @@ class ReaderPresenter(
|
||||
|
||||
val trackManager = Injekt.get<TrackManager>()
|
||||
|
||||
db.getTracks(manga).asRxSingle()
|
||||
.flatMapCompletable { trackList ->
|
||||
Completable.concat(trackList.map { track ->
|
||||
val service = trackManager.getService(track.sync_id)
|
||||
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
|
||||
// We wan't these to execute even if the presenter is destroyed so launch on GlobalScope
|
||||
GlobalScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val trackList = db.getTracks(manga).executeAsBlocking()
|
||||
trackList.map { track ->
|
||||
val service = trackManager.getService(track.sync_id)
|
||||
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
|
||||
try {
|
||||
track.last_chapter_read = chapterRead
|
||||
|
||||
// We wan't these to execute even if the presenter is destroyed and leaks
|
||||
// for a while. The view can still be garbage collected.
|
||||
Observable.defer { service.update(track) }
|
||||
.map { db.insertTrack(track).executeAsBlocking() }
|
||||
.toCompletable()
|
||||
.onErrorComplete()
|
||||
} else {
|
||||
Completable.complete()
|
||||
service.update(track)
|
||||
db.insertTrack(track).executeAsBlocking()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.onErrorComplete()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -604,19 +610,19 @@ class ReaderPresenter(
|
||||
if (removeAfterReadSlots == -1) return
|
||||
|
||||
Completable
|
||||
.fromCallable {
|
||||
// Position of the read chapter
|
||||
val position = chapterList.indexOf(chapter)
|
||||
.fromCallable {
|
||||
// Position of the read chapter
|
||||
val position = chapterList.indexOf(chapter)
|
||||
|
||||
// Retrieve chapter to delete according to preference
|
||||
val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
|
||||
if (chapterToDelete != null) {
|
||||
downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
|
||||
}
|
||||
// Retrieve chapter to delete according to preference
|
||||
val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
|
||||
if (chapterToDelete != null) {
|
||||
downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
|
||||
}
|
||||
.onErrorComplete()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
.onErrorComplete()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -625,9 +631,8 @@ class ReaderPresenter(
|
||||
*/
|
||||
private fun deletePendingChapters() {
|
||||
Completable.fromCallable { downloadManager.deletePendingChapters() }
|
||||
.onErrorComplete()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
.onErrorComplete()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -5,12 +5,12 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.Spinner
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
@ -115,7 +115,7 @@ class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDia
|
||||
show_page_number.bindToPreference(preferences.showPageNumber())
|
||||
fullscreen.bindToPreference(preferences.fullscreen())
|
||||
keepscreen.bindToPreference(preferences.keepScreenOn())
|
||||
long_tap.bindToPreference(preferences.readWithLongTap())
|
||||
always_show_chapter_transition.bindToPreference(preferences.alwaysShowChapterTransition())
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -46,6 +46,9 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe
|
||||
var readerTheme = 0
|
||||
private set
|
||||
|
||||
var alwaysShowChapterTransition = true
|
||||
private set
|
||||
|
||||
init {
|
||||
preferences.readWithTapping()
|
||||
.register({ tappingEnabled = it })
|
||||
@ -76,6 +79,9 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe
|
||||
|
||||
preferences.readerTheme()
|
||||
.register({ readerTheme = it })
|
||||
|
||||
preferences.alwaysShowChapterTransition()
|
||||
.register({ alwaysShowChapterTransition = it })
|
||||
}
|
||||
|
||||
fun unsubscribe() {
|
||||
|
@ -144,8 +144,10 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
Timber.d("onReaderPageSelected: ${page.number}/${pages.size}")
|
||||
activity.onPageSelected(page)
|
||||
|
||||
if (page === pages.last()) {
|
||||
Timber.d("Request preload next chapter because we're at the last page")
|
||||
// Preload next chapter once we're within the last 3 pages of the current chapter
|
||||
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 {
|
||||
activity.requestPreloadChapter(it)
|
||||
}
|
||||
@ -185,7 +187,8 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
*/
|
||||
private fun setChaptersInternal(chapters: ViewerChapters) {
|
||||
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
|
||||
if (pager.visibility == View.GONE) {
|
||||
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
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.ViewerChapters
|
||||
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
|
||||
* has R2L direction.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters) {
|
||||
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
|
||||
val newItems = mutableListOf<Any>()
|
||||
|
||||
// Add previous chapter pages and transition.
|
||||
@ -39,7 +40,11 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
newItems.addAll(prevPages.takeLast(2))
|
||||
}
|
||||
}
|
||||
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// Add current chapter.
|
||||
val currPages = chapters.currChapter.pages
|
||||
@ -49,7 +54,13 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
|
||||
// Add next chapter transition and pages.
|
||||
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) {
|
||||
// Add at most two pages, because this chapter will be selected before the user can
|
||||
// swap more pages.
|
||||
|
@ -6,6 +6,7 @@ import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
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.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
|
||||
* next/previous chapter to allow seamless transitions.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters) {
|
||||
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
|
||||
val newItems = mutableListOf<Any>()
|
||||
|
||||
// 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.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// Add current chapter.
|
||||
val currPages = chapters.currChapter.pages
|
||||
@ -45,7 +50,10 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R
|
||||
}
|
||||
|
||||
// Add next chapter transition and pages.
|
||||
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||
if (forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
|
||||
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||
}
|
||||
|
||||
if (chapters.nextChapter != null) {
|
||||
// Add at most two pages, because this chapter will be selected before the user can
|
||||
// swap more pages.
|
||||
|
@ -34,6 +34,9 @@ class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) {
|
||||
var doubleTapAnimDuration = 500
|
||||
private set
|
||||
|
||||
var alwaysShowChapterTransition = true
|
||||
private set
|
||||
|
||||
init {
|
||||
preferences.readWithTapping()
|
||||
.register({ tappingEnabled = it })
|
||||
@ -52,6 +55,9 @@ class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) {
|
||||
|
||||
preferences.readWithVolumeKeysInverted()
|
||||
.register({ volumeKeysInverted = it })
|
||||
|
||||
preferences.alwaysShowChapterTransition()
|
||||
.register({ alwaysShowChapterTransition = it })
|
||||
}
|
||||
|
||||
fun unsubscribe() {
|
||||
|
@ -142,9 +142,11 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
Timber.d("onPageSelected: ${page.number}/${pages.size}")
|
||||
activity.onPageSelected(page)
|
||||
|
||||
if (page === pages.last()) {
|
||||
Timber.d("Request preload next chapter because we're at the last page")
|
||||
val transition = adapter.items.getOrNull(position + 1) as? ChapterTransition.Next
|
||||
// Preload next chapter once we're within the last 3 pages of the current chapter
|
||||
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}")
|
||||
val transition = adapter.items.getOrNull(pages.size + 1) as? ChapterTransition.Next
|
||||
if (transition?.to != null) {
|
||||
activity.requestPreloadChapter(transition.to)
|
||||
}
|
||||
@ -172,7 +174,8 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
*/
|
||||
override fun setChapters(chapters: ViewerChapters) {
|
||||
Timber.d("setChapters")
|
||||
adapter.setChapters(chapters)
|
||||
var forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition
|
||||
adapter.setChapters(chapters, forceTransition)
|
||||
|
||||
if (recycler.visibility == View.GONE) {
|
||||
Timber.d("Recycler first layout")
|
||||
|
@ -11,7 +11,6 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceScreen
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.list.listItemsSingleChoice
|
||||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
@ -34,6 +33,13 @@ class SettingsDownloadController : SettingsController() {
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) {
|
||||
titleRes = R.string.pref_category_downloads
|
||||
|
||||
preference {
|
||||
titleRes = R.string.label_download_queue
|
||||
onClick {
|
||||
router.pushController(DownloadController().withFadeTransaction())
|
||||
}
|
||||
}
|
||||
|
||||
preference {
|
||||
key = Keys.downloadsDirectory
|
||||
titleRes = R.string.pref_download_directory
|
||||
|
@ -45,11 +45,11 @@ class SettingsGeneralController : SettingsController() {
|
||||
intListPreference(activity) {
|
||||
key = Keys.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.system_theme, R.string.sysyem_white_theme, R.string.system_amoled_theme, R.string
|
||||
.system_darkblue_theme)
|
||||
entryValues = listOf(1, 8, 2, 3, 4, 5, 9, 6, 7)
|
||||
R.string.sysyem_white_theme, R.string.system_theme, R.string.system_amoled_theme,
|
||||
R.string.system_darkblue_theme)
|
||||
entryValues = listOf(8, 1, 2, 3, 4, 9, 5, 6, 7)
|
||||
defaultValue = 9
|
||||
|
||||
onChange {
|
||||
|
@ -8,7 +8,6 @@ import com.bluelinelabs.conductor.Controller
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.R
|
||||
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.util.system.getResourceColor
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
@ -24,13 +23,6 @@ class SettingsMainController : SettingsController() {
|
||||
|
||||
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 {
|
||||
iconRes = R.drawable.ic_tune_white_24dp
|
||||
iconTint = tintColor
|
||||
|
@ -87,6 +87,12 @@ class SettingsReaderController : SettingsController() {
|
||||
defaultValue = false
|
||||
}
|
||||
}
|
||||
switchPreference {
|
||||
key = Keys.alwaysShowChapterTransition
|
||||
titleRes = R.string.pref_always_show_chapter_transition
|
||||
defaultValue = true
|
||||
}
|
||||
|
||||
preferenceCategory {
|
||||
titleRes = R.string.pager_viewer
|
||||
|
||||
|
@ -50,7 +50,7 @@ class SettingsTrackingController : SettingsController(),
|
||||
}
|
||||
trackPreference(trackManager.kitsu) {
|
||||
onClick {
|
||||
val dialog = TrackLoginDialog(trackManager.kitsu)
|
||||
val dialog = TrackLoginDialog(trackManager.kitsu, context.getString(R.string.email))
|
||||
dialog.targetController = this@SettingsTrackingController
|
||||
dialog.showDialog(router)
|
||||
}
|
||||
|
@ -2,21 +2,25 @@ package eu.kanade.tachiyomi.ui.setting.track
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.view.Gravity.CENTER
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ProgressBar
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class AnilistLoginActivity : AppCompatActivity() {
|
||||
|
||||
private val trackManager: TrackManager by injectLazy()
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
@ -26,14 +30,10 @@ class AnilistLoginActivity : AppCompatActivity() {
|
||||
val regex = "(?:access_token=)(.*?)(?:&)".toRegex()
|
||||
val matchResult = regex.find(intent.data?.fragment.toString())
|
||||
if (matchResult?.groups?.get(1) != null) {
|
||||
trackManager.aniList.login(matchResult.groups[1]!!.value)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
returnToSettings()
|
||||
}, {
|
||||
returnToSettings()
|
||||
})
|
||||
scope.launch {
|
||||
trackManager.aniList.login(matchResult.groups[1]!!.value)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.aniList.logout()
|
||||
returnToSettings()
|
||||
@ -47,5 +47,4 @@ class AnilistLoginActivity : AppCompatActivity() {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -2,13 +2,18 @@ package eu.kanade.tachiyomi.ui.setting.track
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.view.Gravity.CENTER
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ProgressBar
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
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.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
@ -17,6 +22,8 @@ class BangumiLoginActivity : AppCompatActivity() {
|
||||
|
||||
private val trackManager: TrackManager by injectLazy()
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
@ -25,14 +32,10 @@ class BangumiLoginActivity : AppCompatActivity() {
|
||||
|
||||
val code = intent.data?.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
trackManager.bangumi.login(code)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
returnToSettings()
|
||||
}, {
|
||||
returnToSettings()
|
||||
})
|
||||
scope.launch {
|
||||
trackManager.bangumi.login(code)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.bangumi.logout()
|
||||
returnToSettings()
|
||||
@ -46,5 +49,4 @@ class BangumiLoginActivity : AppCompatActivity() {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -2,21 +2,25 @@ package eu.kanade.tachiyomi.ui.setting.track
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.view.Gravity.CENTER
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ProgressBar
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class ShikimoriLoginActivity : AppCompatActivity() {
|
||||
|
||||
private val trackManager: TrackManager by injectLazy()
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
@ -25,14 +29,10 @@ class ShikimoriLoginActivity : AppCompatActivity() {
|
||||
|
||||
val code = intent.data?.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
trackManager.shikimori.login(code)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
returnToSettings()
|
||||
}, {
|
||||
returnToSettings()
|
||||
})
|
||||
scope.launch {
|
||||
trackManager.shikimori.login(code)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.shikimori.logout()
|
||||
returnToSettings()
|
||||
@ -46,5 +46,4 @@ class ShikimoriLoginActivity : AppCompatActivity() {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
@ -169,7 +169,7 @@ inline val View.marginLeft: Int
|
||||
|
||||
object RecyclerWindowInsetsListener : View.OnApplyWindowInsetsListener {
|
||||
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)
|
||||
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 {
|
||||
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 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,
|
||||
swipeRefreshLayout: SwipeRefreshLayout? = null,
|
||||
f: ((WindowInsets) -> Unit)? = null) {
|
||||
afterInsets: ((WindowInsets) -> Unit)? = null) {
|
||||
var statusBarHeight = -1
|
||||
activity!!.appbar.y = 0f
|
||||
activity?.appbar?.y = 0f
|
||||
val attrsArray = intArrayOf(android.R.attr.actionBarSize)
|
||||
val array = recycler.context.obtainStyledAttributes(attrsArray)
|
||||
val appBarHeight = array.getDimensionPixelSize(0, 0)
|
||||
array.recycle()
|
||||
recycler.doOnApplyWindowInsets { view, insets, _ ->
|
||||
val attrsArray = intArrayOf(android.R.attr.actionBarSize)
|
||||
val array = view.context.obtainStyledAttributes(attrsArray)
|
||||
val headerHeight = insets.systemWindowInsetTop + array.getDimensionPixelSize(0, 0)
|
||||
val headerHeight = insets.systemWindowInsetTop + appBarHeight
|
||||
view.updatePaddingRelative(
|
||||
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
|
||||
array.recycle()
|
||||
f?.invoke(insets)
|
||||
afterInsets?.invoke(insets)
|
||||
}
|
||||
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
if (router.backstack.lastOrNull()?.controller() == this@scrollViewWith &&
|
||||
if (router?.backstack?.lastOrNull()?.controller() == this@scrollViewWith &&
|
||||
statusBarHeight > -1 &&
|
||||
activity != null &&
|
||||
activity!!.appbar.height > 0) {
|
||||
activity!!.appbar.y -= dy
|
||||
activity!!.appbar.y = clamp(
|
||||
activity!!.appbar.y,
|
||||
-activity!!.appbar.height.toFloat(),// + statusBarHeight,
|
||||
-activity!!.appbar.height.toFloat(),
|
||||
0f
|
||||
)
|
||||
}
|
||||
@ -350,8 +356,8 @@ fun Controller.scrollViewWith(recycler: RecyclerView,
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
super.onScrollStateChanged(recyclerView, newState)
|
||||
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
if (router.backstack.lastOrNull()?.controller() == this@scrollViewWith &&
|
||||
statusBarHeight > -1 &&
|
||||
if (router?.backstack?.lastOrNull()?.controller() == this@scrollViewWith &&
|
||||
statusBarHeight > -1 && activity != null &&
|
||||
activity!!.appbar.height > 0) {
|
||||
val halfWay = abs((-activity!!.appbar.height.toFloat()) / 2)
|
||||
val shortAnimationDuration = resources?.getInteger(
|
||||
|
@ -28,7 +28,7 @@ class ExtensionPreference @JvmOverloads constructor(context: Context, attrs: Att
|
||||
val updates = Injekt.get<PreferencesHelper>().extensionUpdatesCount().getOrDefault()
|
||||
if (updates > 0) {
|
||||
extUpdateText.text = context.resources.getQuantityString(R.plurals
|
||||
.extensions_updates_available, updates, updates)
|
||||
.updates_available, updates, updates)
|
||||
extUpdateText.visible()
|
||||
}
|
||||
else {
|
||||
|
@ -13,11 +13,18 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
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 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
|
||||
private set
|
||||
@ -53,6 +60,10 @@ abstract class LoginDialogPreference(bundle: Bundle? = null) : DialogController(
|
||||
password.transformationMethod = PasswordTransformationMethod()
|
||||
}
|
||||
|
||||
if (!usernameLabel.isNullOrEmpty()) {
|
||||
username_label.text = usernameLabel
|
||||
}
|
||||
|
||||
login.setMode(ActionProcessButton.Mode.ENDLESS)
|
||||
login.setOnClickListener { checkLogin() }
|
||||
|
||||
|
@ -16,7 +16,7 @@ import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
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
|
||||
|
||||
|
@ -6,22 +6,25 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
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.login
|
||||
import kotlinx.android.synthetic.main.pref_account_login.view.password
|
||||
import kotlinx.android.synthetic.main.pref_account_login.view.username
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import kotlinx.android.synthetic.main.pref_account_login.view.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uy.kohesive.injekt.Injekt
|
||||
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"))!!
|
||||
|
||||
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) {
|
||||
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())
|
||||
}
|
||||
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = TODO("Not yet implemented")
|
||||
|
||||
override fun checkLogin() {
|
||||
requestSubscription?.unsubscribe()
|
||||
|
||||
@ -40,17 +46,21 @@ class TrackLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) {
|
||||
val user = username.text.toString()
|
||||
val pass = password.text.toString()
|
||||
|
||||
requestSubscription = service.login(user, pass)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
service.login(user, pass)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
dialog?.dismiss()
|
||||
context.toast(R.string.login_success)
|
||||
}, { error ->
|
||||
login.progress = -1
|
||||
login.setText(R.string.unknown_error)
|
||||
error.message?.let { context.toast(it) }
|
||||
})
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
login.progress = -1
|
||||
login.setText(R.string.unknown_error)
|
||||
error.message?.let { context.toast(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,5 +79,4 @@ class TrackLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) {
|
||||
interface Listener {
|
||||
fun trackDialogClosed(service: TrackService)
|
||||
}
|
||||
|
||||
}
|
||||
|
6
app/src/main/res/color/btn_bg_primary_selector.xml
Normal file
6
app/src/main/res/color/btn_bg_primary_selector.xml
Normal 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>
|
6
app/src/main/res/color/mtrl_btn_bg_selector.xml
Normal file
6
app/src/main/res/color/mtrl_btn_bg_selector.xml
Normal 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>
|
@ -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>
|
@ -2,6 +2,7 @@
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:tint="?actionBarTintColor"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
|
9
app/src/main/res/layout/auto_ext_checkbox.xml
Normal file
9
app/src/main/res/layout/auto_ext_checkbox.xml
Normal 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>
|
@ -1,15 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
<FrameLayout
|
||||
android:id="@+id/frame_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
tools:listitem="@layout/catalogue_main_controller_card" />
|
||||
android:layout_height="match_parent"
|
||||
android:background="?android:attr/colorBackground">
|
||||
|
||||
</FrameLayout>
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:paddingBottom="20dp"
|
||||
tools:listitem="@layout/catalogue_main_controller_card" />
|
||||
|
||||
</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>
|
||||
|
@ -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>
|
@ -2,13 +2,14 @@
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/chapter_layout"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chapter_title"
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
@ -23,7 +24,7 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chapter_scanlator"
|
||||
style="@style/TextAppearance.Regular.Caption.Hint"
|
||||
style="@style/TextAppearance.MaterialComponents.Caption"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user