merge md2 stuff in

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

View File

@ -21,14 +21,14 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,24 +11,19 @@ import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.MediaType
import eu.kanade.tachiyomi.network.await
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val parser = JsonParser()
private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> {
suspend fun addLibManga(track: Track): Track {
val query = """
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
@ -38,34 +33,33 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|""".trimMargin()
val variables = jsonObject(
"mangaId" to track.media_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus()
"mangaId" to track.media_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus()
)
val payload = jsonObject(
"query" to query,
"variables" to variables
"query" to query,
"variables" to variables
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
netResponse.close()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
track
}
.url(apiUrl)
.post(body)
.build()
val netResponse = authClient.newCall(request).await()
val responseBody = netResponse.body?.string().orEmpty()
netResponse.close()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = JsonParser().parse(responseBody).obj
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
return track
}
fun updateLibManga(track: Track): Observable<Track> {
suspend fun updateLibManga(track: Track): Track {
val query = """
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
@ -76,28 +70,25 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|""".trimMargin()
val variables = jsonObject(
"listId" to track.library_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus(),
"score" to track.score.toInt()
"listId" to track.library_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus(),
"score" to track.score.toInt()
)
val payload = jsonObject(
"query" to query,
"variables" to variables
"query" to query,
"variables" to variables
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
.url(apiUrl)
.post(body)
.build()
authClient.newCall(request).execute()
return track
}
fun search(search: String): Observable<List<TrackSearch>> {
suspend fun search(search: String): List<TrackSearch> {
val query = """
|query Search(${'$'}query: String) {
|Page (perPage: 50) {
@ -123,35 +114,31 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|""".trimMargin()
val variables = jsonObject(
"query" to search
"query" to search
)
val payload = jsonObject(
"query" to query,
"variables" to variables
"query" to query,
"variables" to variables
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["media"].array
val entries = media.map { jsonToALManga(it.obj) }
entries.map { it.toTrack() }
}
.url(apiUrl)
.post(body)
.build()
val netResponse = authClient.newCall(request).await()
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = JsonParser().parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["media"].array
val entries = media.map { jsonToALManga(it.obj) }
return entries.map { it.toTrack() }
}
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
suspend fun findLibManga(track: Track, userid: Int): Track? {
val query = """
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|Page {
@ -183,45 +170,47 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|""".trimMargin()
val variables = jsonObject(
"id" to userid,
"manga_id" to track.media_id
"id" to userid,
"manga_id" to track.media_id
)
val payload = jsonObject(
"query" to query,
"variables" to variables
"query" to query,
"variables" to variables
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) }
entries.firstOrNull()?.toTrack()
}
.url(apiUrl)
.post(body)
.build()
val result = authClient.newCall(request).await()
return result.let { resp ->
val responseBody = resp.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = JsonParser().parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) }
entries.firstOrNull()?.toTrack()
}
}
fun getLibManga(track: Track, userid: Int): Observable<Track> {
return findLibManga(track, userid)
.map { it ?: throw Exception("Could not find manga") }
suspend fun getLibManga(track: Track, userid: Int): Track {
val track = findLibManga(track, userid)
if (track == null) {
throw Exception("Could not find manga")
} else {
return track
}
}
fun createOAuth(token: String): OAuth {
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
}
fun getCurrentUser(): Observable<Pair<Int, String>> {
suspend fun getCurrentUser(): Pair<Int, String> {
val query = """
|query User {
|Viewer {
@ -233,49 +222,62 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|""".trimMargin()
val payload = jsonObject(
"query" to query
"query" to query
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val viewer = data["Viewer"].obj
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
}
.url(apiUrl)
.post(body)
.build()
val netResponse = authClient.newCall(request).await()
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = JsonParser().parse(responseBody).obj
val data = response["data"]!!.obj
val viewer = data["Viewer"].obj
return Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
}
private fun jsonToALManga(struct: JsonObject): ALManga {
val date = try {
val date = Calendar.getInstance()
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0)
date.set(
struct["startDate"]["year"].nullInt ?: 0,
(struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0
)
date.timeInMillis
} catch (_: Exception) {
0L
}
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
date, struct["chapters"].nullInt ?: 0)
return ALManga(
struct["id"].asInt,
struct["title"]["romaji"].asString,
struct["coverImage"]["large"].asString,
struct["description"].nullString.orEmpty(),
struct["type"].asString,
struct["status"].asString,
date,
struct["chapters"].nullInt ?: 0
)
}
private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj))
return ALUserManga(
struct["id"].asLong,
struct["status"].asString,
struct["scoreRaw"].asInt,
struct["progress"].asInt,
jsonToALManga(struct["media"].obj)
)
}
companion object {
private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/"
@ -285,9 +287,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "token")
.build()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "token")
.build()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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? = ""
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import rx.Completable
import rx.Observable
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
@ -62,11 +59,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
override suspend fun add(track: Track): Track {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
@ -74,42 +71,42 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track)
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
override suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track)
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
return track
}
override fun search(query: String): Observable<List<TrackSearch>> {
override suspend fun search(query: String): List<TrackSearch> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
}
override fun login(username: String, password: String): Completable {
override suspend fun login(username: String, password: String): Boolean {
logout()
return Observable.fromCallable { api.login(username, password) }
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
try {
val csrf = api.login(username, password)
saveCSRF(csrf)
saveCredentials(username, password)
return true
} catch (e: Exception) {
logout()
return false
}
}
fun refreshLogin() {
@ -143,8 +140,8 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
val isAuthorized: Boolean
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
getCSRF().isNotEmpty() &&
checkCookies()
fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
@ -160,5 +157,4 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return ckCount == 2
}
}

View File

