diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index b72283abec..67c4dad27a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -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( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 48b56acb09..02c1011bbd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -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" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 395378278b..486851cdda 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt index 62c34d422f..c0368cbd5a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index f4bfcaeff4..b668efaebd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -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 + abstract suspend fun add(track: Track): Track - abstract fun update(track: Track): Observable + abstract suspend fun update(track: Track): Track - abstract fun bind(track: Track): Observable + abstract suspend fun bind(track: Track): Track - abstract fun search(query: String): Observable> + abstract suspend fun search(query: String): List - abstract fun refresh(track: Track): Observable + 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() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 5c83029730..d3b5d78968 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -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 { + override suspend fun add(track: Track): Track { return api.addLibManga(track) } - override fun update(track: Track): Observable { + 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 { - 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> { + override suspend fun search(query: String): List { return api.search(query) } - override fun refresh(track: Track): Observable { - return api.getLibManga(track, getUsername().toInt()) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } + override 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() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index 2dacccf141..6422556652 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -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 { + 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 { + 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> { + suspend fun search(search: String): List { 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 { + 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 { - 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> { + suspend fun getCurrentUser(): Pair { 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() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt index 6877b6efb6..5eba6f373c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt @@ -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().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") } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt deleted file mode 100644 index d058a85f59..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eu.kanade.tachiyomi.data.track.bangumi - -data class Avatar( - val large: String? = "", - val medium: String? = "", - val small: String? = "" -) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt index 147dde6de6..35bbff47e5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt @@ -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 { + override suspend fun add(track: Track): Track { return api.addLibManga(track) } - override fun update(track: Track): Observable { + 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 { - 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> { + override suspend fun search(query: String): List { return api.search(query) } - override fun refresh(track: Track): Observable { - return api.statusLibManga(track) - .flatMap { - track.copyPersonalFrom(it!!) - api.findLibManga(track) - .map { remoteTrack -> - if (remoteTrack != null) { - track.total_chapters = remoteTrack.total_chapters - track.status = remoteTrack.status - } - track - } - } + 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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt index 3b356f6533..d24f50b042 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -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 { + 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 { + 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> { - val url = Uri.parse( - "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon() + suspend fun search(search: String): List { + 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 { - 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 { + 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 { - 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() + ) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt index add168201e..fc2617d335 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt @@ -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) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt index 03143d19f5..db0d8396c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt @@ -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? = "" +) + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt deleted file mode 100644 index 811d0fd459..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt +++ /dev/null @@ -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) - -} - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt deleted file mode 100644 index 3d2ea3c14b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eu.kanade.tachiyomi.data.track.bangumi - -data class Status( - val id: Int? = 0, - val name: String? = "", - val type: String? = "" -) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt deleted file mode 100644 index 9e82f533e3..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt +++ /dev/null @@ -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? = "" -) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 4b4f8cbcfe..40a431e7d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -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 { + override suspend fun add(track: Track): Track { return api.addLibManga(track, getUserId()) } - override fun update(track: Track): Observable { + 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 { - 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> { + override suspend fun search(query: String): List { return api.search(query) } - override fun refresh(track: Track): Observable { - return api.getLibManga(track) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } + override 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 } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index fa72b6d547..272206f034 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -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 { + val key = searchRest.getKey()["media"].asJsonObject["key"].string + return algoliaSearch(key, query) + } + + private suspend fun algoliaSearch(key: String, query: String): List { + 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 { - 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 { - 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> { - return searchRest - .getKey().map { json -> - json["media"].asJsonObject["key"].string - }.flatMap { key -> - algoliaSearch(key, query) - } - } - - - private fun algoliaSearch(key: String, query: String): Observable> { - 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 { - 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 { - 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 { - 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 { - 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 + 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 - + 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 + 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 + 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 - + suspend fun getCurrentUser( + @Query("filter[self]", encoded = true) self: Boolean = true + ): JsonObject } private interface SearchKeyRest { @GET("media/") - fun getKey(): Observable + 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 + 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 - + 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() + ) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index db14187fc0..3c3efe5e37 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -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 { + override suspend fun add(track: Track): Track { return api.addLibManga(track) } - override fun update(track: Track): Observable { + 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 { - 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> { + override suspend fun search(query: String): List { return api.search(query) } - override fun refresh(track: Track): Observable { - return api.getLibManga(track) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } + override 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 } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index 65c16d0e25..672bce8a11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -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> { - return if (query.startsWith(PREFIX_MY)) { + suspend fun search(query: String): List { + 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 { - return Observable.defer { - authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))) - .asObservableSuccess() - .map { track } - } - } - - fun updateLibManga(track: Track): Observable { - return Observable.defer { - authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))) - .asObservableSuccess() - .map { track } - } - } - - fun findLibManga(track: Track): Observable { - 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 { - 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> { - 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 { + val results = getListXml(getListUrl()).select("manga") - private fun getListUrl(): Observable { - 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 { - 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 - } + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt index 00f7a517ff..33df133ca0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt @@ -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 { + override suspend fun add(track: Track): Track { return api.addLibManga(track, getUsername()) } - override fun update(track: Track): Observable { + 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 { - 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> { + override suspend fun search(query: String): List { return api.search(query) } - override fun refresh(track: Track): Observable { - return api.findLibManga(track, getUsername()) - .map { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - } - track - } + 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?) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt index 74702fcca2..e5ff4ca81d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt @@ -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 { - 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 = addLibManga(track, user_id) - - fun search(search: String): Observable> { - val url = Uri.parse("$apiUrl/mangas").buildUpon() + suspend fun search(search: String): List { + 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 { - 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 { - 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() + ) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.kt index 4d2a1de66c..fd056bbc08 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.kt @@ -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() } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt index 02c8d1afdc..72dd10d3f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt @@ -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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index d55c4b9357..ad7ad63ce2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -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 { + suspend fun checkForUpdates(context: Context): List { return withContext(Dispatchers.IO) { val call = GET("$REPO_URL/index.json") val response = network.client.newCall(call).await() diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt index 7960bdcb6e..77bffc2ec4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt @@ -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)" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index dc76ff0e7a..7bb1a0d849 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -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() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/CenteredToolbar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/CenteredToolbar.kt index 00dccc4c69..61b5e9e3e9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/CenteredToolbar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/CenteredToolbar.kt @@ -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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt index 787c43a758..4a9882796c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt @@ -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(), FlexibleAdapter.OnItemClickListener, CatalogueAdapter.OnBrowseClickListener, RootSearchInterface, + CatalogueAdapter.OnLatestClickListener { /** @@ -62,6 +66,13 @@ class CatalogueController : NucleusController(), */ 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(), * @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(), 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(), 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(), * @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(), 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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt index 096c5b19e7..ae5611e70a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt @@ -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 } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt index f8b1f56715..d40c17c7e5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt @@ -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>(null, controller, true) { +class ExtensionAdapter(val listener: OnButtonClickListener) : + FlexibleAdapter>(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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt new file mode 100644 index 0000000000..2c1793ae6a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt @@ -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() + + private var currentDownloads = hashMapOf() + + fun onCreate() { + extensionManager.findAvailableExtensions() + bindToExtensionsObservable() + } + + private fun bindToExtensionsObservable(): Subscription { + val installedObservable = extensionManager.getInstalledExtensionsObservable() + val untrustedObservable = extensionManager.getUntrustedExtensionsObservable() + val availableObservable = extensionManager.getAvailableExtensionsObservable() + .startWith(emptyList()) + + 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 { + val context = Injekt.get() + val activeLangs = preferences.enabledLanguages().getOrDefault() + + val (installed, untrusted, available) = tuple + + val items = mutableListOf() + + 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.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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt new file mode 100644 index 0000000000..7f2a106715 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt @@ -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>? = null + + val presenter = ExtensionBottomPresenter(this) + + private var extensions: List = 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 { + 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) { + //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) : AbstractHeaderItem() { + + override fun getLayoutRes(): Int { + return R.layout.auto_ext_checkbox + } + + override fun createViewHolder( + view: View, adapter: FlexibleAdapter> + ): AutoCheckHolder { + return AutoCheckHolder(view, adapter, autoCheck) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: AutoCheckHolder, + position: Int, + payloads: MutableList? + ) { + //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>, + autoCheck: Preference) : + 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) { + isChecked = pref.getOrDefault() + setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt index 04d2a8de3a..5830d35569 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt @@ -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, List> /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt index 577b27fd0a..3b16e4361b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt @@ -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(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(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)!!) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt index 1975ab8037..fca03ba4c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt @@ -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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt index b46ec61726..114cf40908 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index ba3ca0b721..7773a0cd46 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderItem.kt index b8063c7e07..b676f946a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderItem.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt index 3e294a3605..f77172b3e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt @@ -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() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListController.kt index 2d40139e2e..7207b00abd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListController.kt @@ -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 { 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, 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 } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 185b72b0b6..212d248e18 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/SortFilterBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/SortFilterBottomSheet.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt index 12e3391480..52ae793f51 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/SortFilterBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt @@ -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().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 } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterTagGroup.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterTagGroup.kt index 49393c5656..720e613614 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterTagGroup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterTagGroup.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index fcd7a9b6c9..612d628007 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -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() + /*val downloadManager = Injekt.get() 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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/SearchActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/SearchActivity.kt index f3cfb38c4c..2bdb3b4041 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/SearchActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/SearchActivity.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/ChooseShapeDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/ChooseShapeDialog.kt index 866b0df394..0f8271de3c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/ChooseShapeDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/ChooseShapeDialog.kt @@ -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() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt deleted file mode 100644 index 0b7d1fcdb1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ /dev/null @@ -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().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().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().getOrStub(manga.source) - } - } - - constructor(mangaId: Long) : this( - Injekt.get().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 = BehaviorRelay.create() - - val chapterCountRelay: BehaviorRelay = BehaviorRelay.create() - - val mangaFavoriteRelay: PublishRelay = PublishRelay.create() - - private val trackingIconRelay: BehaviorRelay = BehaviorRelay.create() - - private var trackingIconSubscription: Subscription? = null - - override fun getTitle(): String? { - return manga?.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().services.filter { it.isLogged } } - val db = Injekt.get() - 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().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 - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 5c384b9bfd..a2dade8ec1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -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().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) { 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() + 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) { + trackingBottomSheet?.onNextTrackings(trackings) + } + + fun onTrackSearchResults(results: List) { + 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" + } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index 1e4c66c524..a5edc5f3fa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -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().services.filter { it.isLogged } } var tracks = emptyList() + var trackList: List = emptyList() var chapters:List = 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) } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt index 02c1f46768..e855a62933 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt @@ -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 { - 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 { - 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/TrackingBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/TrackingBottomSheet.kt new file mode 100644 index 0000000000..63c7b35608 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/TrackingBottomSheet.kt @@ -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) { + onRefreshDone() + adapter?.items = trackings + controller.refreshTracker() + } + + fun onSearchResults(results: List) { + 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" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt deleted file mode 100644 index 0c6fff4216..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt +++ /dev/null @@ -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() - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt index 0b7b43b242..130894fbf4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt @@ -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>): MangaChapterHolder { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterMatHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterMatHolder.kt index 6c194b1a2b..b4c8fad61e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterMatHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterMatHolder.kt @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt index ff7375c5fa..a317758899 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt @@ -1,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(null, controller, true) { @@ -26,8 +25,7 @@ class ChaptersAdapter( var items: List = 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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt deleted file mode 100644 index 49128d07da..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt +++ /dev/null @@ -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(), - 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() - - 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 { - bottomMargin = fabBaseMarginBottom + insets.systemWindowInsetBottom - } - fast_scroller?.updateLayoutParams { - 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() { - 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) { - // 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 { - 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) { - presenter.markChaptersRead(chapters, true) - if (presenter.preferences.removeAfterMarkedAsRead()) { - deleteChapters(chapters) - } - } - - private fun markAsUnread(chapters: List) { - presenter.markChaptersRead(chapters, false) - } - - private fun downloadChapters(chapters: List) { - val view = view - destroyActionModeIfNeeded() - presenter.downloadChapters(chapters) - if (view != null && !presenter.manga.favorite && (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() { - 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, bookmarked: Boolean) { - destroyActionModeIfNeeded() - presenter.bookmarkChapters(chapters, bookmarked) - } - - fun deleteChapters(chapters: List) { - destroyActionModeIfNeeded() - if (chapters.isEmpty()) return - presenter.deleteChapters(chapters) - } - - fun onChaptersDeleted(chapters: List) { - //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) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt deleted file mode 100644 index e172e4d7fd..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt +++ /dev/null @@ -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, - private val lastUpdateRelay: BehaviorRelay, - private val mangaFavoriteRelay: PublishRelay, - val preferences: PreferencesHelper = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get() -) : BasePresenter() { - - /** - * List of chapters of the manga. It's always unfiltered and unsorted. - */ - var chapters: List = emptyList() - private set - - /** - * Subject of list of chapters to allow updating the view without going to DB. - */ - val chaptersRelay: PublishRelay> - by lazy { PublishRelay.create>() } - - /** - * Whether the chapter list has been requested to the source. - */ - var hasRequested = false - private set - - /** - * Subscription to retrieve the new list of chapters from the source. - */ - private var fetchChaptersSubscription: Subscription? = null - - /** - * Subscription to observe download status changes. - */ - private var observeDownloadsSubscription: Subscription? = null - - 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) { - for (chapter in chapters) { - if (downloadManager.isChapterDownloaded(chapter, manga)) { - chapter.status = Download.DOWNLOADED - } - } - } - - /** - * Requests an updated list of chapters from the source. - */ - fun fetchChaptersFromSource() { - hasRequested = true - - if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return - fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } - .subscribeOn(Schedulers.io()) - .map { syncChaptersWithSource(db, it, manga, source) } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - view.onFetchChaptersDone() - }, ChaptersController::onFetchChaptersError) - } - - /** - * Updates the UI after applying the filters. - */ - private fun refreshChapters() { - chaptersRelay.call(chapters) - } - - /** - * Applies the view filters to the list of chapters obtained from the database. - * @param chapters the list of chapters from the database - * @return an observable of the list of chapters filtered and sorted. - */ - private fun applyChapterFilters(chapters: List): Observable> { - var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) - if (onlyUnread()) { - observable = observable.filter { !it.read } - } else if (onlyRead()) { - observable = observable.filter { it.read } - } - if (onlyDownloaded()) { - observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } - } - if (onlyBookmarked()) { - observable = observable.filter { it.bookmark } - } - val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { - Manga.SORTING_SOURCE -> when (sortDescending()) { - true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } - false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } - } - Manga.SORTING_NUMBER -> when (sortDescending()) { - true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) } - false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } - } - else -> throw NotImplementedError("Unimplemented sorting method") - } - return observable.toSortedList(sortFunction) - } - - /** - * Called when a download for the active manga changes status. - * @param download the download whose status changed. - */ - fun onDownloadStatusChange(download: Download) { - // Assign the download to the model object. - if (download.status == Download.QUEUE) { - chapters.find { it.id == download.chapter.id }?.let { - if (it.download == null) { - it.download = download - } - } - } - - // Force UI update if downloaded filter active and download finished. - if (onlyDownloaded() && download.status == Download.DOWNLOADED) - refreshChapters() - } - - /** - * Returns the next unread chapter or null if everything is read. - */ - fun getNextUnreadChapter(): ChapterItem? { - return chapters.sortedByDescending { it.source_order }.find { !it.read } - } - - /** - * Mark the selected chapter list as read/unread. - * @param selectedChapters the list of selected chapters. - * @param read whether to mark chapters as read or unread. - */ - fun markChaptersRead(selectedChapters: List, read: Boolean) { - Observable.from(selectedChapters) - .doOnNext { chapter -> - chapter.read = read - if (!read) { - chapter.last_page_read = 0 - 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) { - downloadManager.downloadChapters(manga, chapters) - } - - /** - * Bookmarks the given list of chapters. - * @param selectedChapters the list of chapters to bookmark. - */ - fun bookmarkChapters(selectedChapters: List, bookmarked: Boolean) { - Observable.from(selectedChapters) - .doOnNext { chapter -> - chapter.bookmark = bookmarked - } - .toList() - .flatMap { db.updateChaptersProgress(it).asRxObservable() } - .subscribeOn(Schedulers.io()) - .subscribe() - } - - /** - * Deletes the given list of chapter. - * @param chapters the list of chapters to delete. - */ - fun deleteChapters(chapters: List) { - Observable.just(chapters) - .doOnNext { deleteChaptersInternal(chapters) } - .doOnNext { if (onlyDownloaded()) refreshChapters() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - view.onChaptersDeleted(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) { - 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 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt deleted file mode 100644 index 234fd88c6d..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt +++ /dev/null @@ -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(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() - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadCustomChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadCustomChaptersDialog.kt deleted file mode 100644 index 13e2e80f7d..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadCustomChaptersDialog.kt +++ /dev/null @@ -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 : 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" - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt deleted file mode 100644 index 43c355ea1b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt +++ /dev/null @@ -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(), - 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 { - bottomMargin = fabBaseMarginBottom + insets.systemWindowInsetBottom - } - manga_cover?.updateLayoutParams { - bottomMargin = mangaCoverMarginBottom + insets.systemWindowInsetBottom - } - } else { - manga_genres_tags?.updateLayoutParams { - bottomMargin = bottomM + insets.systemWindowInsetBottom - } - } - manga_cover_full?.updateLayoutParams { - 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() { - override fun onResourceReady(resource: Drawable, - transition: Transition? - ) { - 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() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - 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() { - 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, categories: List) { - val manga = mangas.firstOrNull() ?: return - presenter.moveMangaToCategories(manga, categories) - } - - /** - * Add a shortcut of the manga to the home screen - */ - private fun addToHomeScreen() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // TODO are transformations really unsupported or is it just the Pixel Launcher? - createShortcutForShape() - } else { - ChooseShapeDialog(this).showDialog(router) - } - } - - /** - * 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(96, 96) { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - 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() - } - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt deleted file mode 100644 index 43e6353e5a..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt +++ /dev/null @@ -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, - private val lastUpdateRelay: BehaviorRelay, - private val mangaFavoriteRelay: PublishRelay, - private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get(), - private val coverCache: CoverCache = Injekt.get() -) : BasePresenter() { - - /** - * Subscription to send the manga to the view. - */ - private var viewMangaSubscription: Subscription? = null - - /** - * Subscription to update the manga from the source. - */ - private var fetchMangaSubscription: Subscription? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - sendMangaToView() - - // Update chapter count - chapterCountRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(MangaInfoController::setChapterCount) - - // Update favorite status - mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribe { setFavorite(it) } - .apply { add(this) } - - //update last update date - lastUpdateRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(MangaInfoController::setLastUpdateDate) - } - - /** - * Sends the active manga to the view. - */ - fun sendMangaToView() { - viewMangaSubscription?.let { remove(it) } - viewMangaSubscription = Observable.just(manga) - .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) }) - } - - /** - * Fetch manga information from source. - */ - fun fetchMangaFromSource() { - if (!fetchMangaSubscription.isNullOrUnsubscribed()) return - fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) } - .map { networkManga -> - manga.copyFrom(networkManga) - manga.initialized = true - db.insertManga(manga).executeAsBlocking() - 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() - - 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 { - return db.getCategories().executeAsBlocking() - } - - /** - * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. - * - * @param manga the manga to get categories from. - * @return Array of category ids the manga is in, if none returns default id - */ - fun getMangaCategoryIds(manga: Manga): Array { - val categories = db.getCategoriesForManga(manga).executeAsBlocking() - return categories.mapNotNull { it.id }.toTypedArray() - } - - /** - * Move the given manga to categories. - * - * @param manga the manga to move. - * @param categories the selected categories. - */ - fun moveMangaToCategories(manga: Manga, categories: List) { - val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) } - db.setMangaCategories(mc, listOf(manga)) - } - - /** - * Move the given manga to the category. - * - * @param manga the manga to move. - * @param category the selected category, or null for default category. - */ - fun moveMangaToCategory(manga: Manga, category: Category?) { - moveMangaToCategories(manga, listOfNotNull(category)) - } - - fun updateManga(title:String?, author:String?, artist: String?, uri: Uri?, - description: String?, tags: Array?) { - 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().refreshCoversToo().set(false) - coverCache.copyToCache(manga.thumbnail_url!!, inputStream) - MangaImpl.setLastCoverFetch(manga.id!!, Date().time) - return true - } - return false - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt index dd3f65d98f..5f59acea52 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt @@ -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 : 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 : 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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt index 6aac10037e..b85280289f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt @@ -15,14 +15,15 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class SetTrackScoreDialog : 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 : DialogController val np: NumberPicker = view.findViewById(R.id.score_picker) np.clearFocus() - (targetController as? Listener)?.setScore(item, np.value) - + listener.setScore(item, np.value) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt index 42ccd06a9f..7047b8f39c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt @@ -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 : 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 : DialogController .listItemsSingleChoice(items = statusString, initialSelection = selectedIndex, waitForPositiveButton = false) { dialog, position, _ -> - (targetController as? Listener)?.setStatus(item, position) + listener.setStatus(item, position) dialog.dismiss() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt index 4f4daf86c5..9d8599e516 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt @@ -1,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() { +class TrackAdapter(controller: OnClickListener) : RecyclerView.Adapter() { var items = emptyList() set(value) { @@ -34,9 +35,13 @@ class TrackAdapter(controller: TrackController) : RecyclerView.Adapter(), - 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) { - val atLeastOneLink = trackings.any { it.track != null } - adapter?.items = trackings - swipe_refresh?.isEnabled = atLeastOneLink - (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink) - } - - fun onSearchResults(results: List) { - getSearchDialog()?.onSearchResults(results) - } - - @Suppress("UNUSED_PARAMETER") - fun onSearchResultsError(error: Throwable) { - Timber.e(error) - getSearchDialog()?.onSearchResultsError() - } - - private fun getSearchDialog(): TrackSearchDialog? { - return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog - } - - fun onRefreshDone() { - swipe_refresh?.isRefreshing = false - } - - fun onRefreshError(error: Throwable) { - swipe_refresh?.isRefreshing = false - activity?.toast(error.message) - } - - override fun onLogoClick(position: Int) { - val track = adapter?.getItem(position)?.track ?: return - - if (track.tracking_url.isNullOrBlank()) { - activity?.toast(R.string.url_not_set) - } else { - activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url))) - } - } - - override fun onTitleClick(position: Int) { - val item = adapter?.getItem(position) ?: return - TrackSearchDialog(this, item.service, 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" - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt index c8c9da6887..d56d97eea6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt @@ -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) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt deleted file mode 100644 index 5978c758f1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt +++ /dev/null @@ -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() { - - private val context = preferences.context - - private var trackList: List = emptyList() - - private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } - - private var trackSubscription: Subscription? = null - - private var searchSubscription: Subscription? = null - - private var refreshSubscription: Subscription? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - fetchTrackings() - } - - fun fetchTrackings() { - trackSubscription?.let { remove(it) } - trackSubscription = db.getTracks(manga) - .asRxObservable() - .map { tracks -> - loggedServices.map { service -> - TrackItem(tracks.find { it.sync_id == service.id }, service) - } - } - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { trackList = it } - .subscribeLatestCache(TrackController::onNextTrackings) - } - - fun refresh() { - refreshSubscription?.let { remove(it) } - refreshSubscription = Observable.from(trackList) - .filter { it.track != null } - .concatMap { item -> - item.service.refresh(item.track!!) - .flatMap { db.insertTrack(it).asRxObservable() } - .map { item } - .onErrorReturn { item } - } - .toList() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> view.onRefreshDone() }, - TrackController::onRefreshError) - } - - fun search(query: String, service: TrackService) { - searchSubscription?.let { remove(it) } - searchSubscription = service.search(query) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(TrackController::onSearchResults, - TrackController::onSearchResultsError) - } - - fun registerTracking(item: Track?, service: TrackService) { - if (item != null) { - item.manga_id = manga.id!! - add(service.bind(item) - .flatMap { db.insertTrack(item).asRxObservable() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .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) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt index e6f6337f10..2cb775a555 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt @@ -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) { @@ -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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index 2708ccb7d5..674702dd29 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -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() { /** @@ -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 { 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() - LocalSource.updateCover(context, manga, stream()) - R.string.cover_updated + .fromCallable { + if (manga.source == LocalSource.ID) { + val context = Injekt.get() + 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() - 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() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt index a1ff69ea07..31375df7dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt @@ -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()) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt index 9bb95453a6..495290ccb7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt @@ -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() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt index a8ab6bc00e..537efb4197 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt @@ -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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt index 1e09183dd6..d579d177d0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt @@ -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() // 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. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt index ddbf3c2ec2..b80f6d5782 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt @@ -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() // 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. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt index b610e3b330..224cba589b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt @@ -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() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt index 2b05259b31..f83b2d4a91 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt @@ -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") diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt index a1d7e0eac6..60a0f4377e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt index e0a2903546..24cf730de5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index ca1ee029d4..762bcf79de 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt index 40b96f111b..8f4f3f2cb1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index e213d685ea..0f94f90453 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt index 0418fdaa63..1854f467b4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt @@ -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) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt index bac59b4f6a..da60c68d1d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt @@ -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) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikomoriLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt similarity index 74% rename from app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikomoriLoginActivity.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt index 682f998606..25fed5e2d8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikomoriLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt @@ -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) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt new file mode 100644 index 0000000000..83e864a05a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt index 8feefae628..2525655f38 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt @@ -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( diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ExtensionPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ExtensionPreference.kt index 3e64e84269..b57e4528df 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ExtensionPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ExtensionPreference.kt @@ -28,7 +28,7 @@ class ExtensionPreference @JvmOverloads constructor(context: Context, attrs: Att val updates = Injekt.get().extensionUpdatesCount().getOrDefault() if (updates > 0) { extUpdateText.text = context.resources.getQuantityString(R.plurals - .extensions_updates_available, updates, updates) + .updates_available, updates, updates) extUpdateText.visible() } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt index 24b14eae0b..f5e0f9644b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt @@ -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() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt index 0aa7c3828a..e546204a60 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt @@ -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().get(args.getLong("key")) as LoginSource diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt index baf6806eaf..0b3d45bdd6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt @@ -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().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) } - } diff --git a/app/src/main/res/color/btn_bg_primary_selector.xml b/app/src/main/res/color/btn_bg_primary_selector.xml new file mode 100644 index 0000000000..15e0485f55 --- /dev/null +++ b/app/src/main/res/color/btn_bg_primary_selector.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/mtrl_btn_bg_selector.xml b/app/src/main/res/color/mtrl_btn_bg_selector.xml new file mode 100644 index 0000000000..a802c7b846 --- /dev/null +++ b/app/src/main/res/color/mtrl_btn_bg_selector.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/primary_button_text_color_selector.xml b/app/src/main/res/color/primary_button_text_color_selector.xml new file mode 100644 index 0000000000..0770047cb9 --- /dev/null +++ b/app/src/main/res/color/primary_button_text_color_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sync_black_24dp.xml b/app/src/main/res/drawable/ic_sync_black_24dp.xml index ce8796cb79..885909db8c 100644 --- a/app/src/main/res/drawable/ic_sync_black_24dp.xml +++ b/app/src/main/res/drawable/ic_sync_black_24dp.xml @@ -2,6 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24.0" + android:tint="?actionBarTintColor" android:viewportHeight="24.0"> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/catalogue_main_controller.xml b/app/src/main/res/layout/catalogue_main_controller.xml index fbdbe9b2d1..79157f0c36 100644 --- a/app/src/main/res/layout/catalogue_main_controller.xml +++ b/app/src/main/res/layout/catalogue_main_controller.xml @@ -1,15 +1,44 @@ - + android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto"> - + android:layout_height="match_parent" + android:background="?android:attr/colorBackground"> - + + + + + + + + + + diff --git a/app/src/main/res/layout/catalouge_more_extensions_card_item.xml b/app/src/main/res/layout/catalouge_more_extensions_card_item.xml new file mode 100644 index 0000000000..855ef67e13 --- /dev/null +++ b/app/src/main/res/layout/catalouge_more_extensions_card_item.xml @@ -0,0 +1,43 @@ + + + + + + + +