@ -6,8 +6,9 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.consumeBody
import eu.kanade.tachiyomi.network.consumeXmlBody
import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText
import okhttp3.FormBody
@ -15,98 +16,84 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser
import rx.Observable
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun search(query: String): Observable<List<TrackSearch>> {
return if (query.startsWith(PREFIX_MY)) {
suspend fun search(query: String): List<TrackSearch> {
if (query.startsWith(PREFIX_MY)) {
val realQuery = query.removePrefix(PREFIX_MY)
getList()
.flatMap { Observable.from(it) }
.filter { it.title.contains(realQuery, true) }
.toList()
return getList().filter { it.title.contains(realQuery, true) }.toList()
} else {
client.newCall(GET(searchUrl(query)))
.asObservable()
.flatMap { response ->
Observable.from(Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody")
.select("tr").drop(1))
}
.filter { row ->
row.select(TD)[2].text() != "Novel"
}
.map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = row.searchTitle()
media_id = row.searchMediaId()
total_chapters = row.searchTotalChapters()
summary = row.searchSummary()
cover_url = row.searchCoverUrl()
tracking_url = mangaUrl(media_id)
publishing_status = row.searchPublishingStatus()
publishing_type = row.searchPublishingType()
start_date = row.searchStartDate()
}
}
.toList()
}
}
val realQuery = query.take(100)
val response = client.newCall(GET(searchUrl(realQuery))).await()
val matches = Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody")
.select("tr").drop(1)
fun addLibManga(track: Track): Observable<Track> {
return Observable.defer {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun findLibManga(track: Track): Observable<Track?> {
return authClient.newCall(GET(url = listEntryUrl(track.media_id)))
.asObservable()
.map {response ->
var libTrack: Track? = null
response.use {
if (it.priorResponse?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody())
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
total_chapters = trackForm.select("#totalChap").text().toInt()
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f
}
}
return matches.filter { row -> row.select(TD)[2].text() != "Novel" }
.map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = row.searchTitle()
media_id = row.searchMediaId()
total_chapters = row.searchTotalChapters()
summary = row.searchSummary()
cover_url = row.searchCoverUrl()
tracking_url = mangaUrl(media_id)
publishing_status = row.searchPublishingStatus()
publishing_type = row.searchPublishingType()
start_date = row.searchStartDate()
}
libTrack
}
.toList()
}
}
fun getLibManga(track: Track): Observable<Track> {
return findLibManga(track)
.map { it ?: throw Exception("Could not find manga") }
suspend fun addLibManga(track: Track): Track {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))).await()
return track
}
suspend fun updateLibManga(track: Track): Track {
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await()
return track
}
suspend fun findLibManga(track: Track): Track? {
val response = authClient.newCall(GET(url = listEntryUrl(track.media_id))).await()
var libTrack: Track? = null
response.use {
if (it.priorResponse?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody())
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
last_chapter_read =
trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
total_chapters = trackForm.select("#totalChap").text().toInt()
status =
trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
score = trackForm.select("#add_manga_score > option[selected]").`val`()
.toFloatOrNull() ?: 0f
}
}
}
return libTrack
}
suspend fun getLibManga(track: Track): Track {
val result = findLibManga(track)
if (result == null) {
throw Exception("Could not find manga")
} else {
return result
}
}
fun login(username: String, password: String): String {
@ -121,77 +108,50 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
val response = client.newCall(GET(loginUrl())).execute()
return Jsoup.parse(response.consumeBody())
.select("meta[name=csrf_token]")
.attr("content")
.select("meta[name=csrf_token]")
.attr("content")
}
private fun login(username: String, password: String, csrf: String) {
val response = client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf))).execute()
val response =
client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf)))
.execute()
response.use {
if (response.priorResponse?.code != 302) throw Exception("Authentication error")
}
}
private fun getList(): Observable<List<TrackSearch>> {
return getListUrl()
.flatMap { url ->
getListXml(url)
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
}
}
.toList()
}
private suspend fun getList(): List<TrackSearch> {
val results = getListXml(getListUrl()).select("manga")
private fun getListUrl(): Observable<String> {
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
.asObservable()
.map {response ->
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
return results.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
}
}
private fun getListXml(url: String): Observable<Document> {
return authClient.newCall(GET(url))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
private fun Response.consumeBody(): String? {
use {
if (it.code != 200) throw Exception("HTTP error ${it.code}")
return it.body?.string()
}
}
private fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
}
}
.toList()
}
private suspend fun getListUrl(): String {
val response =
authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())).await()
return baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
private suspend fun getListXml(url: String): Document {
val response = authClient.newCall(GET(url)).await()
return Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
companion object {
@ -206,88 +166,91 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
private fun loginUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("login.php")
.toString()
.appendPath("login.php")
.toString()
private fun searchUrl(query: String): String {
val col = "c[]"
return Uri.parse(baseUrl).buildUpon()
.appendPath("manga.php")
.appendQueryParameter("q", query)
.appendQueryParameter(col, "a")
.appendQueryParameter(col, "b")
.appendQueryParameter(col, "c")
.appendQueryParameter(col, "d")
.appendQueryParameter(col, "e")
.appendQueryParameter(col, "g")
.toString()
.appendPath("manga.php")
.appendQueryParameter("q", query)
.appendQueryParameter(col, "a")
.appendQueryParameter(col, "b")
.appendQueryParameter(col, "c")
.appendQueryParameter(col, "d")
.appendQueryParameter(col, "e")
.appendQueryParameter(col, "g")
.toString()
}
private fun exportListUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath("edit.json")
.toString()
.appendPath("edit.json")
.toString()
private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json")
.toString()
.appendPath("add.json")
.toString()
private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath(mediaId.toString())
.appendPath("edit")
.toString()
.appendPath(mediaId.toString())
.appendPath("edit")
.toString()
private fun loginPostBody(username: String, password: String, csrf: String): RequestBody {
return FormBody.Builder()
.add("user_name", username)
.add("password", password)
.add("cookie", "1")
.add("sublogin", "Login")
.add("submit", "1")
.add(CSRF, csrf)
.build()
.add("user_name", username)
.add("password", password)
.add("cookie", "1")
.add("sublogin", "Login")
.add("submit", "1")
.add(CSRF, csrf)
.build()
}
private fun exportPostBody(): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
.build()
.add("type", "2")
.add("subexport", "Export My List")
.build()
}
private fun mangaPostPayload(track: Track): RequestBody {
val body = JSONObject()
.put("manga_id", track.media_id)
.put("status", track.status)
.put("score", track.score)
.put("num_read_chapters", track.last_chapter_read)
.put("manga_id", track.media_id)
.put("status", track.status)
.put("score", track.score)
.put("num_read_chapters", track.last_chapter_read)
return body.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
return body.toString()
.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
}
private fun Element.searchTitle() = select("strong").text()!!
private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
private fun Element.searchTotalChapters() =
if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
private fun Element.searchCoverUrl() = select("img")
.attr("data-src")
.split("\\?")[0]
.replace("/r/50x70/", "/")
.attr("data-src")
.split("\\?")[0]
.replace("/r/50x70/", "/")
private fun Element.searchMediaId() = select("div.picSurround")
.select("a").attr("id")
.replace("sarea", "")
.toInt()
.select("a").attr("id")
.replace("sarea", "")
.toInt()
private fun Element.searchSummary() = select("div.pt4")
.first()
.ownText()!!
.first()
.ownText()!!
private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished"
private fun Element.searchPublishingStatus() =
if (select(TD).last().text() == "-") "Publishing" else "Finished"
private fun Element.searchPublishingType() = select(TD)[2].text()!!
@ -300,6 +263,6 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
"Dropped" -> 4
"Plan to Read" -> 6
else -> 1
}
}
}
}

View File

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

View File

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

View File

@ -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()
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -40,8 +40,7 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.ui.base.controller.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) {

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,273 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.bluelinelabs.conductor.Router
import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.support.RouterPagerAdapter
import com.google.android.material.tabs.TabLayout
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.RxController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.main.BottomNavBarInterface
import eu.kanade.tachiyomi.ui.main.SearchActivity
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController
import eu.kanade.tachiyomi.ui.manga.track.TrackController
import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationListController
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.applyWindowInsetsForController
import kotlinx.android.synthetic.main.manga_controller.*
import rx.Subscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
class MangaController : RxController, TabbedController, BottomNavBarInterface {
constructor(manga: Manga?,
fromCatalogue: Boolean = false,
smartSearchConfig: CatalogueController.SmartSearchConfig? = null,
update: Boolean = false) : super(Bundle().apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue)
putParcelable(SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig)
putBoolean(UPDATE_EXTRA, update)
}) {
this.manga = manga
if (manga != null) {
source = Injekt.get<SourceManager>().getOrStub(manga.source)
}
}
constructor(manga: Manga?, fromCatalogue: Boolean = false, fromExtension: Boolean = false) :
super
(Bundle()
.apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue)
}) {
this.manga = manga
if (manga != null) {
source = Injekt.get<SourceManager>().getOrStub(manga.source)
}
}
constructor(manga: Manga?, startY:Float?) : super(Bundle().apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
putBoolean(FROM_CATALOGUE_EXTRA, false)
}) {
this.manga = manga
startingChapterYPos = startY
if (manga != null) {
source = Injekt.get<SourceManager>().getOrStub(manga.source)
}
}
constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking())
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) {
val notificationId = bundle.getInt("notificationId", -1)
val context = applicationContext ?: return
if (notificationId > -1) NotificationReceiver.dismissNotification(
context, notificationId, bundle.getInt("groupId", 0)
)
}
var manga: Manga? = null
private set
var source: Source? = null
private set
var startingChapterYPos:Float? = null
var isLockedFromSearch = false
private var adapter: MangaDetailAdapter? = null
val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false)
val lastUpdateRelay: BehaviorRelay<Date> = BehaviorRelay.create()
val chapterCountRelay: BehaviorRelay<Float> = BehaviorRelay.create()
val mangaFavoriteRelay: PublishRelay<Boolean> = PublishRelay.create()
private val trackingIconRelay: BehaviorRelay<Boolean> = BehaviorRelay.create()
private var trackingIconSubscription: Subscription? = null
override fun getTitle(): String? {
return manga?.currentTitle()
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.manga_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
view.applyWindowInsetsForController()
if (manga == null || source == null) return
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
adapter = MangaDetailAdapter()
manga_pager.offscreenPageLimit = 3
manga_pager.adapter = adapter
isLockedFromSearch = activity is SearchActivity &&
SecureActivityDelegate.shouldBeLocked()
if (!fromCatalogue)
manga_pager.currentItem = CHAPTERS_CONTROLLER
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
adapter = null
}
override fun onActivityResumed(activity: Activity) {
super.onActivityResumed(activity)
isLockedFromSearch = activity is SearchActivity &&
SecureActivityDelegate.shouldBeLocked()
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
tabLayout()?.setupWithViewPager(manga_pager)
checkInitialTrackState()
trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) }
}
}
private fun checkInitialTrackState() {
val manga = manga ?: return
val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
val db = Injekt.get<DatabaseHelper>()
val tracks = db.getTracks(manga).executeAsBlocking()
if (loggedServices.any { service -> tracks.any { it.sync_id == service.id } }) {
setTrackingIcon(true)
}
}
fun tabLayout():TabLayout? {
return null
}
fun updateTitle(manga: Manga) {
this.manga?.title = manga.title
setTitle()
}
override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeEnded(handler, type)
if (manga == null || source == null) {
activity?.toast(R.string.manga_not_in_db)
router.popController(this)
}
}
override fun configureTabs(tabs: TabLayout) {
with(tabs) {
tabGravity = TabLayout.GRAVITY_FILL
tabMode = TabLayout.MODE_FIXED
}
}
override fun cleanupTabs(tabs: TabLayout) {
trackingIconSubscription?.unsubscribe()
setTrackingIconInternal(false)
}
fun setTrackingIcon(visible: Boolean) {
trackingIconRelay.call(visible)
}
private fun setTrackingIconInternal(visible: Boolean) {
val tab = tabLayout()?.getTabAt(TRACK_CONTROLLER) ?: return
val drawable = if (visible)
VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null)
else null
//tab.icon = drawable
}
override fun canChangeTabs(block: () -> Unit): Boolean {
val migrationListController = router.getControllerWithTag(MigrationListController.TAG)
as? BottomNavBarInterface
if (migrationListController != null) return migrationListController.canChangeTabs(block)
return true
}
private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) {
private val tabCount = if (Injekt.get<TrackManager>().hasLoggedServices()) 3 else 2
private val tabTitles = listOf(
R.string.manga_detail_tab,
R.string.manga_chapters_tab,
R.string.manga_tracking_tab)
.map { resources!!.getString(it) }
override fun getCount(): Int {
return tabCount
}
override fun configureRouter(router: Router, position: Int) {
val touchOffset = if (tabLayout()?.height == 0) 144f else 0f
if (!router.hasRootController()) {
val controller = when (position) {
INFO_CONTROLLER -> MangaInfoController()
CHAPTERS_CONTROLLER -> ChaptersController(startingChapterYPos?.minus(touchOffset))
TRACK_CONTROLLER -> TrackController()
else -> error("Wrong position $position")
}
router.setRoot(RouterTransaction.with(controller))
}
}
override fun getPageTitle(position: Int): CharSequence {
return tabTitles[position]
}
}
companion object {
const val UPDATE_EXTRA = "update"
const val SMART_SEARCH_CONFIG_EXTRA = "smartSearchConfig"
const val FROM_CATALOGUE_EXTRA = "from_catalogue"
const val MANGA_EXTRA = "manga"
const val INFO_CONTROLLER = 0
const val CHAPTERS_CONTROLLER = 1
const val TRACK_CONTROLLER = 2
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,603 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.view.clicks
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.SearchActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets
import eu.kanade.tachiyomi.util.view.getCoordinates
import eu.kanade.tachiyomi.util.view.getText
import eu.kanade.tachiyomi.util.view.marginBottom
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.updateLayoutParams
import eu.kanade.tachiyomi.util.view.updatePaddingRelative
import kotlinx.android.synthetic.main.chapters_controller.*
import timber.log.Timber
class ChaptersController() : NucleusController<ChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ChaptersAdapter.OnMenuItemClickListener,
DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener {
constructor(startY: Float?) : this() {
this.startingChapterYPos = startY
}
/**
* Adapter containing a list of chapters.
*/
private var adapter: ChaptersAdapter? = null
private var scrollToUnread = true
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
private var snack:Snackbar? = null
/**
* Selected items. Used to restore selections after a rotation.
*/
private val selectedItems = mutableSetOf<ChapterItem>()
private var lastClickPosition = -1
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
var startingChapterYPos:Float? = null
override fun createPresenter(): ChaptersPresenter {
val ctrl = parentController as MangaController
return ChaptersPresenter(ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.chapters_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Init RecyclerView and adapter
adapter = ChaptersAdapter(this, view.context)
setReadingDrawable()
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(view.context)
recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
adapter?.fastScroller = fast_scroller
val fabBaseMarginBottom = fab?.marginBottom ?: 0
recycler.doOnApplyWindowInsets { v, insets, _ ->
fab?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = fabBaseMarginBottom + insets.systemWindowInsetBottom
}
fast_scroller?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.systemWindowInsetBottom
}
// offset the recycler by the fab's inset + some inset on top
v.updatePaddingRelative(bottom = insets.systemWindowInsetBottom +
v.context.resources.getDimensionPixelSize(R.dimen.fab_list_padding))
}
swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() }
fab.clicks().subscribeUntilDestroy {
if (activity is SearchActivity && presenter.isLockedFromSearch) {
SecureActivityDelegate.promptLockIfNeeded(activity)
return@subscribeUntilDestroy
}
val item = presenter.getNextUnreadChapter()
if (item != null) {
// Create animation listener
val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
openChapter(item.chapter, true)
}
}
// Get coordinates and start animation
val coordinates = fab.getCoordinates()
if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) {
openChapter(item.chapter)
}
} else if (snack == null || snack?.getText() != view.context.getString(R.string.no_next_chapter)) {
snack = view.snack(R.string.no_next_chapter, Snackbar.LENGTH_LONG) {
addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (snack == transientBottomBar) snack = null
}
})
}
}
}
}
override fun onDestroyView(view: View) {
adapter = null
actionMode = null
super.onDestroyView(view)
}
/**
* Update FAB with correct drawable.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setReadingDrawable() {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
fab.setImageResource(
when {
(parentController as MangaController).isLockedFromSearch -> R.drawable.ic_lock_white_24dp
else -> R.drawable.ic_play_arrow_white_24dp
}
)
}
override fun onActivityResumed(activity: Activity) {
super.onActivityResumed(activity)
if (view == null) return
if (activity is SearchActivity) {
presenter.updateLockStatus()
setReadingDrawable()
}
// Check if animation view is visible
if (reveal_view.visibility == View.VISIBLE) {
// Show the unReveal effect
val coordinates = fab.getCoordinates()
reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (!(parentController as MangaController).isLockedFromSearch)
inflater.inflate(R.menu.chapters, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items.
val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
// Set correct checkbox values.
menuFilterRead.isChecked = presenter.onlyRead()
menuFilterUnread.isChecked = presenter.onlyUnread()
menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
if (presenter.onlyRead())
//Disable unread filter option if read filter is enabled.
menuFilterUnread.isEnabled = false
if (presenter.onlyUnread())
//Disable read filter option if unread filter is enabled.
menuFilterRead.isEnabled = false
// Display mode submenu
if (presenter.manga.displayMode == Manga.DISPLAY_NAME) {
menu.findItem(R.id.display_title).isChecked = true
} else {
menu.findItem(R.id.display_chapter_number).isChecked = true
}
// Sorting mode submenu
if (presenter.manga.sorting == Manga.SORTING_SOURCE) {
menu.findItem(R.id.sort_by_source).isChecked = true
} else {
menu.findItem(R.id.sort_by_number).isChecked = true
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.display_title -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NAME)
}
R.id.display_chapter_number -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NUMBER)
}
R.id.sort_by_source -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_SOURCE)
}
R.id.sort_by_number -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_NUMBER)
}
R.id.download_next, R.id.download_next_5, R.id.download_next_10,
R.id.download_custom, R.id.download_unread, R.id.download_all
-> downloadChapters(item.itemId)
R.id.action_filter_unread -> {
item.isChecked = !item.isChecked
presenter.setUnreadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_read -> {
item.isChecked = !item.isChecked
presenter.setReadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_downloaded -> {
item.isChecked = !item.isChecked
presenter.setDownloadedFilter(item.isChecked)
}
R.id.action_filter_bookmarked -> {
item.isChecked = !item.isChecked
presenter.setBookmarkedFilter(item.isChecked)
}
R.id.action_filter_empty -> {
presenter.removeFilters()
activity?.invalidateOptionsMenu()
}
R.id.action_sort -> presenter.revertSortOrder()
else -> return super.onOptionsItemSelected(item)
}
return true
}
fun onNextChapters(chapters: List<ChapterItem>) {
// If the list is empty, fetch chapters from source if the conditions are met
// We use presenter chapters instead because they are always unfiltered
if (presenter.chapters.isEmpty()) {
initialFetchChapters()
}
val adapter = adapter ?: return
adapter.updateDataSet(chapters)
if (selectedItems.isNotEmpty()) {
adapter.clearSelection() // we need to start from a clean state, index may have changed
createActionModeIfNeeded()
selectedItems.forEach { item ->
val position = adapter.indexOf(item)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
}
}
actionMode?.invalidate()
}
scrollToUnread()
}
private fun scrollToUnread() {
if (adapter?.items.isNullOrEmpty()) return
if (scrollToUnread) {
val index = presenter.getFirstUnreadIndex() ?: return
val centerOfScreen =
if (startingChapterYPos != null) startingChapterYPos!!.toInt() - recycler.top - 96
else recycler.height / 2 - 96
(recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
index, centerOfScreen
)
}
scrollToUnread = false
}
private fun initialFetchChapters() {
// Only fetch if this view is from the catalog and it hasn't requested previously
if ((parentController as MangaController).fromCatalogue && !presenter.hasRequested) {
fetchChaptersFromSource()
}
}
private fun fetchChaptersFromSource() {
swipe_refresh?.isRefreshing = true
presenter.fetchChaptersFromSource()
}
fun onFetchChaptersDone() {
swipe_refresh?.isRefreshing = false
}
fun onFetchChaptersError(error: Throwable) {
swipe_refresh?.isRefreshing = false
activity?.toast(error.message)
}
fun onChapterStatusChange(download: Download) {
getHolder(download.chapter)?.notifyStatus(download.status, presenter.isLockedFromSearch)
}
private fun getHolder(chapter: Chapter): ChapterHolder? {
return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
if (hasAnimation) {
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
startActivity(intent)
}
override fun onItemClick(view: View?, position: Int): Boolean {
val adapter = adapter ?: return false
val item = adapter.getItem(position) ?: return false
if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
lastClickPosition = position
toggleSelection(position)
return true
} else {
openChapter(item.chapter)
return false
}
}
override fun onItemLongClick(position: Int) {
createActionModeIfNeeded()
when {
lastClickPosition == -1 -> setSelection(position)
lastClickPosition > position -> for (i in position until lastClickPosition)
setSelection(i)
lastClickPosition < position -> for (i in lastClickPosition + 1..position)
setSelection(i)
else -> setSelection(position)
}
lastClickPosition = position
adapter?.notifyDataSetChanged()
}
// SELECTIONS & ACTION MODE
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
val item = adapter.getItem(position) ?: return
adapter.toggleSelection(position)
adapter.notifyDataSetChanged()
if (adapter.isSelected(position)) {
selectedItems.add(item)
} else {
selectedItems.remove(item)
}
actionMode?.invalidate()
}
private fun setSelection(position: Int) {
val adapter = adapter ?: return
val item = adapter.getItem(position) ?: return
if (!adapter.isSelected(position)) {
adapter.toggleSelection(position)
selectedItems.add(item)
actionMode?.invalidate()
}
}
private fun getSelectedChapters(): List<ChapterItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
}
private fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
}
private fun destroyActionModeIfNeeded() {
lastClickPosition = -1
actionMode?.finish()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.chapter_selection, menu)
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
@SuppressLint("StringFormatInvalid")
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = resources?.getString(R.string.label_selected, count)
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_select_all -> selectAll()
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete -> showDeleteChaptersConfirmationDialog()
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
adapter?.mode = SelectableAdapter.Mode.SINGLE
adapter?.clearSelection()
selectedItems.clear()
actionMode = null
}
override fun onDetach(view: View) {
destroyActionModeIfNeeded()
super.onDetach(view)
}
override fun onMenuItemClick(position: Int, item: MenuItem) {
val chapter = adapter?.getItem(position) ?: return
val chapters = listOf(chapter)
when (item.itemId) {
R.id.action_download -> downloadChapters(chapters)
R.id.action_bookmark -> bookmarkChapters(chapters, true)
R.id.action_remove_bookmark -> bookmarkChapters(chapters, false)
R.id.action_delete -> deleteChapters(chapters)
R.id.action_mark_as_read -> markAsRead(chapters)
R.id.action_mark_as_unread -> markAsUnread(chapters)
R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter)
}
}
// SELECTION MODE ACTIONS
private fun selectAll() {
val adapter = adapter ?: return
adapter.selectAll()
selectedItems.addAll(adapter.items)
actionMode?.invalidate()
}
private fun markAsRead(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, true)
if (presenter.preferences.removeAfterMarkedAsRead()) {
deleteChapters(chapters)
}
}
private fun markAsUnread(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, false)
}
private fun downloadChapters(chapters: List<ChapterItem>) {
val view = view
destroyActionModeIfNeeded()
presenter.downloadChapters(chapters)
if (view != null && !presenter.manga.favorite && (snack == null ||
snack?.getText() != view.context.getString(R.string.snack_add_to_library))) {
snack = view.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_add) {
presenter.addToLibrary()
}
addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (snack == transientBottomBar) snack = null
}
})
}
(activity as? MainActivity)?.setUndoSnackBar(snack)
}
}
private fun showDeleteChaptersConfirmationDialog() {
DeleteChaptersDialog(this).showDialog(router)
}
override fun deleteChapters() {
deleteChapters(getSelectedChapters())
}
private fun markPreviousAsRead(chapter: ChapterItem) {
val adapter = adapter ?: return
val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = chapters.indexOf(chapter)
if (chapterPos != -1) {
markAsRead(chapters.take(chapterPos))
}
}
private fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) {
destroyActionModeIfNeeded()
presenter.bookmarkChapters(chapters, bookmarked)
}
fun deleteChapters(chapters: List<ChapterItem>) {
destroyActionModeIfNeeded()
if (chapters.isEmpty()) return
presenter.deleteChapters(chapters)
}
fun onChaptersDeleted(chapters: List<ChapterItem>) {
//this is needed so the downloaded text gets removed from the item
chapters.forEach {
adapter?.updateItem(it)
}
adapter?.notifyDataSetChanged()
}
fun onChaptersDeletedError(error: Throwable) {
Timber.e(error)
}
// OVERFLOW MENU DIALOGS
private fun setDisplayMode(id: Int) {
presenter.setDisplayMode(id)
adapter?.notifyDataSetChanged()
}
private fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
private fun downloadChapters(choice: Int) {
val chaptersToDownload = when (choice) {
R.id.download_next -> getUnreadChaptersSorted().take(1)
R.id.download_next_5 -> getUnreadChaptersSorted().take(5)
R.id.download_next_10 -> getUnreadChaptersSorted().take(10)
R.id.download_custom -> {
showCustomDownloadDialog()
return
}
R.id.download_unread -> presenter.chapters.filter { !it.read }
R.id.download_all -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
private fun showCustomDownloadDialog() {
DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router)
}
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
}

View File

@ -1,443 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
/**
* Presenter of [ChaptersController].
*/
class ChaptersPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get()
) : BasePresenter<ChaptersController>() {
/**
* List of chapters of the manga. It's always unfiltered and unsorted.
*/
var chapters: List<ChapterItem> = emptyList()
private set
/**
* Subject of list of chapters to allow updating the view without going to DB.
*/
val chaptersRelay: PublishRelay<List<ChapterItem>>
by lazy { PublishRelay.create<List<ChapterItem>>() }
/**
* Whether the chapter list has been requested to the source.
*/
var hasRequested = false
private set
/**
* Subscription to retrieve the new list of chapters from the source.
*/
private var fetchChaptersSubscription: Subscription? = null
/**
* Subscription to observe download status changes.
*/
private var observeDownloadsSubscription: Subscription? = null
var isLockedFromSearch = false
fun updateLockStatus() {
val lastCheck = isLockedFromSearch
isLockedFromSearch = SecureActivityDelegate.shouldBeLocked()
if (lastCheck && lastCheck != isLockedFromSearch) {
chapters.forEach {
it.isLocked = false
}
chaptersRelay.call(chapters)
}
}
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
isLockedFromSearch = SecureActivityDelegate.shouldBeLocked()
// Prepare the relay.
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(ChaptersController::onNextChapters
) { _, error -> Timber.e(error) }
// Add the subscription that retrieves the chapters from the database, keeps subscribed to
// changes, and sends the list of chapters to the relay.
add(db.getChapters(manga).asRxObservable()
.map { chapters ->
// Convert every chapter to a model.
chapters.map { it.toModel() }
}
.doOnNext { chapters ->
// Find downloaded chapters
setDownloadedChapters(chapters)
// Store the last emission
this.chapters = chapters
// Listen for download status changes
observeDownloads()
// Emit the number of chapters to the info tab.
chapterCountRelay.call(chapters.maxBy { it.chapter_number }?.chapter_number
?: 0f)
// Emit the upload date of the most recent chapter
lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload
?: 0))
}
.subscribe { chaptersRelay.call(it) })
}
private fun observeDownloads() {
observeDownloadsSubscription?.let { remove(it) }
observeDownloadsSubscription = downloadManager.queue.getStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.filter { download -> download.manga.id == manga.id }
.doOnNext { onDownloadStatusChange(it) }
.subscribeLatestCache(ChaptersController::onChapterStatusChange) {
_, error -> Timber.e(error)
}
}
/**
* Converts a chapter from the database to an extended model, allowing to store new fields.
*/
private fun Chapter.toModel(): ChapterItem {
// Create the model object.
val model = ChapterItem(this, manga)
model.isLocked = isLockedFromSearch
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == id }
if (download != null) {
// If there's an active download, assign it.
model.download = download
}
return model
}
/**
* Finds and assigns the list of downloaded chapters.
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<ChapterItem>) {
for (chapter in chapters) {
if (downloadManager.isChapterDownloaded(chapter, manga)) {
chapter.status = Download.DOWNLOADED
}
}
}
/**
* Requests an updated list of chapters from the source.
*/
fun fetchChaptersFromSource() {
hasRequested = true
if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return
fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) }
.subscribeOn(Schedulers.io())
.map { syncChaptersWithSource(db, it, manga, source) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onFetchChaptersDone()
}, ChaptersController::onFetchChaptersError)
}
/**
* Updates the UI after applying the filters.
*/
private fun refreshChapters() {
chaptersRelay.call(chapters)
}
/**
* Applies the view filters to the list of chapters obtained from the database.
* @param chapters the list of chapters from the database
* @return an observable of the list of chapters filtered and sorted.
*/
private fun applyChapterFilters(chapters: List<ChapterItem>): Observable<List<ChapterItem>> {
var observable = Observable.from(chapters).subscribeOn(Schedulers.io())
if (onlyUnread()) {
observable = observable.filter { !it.read }
} else if (onlyRead()) {
observable = observable.filter { it.read }
}
if (onlyDownloaded()) {
observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID }
}
if (onlyBookmarked()) {
observable = observable.filter { it.bookmark }
}
val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
Manga.SORTING_SOURCE -> when (sortDescending()) {
true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) }
false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
}
Manga.SORTING_NUMBER -> when (sortDescending()) {
true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) }
false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
}
else -> throw NotImplementedError("Unimplemented sorting method")
}
return observable.toSortedList(sortFunction)
}
/**
* Called when a download for the active manga changes status.
* @param download the download whose status changed.
*/
fun onDownloadStatusChange(download: Download) {
// Assign the download to the model object.
if (download.status == Download.QUEUE) {
chapters.find { it.id == download.chapter.id }?.let {
if (it.download == null) {
it.download = download
}
}
}
// Force UI update if downloaded filter active and download finished.
if (onlyDownloaded() && download.status == Download.DOWNLOADED)
refreshChapters()
}
/**
* Returns the next unread chapter or null if everything is read.
*/
fun getNextUnreadChapter(): ChapterItem? {
return chapters.sortedByDescending { it.source_order }.find { !it.read }
}
/**
* Mark the selected chapter list as read/unread.
* @param selectedChapters the list of selected chapters.
* @param read whether to mark chapters as read or unread.
*/
fun markChaptersRead(selectedChapters: List<ChapterItem>, read: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.read = read
if (!read) {
chapter.last_page_read = 0
chapter.pages_left = 0
}
}
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Downloads the given list of chapters with the manager.
* @param chapters the list of chapters to download.
*/
fun downloadChapters(chapters: List<ChapterItem>) {
downloadManager.downloadChapters(manga, chapters)
}
/**
* Bookmarks the given list of chapters.
* @param selectedChapters the list of chapters to bookmark.
*/
fun bookmarkChapters(selectedChapters: List<ChapterItem>, bookmarked: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.bookmark = bookmarked
}
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Deletes the given list of chapter.
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<ChapterItem>) {
Observable.just(chapters)
.doOnNext { deleteChaptersInternal(chapters) }
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onChaptersDeleted(chapters)
}, ChaptersController::onChaptersDeletedError)
}
/**
* Deletes a list of chapters from disk. This method is called in a background thread.
* @param chapters the chapters to delete.
*/
private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
downloadManager.deleteChapters(chapters, manga, source)
chapters.forEach {
it.status = Download.NOT_DOWNLOADED
it.download = null
}
}
/**
* Reverses the sorting and requests an UI update.
*/
fun revertSortOrder() {
manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC)
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the read filter and requests an UI update.
* @param onlyUnread whether to display only unread chapters or all chapters.
*/
fun setUnreadFilter(onlyUnread: Boolean) {
manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the read filter and requests an UI update.
* @param onlyRead whether to display only read chapters or all chapters.
*/
fun setReadFilter(onlyRead: Boolean) {
manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the download filter and requests an UI update.
* @param onlyDownloaded whether to display only downloaded chapters or all chapters.
*/
fun setDownloadedFilter(onlyDownloaded: Boolean) {
manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the bookmark filter and requests an UI update.
* @param onlyBookmarked whether to display only bookmarked chapters or all chapters.
*/
fun setBookmarkedFilter(onlyBookmarked: Boolean) {
manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Removes all filters and requests an UI update.
*/
fun removeFilters() {
manga.readFilter = Manga.SHOW_ALL
manga.downloadedFilter = Manga.SHOW_ALL
manga.bookmarkedFilter = Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Adds manga to library
*/
fun addToLibrary() {
mangaFavoriteRelay.call(true)
}
/**
* Sets the active display mode.
* @param mode the mode to set.
*/
fun setDisplayMode(mode: Int) {
manga.displayMode = mode
db.updateFlags(manga).executeAsBlocking()
}
/**
* Sets the sorting method and requests an UI update.
* @param sort the sorting mode.
*/
fun setSorting(sort: Int) {
manga.sorting = sort
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyDownloaded(): Boolean {
return manga.downloadedFilter == Manga.SHOW_DOWNLOADED
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyBookmarked(): Boolean {
return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED
}
/**
* Whether the display only unread filter is enabled.
*/
fun onlyUnread(): Boolean {
return manga.readFilter == Manga.SHOW_UNREAD
}
/**
* Whether the display only read filter is enabled.
*/
fun onlyRead(): Boolean {
return manga.readFilter == Manga.SHOW_READ
}
/**
* Whether the sorting method is descending or ascending.
*/
fun sortDescending(): Boolean {
return manga.sortDescending()
}
fun getFirstUnreadIndex(): Int? {
if (!manga.favorite) {
return null
}
val index = chapters.sortedByDescending { it.source_order }.indexOfFirst { !it.read }
return if (sortDescending()) (chapters.size - 1) - index
else index
}
}

View File

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

View File

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

View File

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

View File

@ -1,290 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.app.Application
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.storage.DiskUtil
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.util.Date
/**
* Presenter of MangaInfoFragment.
* Contains information and data for fragment.
* Observable updates should be called from here.
*/
class MangaInfoPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get()
) : BasePresenter<MangaInfoController>() {
/**
* Subscription to send the manga to the view.
*/
private var viewMangaSubscription: Subscription? = null
/**
* Subscription to update the manga from the source.
*/
private var fetchMangaSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
sendMangaToView()
// Update chapter count
chapterCountRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setChapterCount)
// Update favorite status
mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
//update last update date
lastUpdateRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setLastUpdateDate)
}
/**
* Sends the active manga to the view.
*/
fun sendMangaToView() {
viewMangaSubscription?.let { remove(it) }
viewMangaSubscription = Observable.just(manga)
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
}
/**
* Fetch manga information from source.
*/
fun fetchMangaFromSource() {
if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
.map { networkManga ->
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
manga
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { sendMangaToView() }
.subscribeFirst({ view, _ ->
view.onFetchMangaDone()
}, MangaInfoController::onFetchMangaError)
}
/**
* Update favorite status of manga, (removes / adds) manga (to / from) library.
*
* @return the new status of the manga.
*/
fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite
db.insertManga(manga).executeAsBlocking()
sendMangaToView()
return manga.favorite
}
fun confirmDeletion() {
coverCache.deleteFromCache(manga.thumbnail_url)
db.resetMangaInfo(manga).executeAsBlocking()
downloadManager.deleteManga(manga, source)
}
fun setFavorite(favorite: Boolean) {
if (manga.favorite == favorite) {
return
}
toggleFavorite()
}
fun shareManga(cover: Bitmap) {
val context = Injekt.get<Application>()
val destDir = File(context.cacheDir, "shared_image")
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(cover, destDir, manga) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.shareManga(file) },
{ view, error -> view.shareManga() }
)
}
private fun saveImage(cover:Bitmap, directory: File, manga: Manga): File? {
directory.mkdirs()
// Build destination file.
val filename = DiskUtil.buildValidFilename("${manga.originalTitle()} - Cover.jpg")
val destFile = File(directory, filename)
val stream: OutputStream = FileOutputStream(destFile)
cover.compress(Bitmap.CompressFormat.JPEG, 75, stream)
stream.flush()
stream.close()
return destFile
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<Category> {
return db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: Manga): Array<Int> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
return categories.mapNotNull { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param manga the manga to move.
* @param categories the selected categories.
*/
fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, listOf(manga))
}
/**
* Move the given manga to the category.
*
* @param manga the manga to move.
* @param category the selected category, or null for default category.
*/
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
fun updateManga(title:String?, author:String?, artist: String?, uri: Uri?,
description: String?, tags: Array<String>?) {
if (manga.source == LocalSource.ID) {
manga.title = if (title.isNullOrBlank()) manga.url else title.trim()
manga.author = author?.trim()
manga.artist = artist?.trim()
manga.description = description?.trim()
val tagsString = tags?.joinToString(", ") { it.capitalize() }
manga.genre = if (tags.isNullOrEmpty()) null else tagsString?.trim()
LocalSource(downloadManager.context).updateMangaInfo(manga)
db.updateMangaInfo(manga).executeAsBlocking()
}
else {
var changed = false
val title = title?.trim()
if (!title.isNullOrBlank() && manga.originalTitle().isBlank()) {
manga.title = title
changed = true
}
else if (title.isNullOrBlank() && manga.currentTitle() != manga.originalTitle()) {
manga.title = manga.originalTitle()
changed = true
} else if (!title.isNullOrBlank() && title != manga.currentTitle()) {
manga.title = "${title}${SManga.splitter}${manga.originalTitle()}"
changed = true
}
val author = author?.trim()
if (author.isNullOrBlank() && manga.currentAuthor() != manga.originalAuthor()) {
manga.author = manga.originalAuthor()
changed = true
} else if (!author.isNullOrBlank() && author != manga.currentAuthor()) {
manga.author = "${author}${SManga.splitter}${manga.originalAuthor() ?: ""}"
changed = true
}
val artist = artist?.trim()
if (artist.isNullOrBlank() && manga.currentArtist() != manga.originalArtist()) {
manga.artist = manga.originalArtist()
changed = true
} else if (!artist.isNullOrBlank() && artist != manga.currentArtist()) {
manga.artist = "${artist}${SManga.splitter}${manga.originalArtist() ?: ""}"
changed = true
}
val description = description?.trim()
if (description.isNullOrBlank() && manga.currentDesc() != manga.originalDesc()) {
manga.description = manga.originalDesc()
changed = true
} else if (!description.isNullOrBlank() && description != manga.currentDesc()) {
manga.description = "${description}${SManga.splitter}${manga.originalDesc() ?: ""}"
changed = true
}
var tagsString = tags?.joinToString(", ")
if ((tagsString.isNullOrBlank() && manga.currentGenres() != manga.originalGenres())
|| tagsString == manga.originalGenres()) {
manga.genre = manga.originalGenres()
changed = true
} else if (!tagsString.isNullOrBlank() && tagsString != manga.currentGenres()) {
tagsString = tags?.joinToString(", ") { it.capitalize() }
manga.genre = "${tagsString}${SManga.splitter}${manga.originalGenres() ?: ""}"
changed = true
}
if (changed) db.updateMangaInfo(manga).executeAsBlocking()
}
if (uri != null) editCoverWithStream(uri)
}
private fun editCoverWithStream(uri: Uri): Boolean {
val inputStream = downloadManager.context.contentResolver.openInputStream(uri) ?:
return false
if (manga.source == LocalSource.ID) {
LocalSource.updateCover(downloadManager.context, manga, inputStream)
return true
}
if (manga.thumbnail_url != null && manga.favorite) {
Injekt.get<PreferencesHelper>().refreshCoversToo().set(false)
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
return true
}
return false
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,8 +2,8 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.annotation.SuppressLint
import android.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)
}
}

View File

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

View File

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

View File

@ -24,6 +24,11 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.Completable
import rx.Observable
import rx.Subscription
@ -40,11 +45,11 @@ import java.util.concurrent.TimeUnit
* Presenter used by the activity to perform background operations.
*/
class ReaderPresenter(
private val db: DatabaseHelper = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
private val db: DatabaseHelper = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<ReaderActivity>() {
/**
@ -87,19 +92,19 @@ class ReaderPresenter(
val dbChapters = db.getChapters(manga).executeAsBlocking()
val selectedChapter = dbChapters.find { it.id == chapterId }
?: error("Requested chapter of id $chapterId not found in chapter list")
?: error("Requested chapter of id $chapterId not found in chapter list")
val chaptersForReader =
if (preferences.skipRead()) {
val list = dbChapters.filter { !it.read }.toMutableList()
val find = list.find { it.id == chapterId }
if (find == null) {
list.add(selectedChapter)
}
list
} else {
dbChapters
if (preferences.skipRead()) {
val list = dbChapters.filter { !it.read }.toMutableList()
val find = list.find { it.id == chapterId }
if (find == null) {
list.add(selectedChapter)
}
list
} else {
dbChapters
}
when (manga.sorting) {
Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader)
@ -170,12 +175,12 @@ class ReaderPresenter(
if (!needsInit()) return
db.getManga(mangaId).asRxObservable()
.first()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { init(it, initialChapterId) }
.subscribeFirst({ _, _ ->
// Ignore onNext event
}, ReaderActivity::setInitialChapterError)
.first()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { init(it, initialChapterId) }
.subscribeFirst({ _, _ ->
// Ignore onNext event
}, ReaderActivity::setInitialChapterError)
}
fun init(mangaId: Long, chapterUrl: String) {
@ -207,13 +212,13 @@ class ReaderPresenter(
// Read chapterList from an io thread because it's retrieved lazily and would block main.
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = Observable
.fromCallable { chapterList.first { chapterId == it.chapter.id } }
.flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ _, _ ->
// Ignore onNext event
}, ReaderActivity::setInitialChapterError)
.fromCallable { chapterList.first { chapterId == it.chapter.id } }
.flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ _, _ ->
// Ignore onNext event
}, ReaderActivity::setInitialChapterError)
}
/**
@ -224,27 +229,29 @@ class ReaderPresenter(
* Callers must also handle the onError event.
*/
private fun getLoadObservable(
loader: ChapterLoader,
chapter: ReaderChapter
loader: ChapterLoader,
chapter: ReaderChapter
): Observable<ViewerChapters> {
return loader.loadChapter(chapter)
.andThen(Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter)
.andThen(Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter)
ViewerChapters(chapter,
chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1))
})
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters ->
val oldChapters = viewerChaptersRelay.value
ViewerChapters(
chapter,
chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1)
)
})
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters ->
val oldChapters = viewerChaptersRelay.value
// Add new references first to avoid unnecessary recycling
newChapters.ref()
oldChapters?.unref()
// Add new references first to avoid unnecessary recycling
newChapters.ref()
oldChapters?.unref()
viewerChaptersRelay.call(newChapters)
}
viewerChaptersRelay.call(newChapters)
}
}
/**
@ -258,10 +265,10 @@ class ReaderPresenter(
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter)
.toCompletable()
.onErrorComplete()
.subscribe()
.also(::add)
.toCompletable()
.onErrorComplete()
.subscribe()
.also(::add)
}
/**
@ -276,13 +283,13 @@ class ReaderPresenter(
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter)
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
.subscribeFirst({ view, _ ->
view.moveToPageIndex(0)
}, { _, _ ->
// Ignore onError event, viewers handle that state
})
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
.subscribeFirst({ view, _ ->
view.moveToPageIndex(0)
}, { _, _ ->
// Ignore onError event, viewers handle that state
})
}
/**
@ -299,12 +306,12 @@ class ReaderPresenter(
val loader = loader ?: return
loader.loadChapter(chapter)
.observeOn(AndroidSchedulers.mainThread())
// Update current chapters whenever a chapter is preloaded
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
.onErrorComplete()
.subscribe()
.also(::add)
.observeOn(AndroidSchedulers.mainThread())
// Update current chapters whenever a chapter is preloaded
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
.onErrorComplete()
.subscribe()
.also(::add)
}
/**
@ -348,9 +355,9 @@ class ReaderPresenter(
*/
private fun saveChapterProgress(chapter: ReaderChapter) {
db.updateChapterProgress(chapter.chapter).asRxCompletable()
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
@ -412,18 +419,18 @@ class ReaderPresenter(
db.updateMangaViewer(manga).executeAsBlocking()
Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
val currChapters = viewerChaptersRelay.value
if (currChapters != null) {
// Save current page
val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read
.subscribeFirst({ view, _ ->
val currChapters = viewerChaptersRelay.value
if (currChapters != null) {
// Save current page
val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read
// Emit manga and chapters to the new viewer
view.setManga(manga)
view.setChapters(currChapters)
}
})
// Emit manga and chapters to the new viewer
view.setManga(manga)
view.setChapters(currChapters)
}
})
}
/**
@ -439,7 +446,7 @@ class ReaderPresenter(
// Build destination file.
val filename = DiskUtil.buildValidFilename(
"${manga.currentTitle()} - ${chapter.name}".take(225)
"${manga.currentTitle()} - ${chapter.name}".take(225)
) + " - ${page.number}.${type.extension}"
val destFile = File(directory, filename)
@ -464,23 +471,25 @@ class ReaderPresenter(
notifier.onClear()
// Pictures directory.
val destDir = File(Environment.getExternalStorageDirectory().absolutePath +
val destDir = File(
Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + "Tachiyomi")
File.separator + "Tachiyomi"
)
// Copy file in background.
Observable.fromCallable { saveImage(page, destDir, manga) }
.doOnNext { file ->
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
}
.doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
)
.doOnNext { file ->
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
}
.doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
)
}
/**
@ -498,13 +507,13 @@ class ReaderPresenter(
val destDir = File(context.cacheDir, "shared_image")
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(page, destDir, manga) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onShareImageResult(file) },
{ _, _ -> /* Empty */ }
)
.map { saveImage(page, destDir, manga) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onShareImageResult(file) },
{ _, _ -> /* Empty */ }
)
}
/**
@ -516,29 +525,29 @@ class ReaderPresenter(
val stream = page.stream ?: return
Observable
.fromCallable {
if (manga.source == LocalSource.ID) {
val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream())
R.string.cover_updated
.fromCallable {
if (manga.source == LocalSource.ID) {
val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream())
R.string.cover_updated
SetAsCoverResult.Success
} else {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream())
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
SetAsCoverResult.Success
} else {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream())
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
SetAsCoverResult.Success
} else {
SetAsCoverResult.AddToLibraryFirst
}
SetAsCoverResult.AddToLibraryFirst
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, result -> view.onSetAsCoverResult(result) },
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, result -> view.onSetAsCoverResult(result) },
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
)
}
/**
@ -568,27 +577,24 @@ class ReaderPresenter(
val trackManager = Injekt.get<TrackManager>()
db.getTracks(manga).asRxSingle()
.flatMapCompletable { trackList ->
Completable.concat(trackList.map { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
// We wan't these to execute even if the presenter is destroyed so launch on GlobalScope
GlobalScope.launch {
withContext(Dispatchers.IO) {
val trackList = db.getTracks(manga).executeAsBlocking()
trackList.map { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
try {
track.last_chapter_read = chapterRead
// We wan't these to execute even if the presenter is destroyed and leaks
// for a while. The view can still be garbage collected.
Observable.defer { service.update(track) }
.map { db.insertTrack(track).executeAsBlocking() }
.toCompletable()
.onErrorComplete()
} else {
Completable.complete()
service.update(track)
db.insertTrack(track).executeAsBlocking()
} catch (e: Exception) {
Timber.e(e)
}
})
}
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
}
/**
@ -604,19 +610,19 @@ class ReaderPresenter(
if (removeAfterReadSlots == -1) return
Completable
.fromCallable {
// Position of the read chapter
val position = chapterList.indexOf(chapter)
.fromCallable {
// Position of the read chapter
val position = chapterList.indexOf(chapter)
// Retrieve chapter to delete according to preference
val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
if (chapterToDelete != null) {
downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
}
// Retrieve chapter to delete according to preference
val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
if (chapterToDelete != null) {
downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
@ -625,9 +631,8 @@ class ReaderPresenter(
*/
private fun deletePendingChapters() {
Completable.fromCallable { downloadManager.deletePendingChapters() }
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}

View File

@ -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())
}
/**

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -169,7 +169,7 @@ inline val View.marginLeft: Int
object RecyclerWindowInsetsListener : View.OnApplyWindowInsetsListener {
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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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