Update to MAL's new API + Refactoring

To Arkon: no, I'm not hogging your clientId

Co-Authored-By: arkon <4098258+arkon@users.noreply.github.com>
This commit is contained in:
Jays2Kings 2021-03-21 22:22:46 -04:00
parent 77a0244373
commit 05e3437f49
31 changed files with 624 additions and 472 deletions

View File

@ -95,7 +95,18 @@
android:theme="@style/FilePickerTheme" /> android:theme="@style/FilePickerTheme" />
<activity <activity
android:name=".ui.setting.track.MyAnimeListLoginActivity" android:name=".ui.setting.track.MyAnimeListLoginActivity"
android:configChanges="uiMode|orientation|screenSize" /> android:label="MyAnimeList">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="myanimelist-auth"
android:scheme="tachiyomij2k" />
</intent-filter>
</activity>
<activity <activity
android:name=".ui.setting.track.AnilistLoginActivity" android:name=".ui.setting.track.AnilistLoginActivity"
android:label="Anilist"> android:label="Anilist">

View File

@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.chapter.ChapterFilter import eu.kanade.tachiyomi.util.chapter.ChapterFilter
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingleton import uy.kohesive.injekt.api.addSingleton
@ -49,6 +50,8 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { Gson() } addSingletonFactory { Gson() }
addSingletonFactory { Json { ignoreUnknownKeys = true } }
addSingletonFactory { ChapterFilter() } addSingletonFactory { ChapterFilter() }
// Asynchronously init expensive components for a faster cold start // Asynchronously init expensive components for a faster cold start

View File

@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.updater.UpdaterJob import eu.kanade.tachiyomi.data.updater.UpdaterJob
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.ui.library.LibraryPresenter import eu.kanade.tachiyomi.ui.library.LibraryPresenter
import eu.kanade.tachiyomi.util.system.toast
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
@ -84,10 +85,15 @@ object Migrations {
if (oldVersion < 66) { if (oldVersion < 66) {
LibraryPresenter.updateCustoms() LibraryPresenter.updateCustoms()
} }
if (oldVersion < 67) { if (oldVersion < 68) {
// Force MAL log out due to login flow change // Force MAL log out due to login flow change
// v67: switched from scraping to WebView
// v68: switched from WebView to OAuth
val trackManager = Injekt.get<TrackManager>() val trackManager = Injekt.get<TrackManager>()
trackManager.myAnimeList.logout() if (trackManager.myAnimeList.isLogged) {
trackManager.myAnimeList.logout()
context.toast(R.string.myanimelist_relogin)
}
} }
return true return true
} }

View File

@ -150,7 +150,7 @@ class PreferencesHelper(val context: Context) {
.apply() .apply()
} }
fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "") fun trackToken(sync: TrackService) = flowPrefs.getString(Keys.trackToken(sync.id), "")
fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10") fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10")

View File

@ -22,6 +22,9 @@ abstract class TrackService(val id: Int) {
@StringRes @StringRes
abstract fun nameRes(): Int abstract fun nameRes(): Int
// Application and remote support for reading dates
open val supportsReadingDates: Boolean = false
@DrawableRes @DrawableRes
abstract fun getLogo(): Int abstract fun getLogo(): Int
@ -43,6 +46,8 @@ abstract class TrackService(val id: Int) {
abstract fun displayScore(track: Track): String abstract fun displayScore(track: Track): String
abstract suspend fun add(track: Track): Track
abstract suspend fun update(track: Track): Track abstract suspend fun update(track: Track): Track
abstract suspend fun bind(track: Track): Track abstract suspend fun bind(track: Track): Track

View File

@ -122,6 +122,12 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override suspend fun add(track: Track): Track {
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
return api.addLibManga(track)
}
override suspend fun update(track: Track): Track { override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
@ -145,10 +151,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
update(track) update(track)
} else { } else {
// Set default fields if it's not found in the list add(track)
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
api.addLibManga(track)
} }
} }
@ -187,7 +190,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).set(null) preferences.trackToken(this).delete()
interceptor.setAuth(null) interceptor.setAuth(null)
} }

View File

@ -37,22 +37,25 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override suspend fun add(track: Track): Track {
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
api.addLibManga(track)
return update(track)
}
override suspend fun bind(track: Track): Track { override suspend fun bind(track: Track): Track {
val statusTrack = api.statusLibManga(track) val statusTrack = api.statusLibManga(track)
val remoteTrack = api.findLibManga(track) val remoteTrack = api.findLibManga(track)
if (statusTrack != null && remoteTrack != null) { return if (statusTrack != null && remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
track.status = remoteTrack.status track.status = remoteTrack.status
track.last_chapter_read = remoteTrack.last_chapter_read track.last_chapter_read = remoteTrack.last_chapter_read
refresh(track) refresh(track)
} else { } else {
track.score = DEFAULT_SCORE.toFloat() add(track)
track.status = DEFAULT_STATUS
api.addLibManga(track)
update(track)
} }
return track
} }
override suspend fun search(query: String): List<TrackSearch> { override suspend fun search(query: String): List<TrackSearch> {
@ -133,8 +136,7 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).set(null) preferences.trackToken(this).delete()
interceptor.clearOauth()
} }
companion object { companion object {

View File

@ -92,16 +92,20 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override suspend fun add(track: Track): Track {
track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS
return api.addLibManga(track, getUserId())
}
override suspend fun bind(track: Track): Track { override suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUserId()) val remoteTrack = api.findLibManga(track, getUserId())
if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id track.media_id = remoteTrack.media_id
return update(track) update(track)
} else { } else {
track.score = DEFAULT_SCORE add(track)
track.status = DEFAULT_STATUS
return api.addLibManga(track, getUserId())
} }
} }

View File

@ -36,6 +36,10 @@ class TrackSearch : Track {
var start_date: String = "" var start_date: String = ""
override var started_reading_date: Long = 0
override var finished_reading_date: Long = 0
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other == null || javaClass != other.javaClass) return false if (other == null || javaClass != other.javaClass) return false

View File

@ -8,17 +8,24 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy
class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
private val interceptor by lazy { MyAnimeListInterceptor(this) } private val json: Json by injectLazy()
private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) }
private val api by lazy { MyAnimeListApi(client, interceptor) } private val api by lazy { MyAnimeListApi(client, interceptor) }
@StringRes @StringRes
override fun nameRes() = R.string.myanimelist override fun nameRes() = R.string.myanimelist
override val supportsReadingDates: Boolean = true
override fun getLogo() = R.drawable.ic_tracker_mal override fun getLogo() = R.drawable.ic_tracker_mal
override fun getLogoColor() = Color.rgb(46, 81, 162) override fun getLogoColor() = Color.rgb(46, 81, 162)
@ -59,26 +66,26 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString() return track.score.toInt().toString()
} }
override suspend fun update(track: Track): Track { override suspend fun add(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { track.status = READING
track.status = COMPLETED track.score = 0F
} return api.updateItem(track)
}
return api.updateLibManga(track) override suspend fun update(track: Track): Track {
return api.updateItem(track)
} }
override suspend fun bind(track: Track): Track { override suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track) val remoteTrack = api.findListItem(track)
if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
update(track) update(track)
} else { } else {
// Set default fields if it's not found in the list // Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat() add(track)
track.status = DEFAULT_STATUS
return api.addLibManga(track)
} }
return track
} }
override fun canRemoveFromService(): Boolean = true override fun canRemoveFromService(): Boolean = true
@ -88,22 +95,33 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
} }
override suspend fun search(query: String): List<TrackSearch> { override suspend fun search(query: String): List<TrackSearch> {
if (query.startsWith(SEARCH_ID_PREFIX)) {
query.substringAfter(SEARCH_ID_PREFIX).toIntOrNull()?.let { id ->
return listOf(api.getMangaDetails(id))
}
}
if (query.startsWith(SEARCH_LIST_PREFIX)) {
query.substringAfter(SEARCH_LIST_PREFIX).let { title ->
return api.findListItems(title)
}
}
return api.search(query) return api.search(query)
} }
override suspend fun refresh(track: Track): Track { override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track) return api.findListItem(track) ?: add(track)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
} }
suspend fun login(csrfToken: String) = login("myanimelist", csrfToken) override suspend fun login(username: String, password: String) = login(password)
override suspend fun login(username: String, password: String): Boolean { suspend fun login(authCode: String): Boolean {
return try { return try {
saveCSRF(password) val oauth = api.getAccessToken(authCode)
saveCredentials(username, password) interceptor.setAuth(oauth)
val username = api.getCurrentUser()
saveCredentials(username, oauth.access_token)
true true
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
@ -112,45 +130,37 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
} }
} }
// Attempt to login again if cookies have been cleared but credentials are still filled
suspend fun ensureLoggedIn() {
if (isAuthorized) return
if (!isLogged) throw Exception("MAL Login Credentials not found")
}
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).delete() preferences.trackToken(this).delete()
networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!) interceptor.setAuth(null)
} }
private val isAuthorized: Boolean fun saveOAuth(oAuth: OAuth?) {
get() = super.isLogged && getCSRF().isNotEmpty() && checkCookies() preferences.trackToken(this).set(json.encodeToString(oAuth))
}
fun getCSRF(): String = preferences.trackToken(this).getOrDefault() fun loadOAuth(): OAuth? {
return try {
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) json.decodeFromString<OAuth>(preferences.trackToken(this).get())
} catch (e: Exception) {
private fun checkCookies(): Boolean { null
var ckCount = 0
val url = BASE_URL.toHttpUrlOrNull()!!
for (ck in networkService.cookieManager.get(url)) {
if (ck.name == USER_SESSION_COOKIE || ck.name == LOGGED_IN_COOKIE) ckCount++
} }
return ckCount == 2
} }
companion object { companion object {
const val READING = 1 const val READING = 1
const val COMPLETED = 2 const val COMPLETED = 2
const val ON_HOLD = 3 const val ON_HOLD = 3
const val DROPPED = 4 const val DROPPED = 4
const val PLAN_TO_READ = 6 const val PLAN_TO_READ = 6
const val REREADING = 7
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0 const val DEFAULT_SCORE = 0
private const val SEARCH_ID_PREFIX = "id:"
private const val SEARCH_LIST_PREFIX = "my:"
const val BASE_URL = "https://myanimelist.net" const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID" const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in" const val LOGGED_IN_COOKIE = "is_logged_in"

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
@ -9,13 +10,26 @@ import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.consumeBody import eu.kanade.tachiyomi.network.consumeBody
import eu.kanade.tachiyomi.network.consumeXmlBody import eu.kanade.tachiyomi.network.consumeXmlBody
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil
import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText import eu.kanade.tachiyomi.util.selectText
import eu.kanade.tachiyomi.util.system.withIOContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
@ -24,52 +38,202 @@ import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import timber.log.Timber import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.*
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun search(query: String): List<TrackSearch> { suspend fun getAccessToken(authCode: String): OAuth {
return withContext(Dispatchers.IO) { return withIOContext {
if (query.startsWith(PREFIX_MY)) { val formBody: RequestBody = FormBody.Builder()
queryUsersList(query) .add("client_id", clientId)
} else { .add("code", authCode)
val realQuery = query.take(100) .add("code_verifier", codeVerifier)
val response = client.newCall(GET(searchUrl(realQuery))).await() .add("grant_type", "authorization_code")
val matches = Jsoup.parse(response.consumeBody()) .build()
.select("div.js-categories-seasonal.js-block-list.list").select("table") client.newCall(POST("$baseOAuthUrl/token", body = formBody))
.select("tbody").select("tr").drop(1) .await()
.parseAs()
}
}
matches.filter { row -> row.select(TD)[2].text() != "Novel" }.map { row -> suspend fun getCurrentUser(): String {
TrackSearch.create(TrackManager.MYANIMELIST).apply { return withIOContext {
title = row.searchTitle() val request = Request.Builder()
media_id = row.searchMediaId() .url("$baseApiUrl/users/@me")
total_chapters = row.searchTotalChapters() .get()
summary = row.searchSummary() .build()
cover_url = row.searchCoverUrl() authClient.newCall(request)
tracking_url = mangaUrl(media_id) .await()
publishing_status = row.searchPublishingStatus() .parseAs<JsonObject>()
publishing_type = row.searchPublishingType() .let { it["name"]!!.jsonPrimitive.content }
start_date = row.searchStartDate() }
}
suspend fun search(query: String): List<TrackSearch> {
return withIOContext {
val url = "$baseApiUrl/manga".toUri().buildUpon()
.appendQueryParameter("q", query)
.appendQueryParameter("nsfw", "true")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
it["data"]!!.jsonArray
.map { data -> data.jsonObject["node"]!!.jsonObject }
.map { node ->
val id = node["id"]!!.jsonPrimitive.int
async { getMangaDetails(id) }
}
.awaitAll()
.filter { trackSearch -> trackSearch.publishing_type != "novel" }
} }
}.toList() }
}
suspend fun getMangaDetails(id: Int): TrackSearch {
return withIOContext {
val url = "$baseApiUrl/manga".toUri().buildUpon()
.appendPath(id.toString())
.appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
val obj = it.jsonObject
TrackSearch.create(TrackManager.MYANIMELIST).apply {
media_id = obj["id"]!!.jsonPrimitive.int
title = obj["title"]!!.jsonPrimitive.content
summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
cover_url = obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content ?: ""
tracking_url = "https://myanimelist.net/manga/$media_id"
publishing_status = obj["status"]!!.jsonPrimitive.content.replace("_", " ")
publishing_type = obj["media_type"]!!.jsonPrimitive.content.replace("_", " ")
start_date = try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(obj["start_date"]!!)
} catch (e: Exception) {
""
}
}
}
}
}
suspend fun updateItem(track: Track): Track {
return withIOContext {
val formBodyBuilder = FormBody.Builder()
.add("status", track.toMyAnimeListStatus() ?: "reading")
.add("is_rereading", (track.status == MyAnimeList.REREADING).toString())
.add("score", track.score.toString())
.add("num_chapters_read", track.last_chapter_read.toString())
val request = Request.Builder()
.url(mangaUrl(track.media_id).toString())
.put(formBodyBuilder.build())
.build()
authClient.newCall(request)
.await()
.parseAs<JsonObject>()
.let { parseMangaItem(it, track) }
}
}
suspend fun findListItem(track: Track): Track? {
return withIOContext {
val uri = "$baseApiUrl/manga".toUri().buildUpon()
.appendPath(track.media_id.toString())
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
.build()
authClient.newCall(GET(uri.toString()))
.await()
.parseAs<JsonObject>()
.let { obj ->
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
obj.jsonObject["my_list_status"]?.jsonObject?.let {
parseMangaItem(it, track)
}
}
}
}
suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> {
return withIOContext {
val json = getListPage(offset)
val obj = json.jsonObject
val matches = obj["data"]!!.jsonArray
.filter {
it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains(
query,
ignoreCase = true
)
}
.map {
val id = it.jsonObject["node"]!!.jsonObject["id"]!!.jsonPrimitive.int
async { getMangaDetails(id) }
}
.awaitAll()
// Check next page if there's more
if (!obj["paging"]!!.jsonObject["next"]?.jsonPrimitive?.contentOrNull.isNullOrBlank()) {
matches + findListItems(query, offset + listPaginationAmount)
} else {
matches
} }
} }
} }
private suspend fun queryUsersList(query: String): List<TrackSearch> { private suspend fun getListPage(offset: Int): JsonObject {
val realQuery = query.removePrefix(PREFIX_MY).take(100) return withIOContext {
return getList().filter { it.title.contains(realQuery, true) }.toList() val urlBuilder = "$baseApiUrl/users/@me/mangalist".toUri().buildUpon()
.appendQueryParameter("fields", "list_status{start_date,finish_date}")
.appendQueryParameter("limit", listPaginationAmount.toString())
if (offset > 0) {
urlBuilder.appendQueryParameter("offset", offset.toString())
}
val request = Request.Builder()
.url(urlBuilder.build().toString())
.get()
.build()
authClient.newCall(request)
.await()
.parseAs()
}
} }
suspend fun addLibManga(track: Track): Track { private fun parseMangaItem(response: JsonObject, track: Track): Track {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))).await() val obj = response.jsonObject
return track return track.apply {
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]!!.jsonPrimitive.content)
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.int
score = obj["score"]!!.jsonPrimitive.int.toFloat()
}
} }
suspend fun updateLibManga(track: Track): Track { private fun parseDate(isoDate: String): Long {
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await() return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
return track }
private fun convertToIsoDate(epochTime: Long): String? {
if (epochTime == 0L) {
return ""
}
return try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(epochTime)
} catch (e: Exception) {
null
}
} }
suspend fun remove(track: Track): Boolean { suspend fun remove(track: Track): Boolean {
@ -82,152 +246,43 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
return false return false
} }
suspend fun findLibManga(track: Track): Track? { private fun removeUrl(mediaId: Int) = "$baseApiUrl/manga".toUri().buildUpon().appendPath(mediaId.toString())
return withContext(Dispatchers.IO) {
val response = authClient.newCall(GET(url = listEntryUrl(track.media_id))).await()
var remoteTrack: Track? = null
response.use {
if (it.priorResponse?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody())
remoteTrack = 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
}
}
}
remoteTrack
}
}
suspend fun getLibManga(track: Track): Track {
val result = findLibManga(track)
if (result == null) {
throw Exception("Could not find manga")
} else {
return result
}
}
private suspend fun getList(): List<TrackSearch> {
val results = getListXml(getListUrl()).select("manga")
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)
}
}.toList()
}
private suspend fun getListUrl(): String {
return withContext(Dispatchers.IO) {
val response =
authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())).execute()
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 {
const val CSRF = "csrf_token"
const val baseUrl = "https://myanimelist.net"
private const val baseMangaUrl = "$baseUrl/manga/"
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
private const val PREFIX_MY = "my:"
private const val TD = "td"
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
fun loginUrl() = baseUrl.toUri().buildUpon().appendPath("login.php").build()
private fun searchUrl(query: String): String {
val col = "c[]"
return baseUrl.toUri().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()
}
private fun exportListUrl() = baseUrl.toUri().buildUpon().appendPath("panel.php")
.appendQueryParameter("go", "export").toString()
private fun updateUrl() =
baseModifyListUrl.toUri().buildUpon().appendPath("edit.json").toString()
private fun removeUrl(mediaId: Int) = baseModifyListUrl.toUri().buildUpon().appendPath(mediaId.toString())
.appendPath("delete").toString() .appendPath("delete").toString()
private fun addUrl() = companion object {
baseModifyListUrl.toUri().buildUpon().appendPath("add.json").toString() // Registered under jay's MAL account
private const val clientId = "8d3821c90edb495432a5ecb61de59200"
private fun listEntryUrl(mediaId: Int) = private const val baseOAuthUrl = "https://myanimelist.net/v1/oauth2"
baseModifyListUrl.toUri().buildUpon().appendPath(mediaId.toString()) private const val baseApiUrl = "https://api.myanimelist.net/v2"
.appendPath("edit").toString()
private fun loginPostBody(username: String, password: String, csrf: String): RequestBody { private const val listPaginationAmount = 250
return FormBody.Builder().add("user_name", username).add("password", password)
.add("cookie", "1").add("sublogin", "Login").add("submit", "1").add(CSRF, csrf) private var codeVerifier: String = ""
fun authUrl(): Uri = "$baseOAuthUrl/authorize".toUri().buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("code_challenge", getPkceChallengeCode())
.appendQueryParameter("response_type", "code")
.build() .build()
fun mangaUrl(id: Int): Uri = "$baseApiUrl/manga".toUri().buildUpon()
.appendPath(id.toString())
.appendPath("my_list_status")
.build()
fun refreshTokenRequest(refreshToken: String): Request {
val formBody: RequestBody = FormBody.Builder()
.add("client_id", clientId)
.add("refresh_token", refreshToken)
.add("grant_type", "refresh_token")
.build()
return POST("$baseOAuthUrl/token", body = formBody)
} }
private fun exportPostBody(): RequestBody { private fun getPkceChallengeCode(): String {
return FormBody.Builder().add("type", "2").add("subexport", "Export My List").build() codeVerifier = PkceUtil.generateCodeVerifier()
} return codeVerifier
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)
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.searchCoverUrl() =
select("img").attr("data-src").split("\\?")[0].replace("/r/50x70/", "/")
private fun Element.searchMediaId() =
select("div.picSurround").select("a").attr("id").replace("sarea", "").toInt()
private fun Element.searchSummary() = select("div.pt4").first().ownText()!!
private fun Element.searchPublishingStatus() =
if (select(TD).last().text() == "-") "Publishing" else "Finished"
private fun Element.searchPublishingType() = select(TD)[2].text()!!
private fun Element.searchStartDate() = select(TD)[6].text()!!
private fun getStatus(status: String) = when (status) {
"Reading" -> 1
"Completed" -> 2
"On-Hold" -> 3
"Dropped" -> 4
"Plan to Read" -> 6
else -> 1
} }
} }
} }

View File

@ -4,6 +4,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
@ -11,51 +13,57 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okio.Buffer import okio.Buffer
import org.json.JSONObject import org.json.JSONObject
import uy.kohesive.injekt.injectLazy
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor { class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var token: String?) : Interceptor {
val scope = CoroutineScope(Job() + Dispatchers.Main) val scope = CoroutineScope(Job() + Dispatchers.Main)
private val json: Json by injectLazy()
private var oauth: OAuth? = null
set(value) {
field = value?.copy(expires_in = System.currentTimeMillis() + (value.expires_in * 1000))
}
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
runBlocking { val originalRequest = chain.request()
myanimelist.ensureLoggedIn()
}
val request = chain.request()
return chain.proceed(updateRequest(request))
}
private fun updateRequest(request: Request): Request { if (token.isNullOrEmpty()) {
return request.body?.let { throw Exception("Not authenticated with MyAnimeList")
val contentType = it.contentType().toString() }
val updatedBody = when { if (oauth == null) {
contentType.contains("x-www-form-urlencoded") -> updateFormBody(it) oauth = myanimelist.loadOAuth()
contentType.contains("json") -> updateJsonBody(it) }
else -> it // Refresh access token if null or expired.
if (oauth!!.isExpired()) {
chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!.refresh_token)).use {
if (it.isSuccessful) {
setAuth(json.decodeFromString(it.body!!.string()))
}
} }
request.newBuilder().post(updatedBody).build()
} ?: request
}
private fun bodyToString(requestBody: RequestBody): String {
Buffer().use {
requestBody.writeTo(it)
return it.readUtf8()
} }
// Throw on null auth.
if (oauth == null) {
throw Exception("No authentication token")
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build()
return chain.proceed(authRequest)
} }
private fun updateFormBody(requestBody: RequestBody): RequestBody { /**
val formString = bodyToString(requestBody) * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
* and the oauth object.
return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody( */
requestBody.contentType() fun setAuth(oauth: OAuth?) {
) token = oauth?.access_token
} this.oauth = oauth
myanimelist.saveOAuth(oauth)
private fun updateJsonBody(requestBody: RequestBody): RequestBody {
val jsonString = bodyToString(requestBody)
val newBody = JSONObject(jsonString)
.put(MyAnimeListApi.CSRF, myanimelist.getCSRF())
return newBody.toString().toRequestBody(requestBody.contentType())
} }
} }

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toMyAnimeListStatus() = when (status) {
MyAnimeList.READING -> "reading"
MyAnimeList.COMPLETED -> "completed"
MyAnimeList.ON_HOLD -> "on_hold"
MyAnimeList.DROPPED -> "dropped"
MyAnimeList.PLAN_TO_READ -> "plan_to_read"
MyAnimeList.REREADING -> "reading"
else -> null
}
fun getStatus(status: String) = when (status) {
"reading" -> MyAnimeList.READING
"completed" -> MyAnimeList.COMPLETED
"on_hold" -> MyAnimeList.ON_HOLD
"dropped" -> MyAnimeList.DROPPED
"plan_to_read" -> MyAnimeList.PLAN_TO_READ
else -> MyAnimeList.READING
}

View File

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val refresh_token: String,
val access_token: String,
val token_type: String,
val expires_in: Long
) {
fun isExpired() = System.currentTimeMillis() > expires_in
}

View File

@ -70,20 +70,21 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track, getUsername()) return api.updateLibManga(track, getUsername())
} }
override suspend fun add(track: Track): Track {
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
return api.addLibManga(track, getUsername())
}
override suspend fun bind(track: Track): Track { override suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUsername()) val remoteTrack = api.findLibManga(track, getUsername())
if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
update(track) update(track)
} else { } else {
// Set default fields if it's not found in the list add(track)
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
return api.addLibManga(track, getUsername())
} }
return track
} }
override suspend fun search(query: String) = api.search(query) override suspend fun search(query: String) = api.search(query)
@ -130,7 +131,7 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).set(null) preferences.trackToken(this).delete()
interceptor.newAuth(null) interceptor.newAuth(null)
} }

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.MediaType import okhttp3.MediaType
@ -10,6 +12,8 @@ import okhttp3.Response
import rx.Observable import rx.Observable
import rx.Producer import rx.Producer
import rx.Subscription import rx.Subscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.fullType
import java.io.BufferedReader import java.io.BufferedReader
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader import java.io.InputStreamReader
@ -105,6 +109,15 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene
return progressClient.newCall(request) return progressClient.newCall(request)
} }
inline fun <reified T> Response.parseAs(): T {
// Avoiding Injekt.get<Json>() due to compiler issues
val json = Injekt.getInstance<Json>(fullType<Json>().type)
this.use {
val responseBody = it.body?.string().orEmpty()
return json.decodeFromString(responseBody)
}
}
fun MediaType.Companion.jsonType(): MediaType = "application/json; charset=utf-8".toMediaTypeOrNull()!! fun MediaType.Companion.jsonType(): MediaType = "application/json; charset=utf-8".toMediaTypeOrNull()!!
fun Response.consumeBody(): String? { fun Response.consumeBody(): String? {

View File

@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.ui.base.activity
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.system.ThemeUtil
import uy.kohesive.injekt.injectLazy
abstract class BaseThemedActivity : AppCompatActivity() {
val preferences: PreferencesHelper by injectLazy()
override fun onCreate(savedInstanceState: Bundle?) {
AppCompatDelegate.setDefaultNightMode(ThemeUtil.nightMode(preferences.theme()))
setTheme(ThemeUtil.theme(preferences.theme()))
super.onCreate(savedInstanceState)
}
}

View File

@ -46,5 +46,7 @@ class TrackAdapter(controller: OnClickListener) : RecyclerView.Adapter<TrackHold
fun onChaptersClick(position: Int) fun onChaptersClick(position: Int)
fun onScoreClick(position: Int) fun onScoreClick(position: Int)
fun onRemoveClick(position: Int) fun onRemoveClick(position: Int)
fun onStartDateClick(position: Int)
fun onFinishDateClick(position: Int)
} }
} }

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.recently_read
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DateFormat import java.text.DateFormat

View File

@ -10,9 +10,10 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.ui.setting.track.MyAnimeListLoginActivity
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.widget.preference.LoginPreference import eu.kanade.tachiyomi.widget.preference.LoginPreference
import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog
import eu.kanade.tachiyomi.widget.preference.TrackLogoutDialog import eu.kanade.tachiyomi.widget.preference.TrackLogoutDialog
@ -38,49 +39,45 @@ class SettingsTrackingController :
titleRes = R.string.services titleRes = R.string.services
trackPreference(trackManager.myAnimeList) { trackPreference(trackManager.myAnimeList) {
onClick { activity?.openInBrowser(MyAnimeListApi.authUrl(), trackManager.myAnimeList.getLogoColor())
if (trackManager.myAnimeList.isLogged) {
val dialog = TrackLogoutDialog(trackManager.myAnimeList)
dialog.targetController = this@SettingsTrackingController
dialog.showDialog(router)
} else {
startActivity(MyAnimeListLoginActivity.newIntent(context))
}
}
} }
trackPreference(trackManager.aniList) { trackPreference(trackManager.aniList) {
onClick { activity?.openInBrowser(AnilistApi.authUrl(), trackManager.aniList.getLogoColor())
showDialog(trackManager.aniList, AnilistApi.authUrl())
}
} }
trackPreference(trackManager.kitsu) { trackPreference(trackManager.kitsu) {
onClick { val dialog = TrackLoginDialog(trackManager.kitsu, R.string.email)
showDialog(trackManager.kitsu, userNameLabel = context.getString(R.string.email)) dialog.targetController = this@SettingsTrackingController
} dialog.showDialog(router)
} }
trackPreference(trackManager.shikimori) { trackPreference(trackManager.shikimori) {
onClick { activity?.openInBrowser(ShikimoriApi.authUrl(), trackManager.shikimori.getLogoColor())
showDialog(trackManager.shikimori, ShikimoriApi.authUrl())
}
} }
trackPreference(trackManager.bangumi) { trackPreference(trackManager.bangumi) {
onClick { activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor())
showDialog(trackManager.bangumi, BangumiApi.authUrl())
}
} }
} }
} }
inline fun PreferenceScreen.trackPreference( private inline fun PreferenceScreen.trackPreference(
service: TrackService, service: TrackService,
block: (@DSL LoginPreference).() -> Unit crossinline login: () -> Unit
): LoginPreference { ): LoginPreference {
return initThenAdd( return initThenAdd(
LoginPreference(context).apply { LoginPreference(context).apply {
key = Keys.trackUsername(service.id) key = Keys.trackUsername(service.id)
title = context.getString(service.nameRes()) title = context.getString(service.nameRes())
}, },
block {
onClick {
if (service.isLogged) {
val dialog = TrackLogoutDialog(service)
dialog.targetController = this@SettingsTrackingController
dialog.showDialog(router)
} else {
login()
}
}
}
) )
} }
@ -92,24 +89,6 @@ class SettingsTrackingController :
updatePreference(trackManager.bangumi.id) updatePreference(trackManager.bangumi.id)
} }
private fun showDialog(trackService: TrackService, url: Uri? = null, userNameLabel: String? = null) {
if (trackService.isLogged) {
val dialog = TrackLogoutDialog(trackService)
dialog.targetController = this@SettingsTrackingController
dialog.showDialog(router)
} else if (url == null) {
val dialog = TrackLoginDialog(trackService, userNameLabel)
dialog.targetController = this@SettingsTrackingController
dialog.showDialog(router)
} else {
val tabsIntent = CustomTabsIntent.Builder()
.setToolbarColor(activity!!.getResourceColor(R.attr.colorPrimaryVariant))
.build()
tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
tabsIntent.launchUrl(activity!!, url)
}
}
private fun updatePreference(id: Int) { private fun updatePreference(id: Int) {
val pref = findPreference(Keys.trackUsername(id)) as? LoginPreference val pref = findPreference(Keys.trackUsername(id)) as? LoginPreference
pref?.notifyChanged() pref?.notifyChanged()

View File

@ -1,14 +1,17 @@
package eu.kanade.tachiyomi.ui.setting.track package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -16,22 +19,13 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class AnilistLoginActivity : AppCompatActivity() { class AnilistLoginActivity : BaseOAuthLoginActivity() {
private val trackManager: TrackManager by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
val view = ProgressBar(this)
setContentView(view, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER))
override fun handleResult(data: Uri?) {
val regex = "(?:access_token=)(.*?)(?:&)".toRegex() val regex = "(?:access_token=)(.*?)(?:&)".toRegex()
val matchResult = regex.find(intent.data?.fragment.toString()) val matchResult = regex.find(data?.fragment.toString())
if (matchResult?.groups?.get(1) != null) { if (matchResult?.groups?.get(1) != null) {
scope.launch { lifecycleScope.launchIO {
trackManager.aniList.login(matchResult.groups[1]!!.value) trackManager.aniList.login(matchResult.groups[1]!!.value)
returnToSettings() returnToSettings()
} }
@ -40,17 +34,4 @@ class AnilistLoginActivity : AppCompatActivity() {
returnToSettings() returnToSettings()
} }
} }
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
private fun returnToSettings() {
finish()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
}
} }

View File

@ -1,35 +1,29 @@
package eu.kanade.tachiyomi.ui.setting.track package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class BangumiLoginActivity : AppCompatActivity() { class BangumiLoginActivity : BaseOAuthLoginActivity() {
private val trackManager: TrackManager by injectLazy() override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
val view = ProgressBar(this)
setContentView(view, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER))
val code = intent.data?.getQueryParameter("code")
if (code != null) { if (code != null) {
scope.launch { lifecycleScope.launchIO {
trackManager.bangumi.login(code) trackManager.bangumi.login(code)
returnToSettings() returnToSettings()
} }
@ -38,12 +32,4 @@ class BangumiLoginActivity : AppCompatActivity() {
returnToSettings() returnToSettings()
} }
} }
private fun returnToSettings() {
finish()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
}
} }

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ProgressBar
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.activity.BaseThemedActivity
import eu.kanade.tachiyomi.ui.main.MainActivity
import uy.kohesive.injekt.injectLazy
abstract class BaseOAuthLoginActivity : BaseThemedActivity() {
internal val trackManager: TrackManager by injectLazy()
abstract fun handleResult(data: Uri?)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val view = ProgressBar(this)
setContentView(
view,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
)
)
handleResult(intent.data)
}
internal fun returnToSettings() {
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
finishAfterTransition()
}
}

View File

@ -1,77 +1,21 @@
package eu.kanade.tachiyomi.ui.setting.track package eu.kanade.tachiyomi.ui.setting.track
import android.content.Context import android.net.Uri
import android.content.Intent import androidx.lifecycle.lifecycleScope
import android.os.Bundle import eu.kanade.tachiyomi.util.system.launchIO
import android.webkit.WebView
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.webview.BaseWebViewActivity
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import kotlinx.android.synthetic.main.webview_activity.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uy.kohesive.injekt.injectLazy
class MyAnimeListLoginActivity : BaseWebViewActivity() { class MyAnimeListLoginActivity : BaseOAuthLoginActivity() {
private val trackManager: TrackManager by injectLazy() override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
private val scope = CoroutineScope(Job() + Dispatchers.Main) if (code != null) {
lifecycleScope.launchIO {
override fun onCreate(savedState: Bundle?) { trackManager.myAnimeList.login(code)
super.onCreate(savedState) returnToSettings()
title = "MyAnimeList"
webview.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
view.loadUrl(url)
return true
} }
} else {
override fun onPageFinished(view: WebView?, url: String?) { trackManager.myAnimeList.logout()
super.onPageFinished(view, url) returnToSettings()
// Get CSRF token from HTML after post-login redirect
if (url == MyAnimeListApi.baseUrl + "/") {
view?.evaluateJavascript(
"(function(){return document.querySelector('meta[name=csrf_token]').getAttribute('content')})();"
) {
scope.launch {
withContext(Dispatchers.IO) { trackManager.myAnimeList.login(it.replace("\"", "")) }
returnToSettings()
}
}
}
}
}
webview.loadUrl(MyAnimeListApi.loginUrl().toString())
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
private fun returnToSettings() {
finish()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
}
companion object {
fun newIntent(context: Context): Intent {
val intent = Intent(context, MyAnimeListLoginActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
return intent
} }
} }
} }

View File

@ -1,35 +1,28 @@
package eu.kanade.tachiyomi.ui.setting.track package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class ShikimoriLoginActivity : BaseOAuthLoginActivity() {
class ShikimoriLoginActivity : AppCompatActivity() { override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
private val trackManager: TrackManager by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
val view = ProgressBar(this)
setContentView(view, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER))
val code = intent.data?.getQueryParameter("code")
if (code != null) { if (code != null) {
scope.launch { lifecycleScope.launchIO {
trackManager.shikimori.login(code) trackManager.shikimori.login(code)
returnToSettings() returnToSettings()
} }
@ -38,12 +31,4 @@ class ShikimoriLoginActivity : AppCompatActivity() {
returnToSettings() returnToSettings()
} }
} }
private fun returnToSettings() {
finish()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
}
} }

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.util
import android.util.Base64
import java.security.SecureRandom
object PkceUtil {
private const val PKCE_BASE64_ENCODE_SETTINGS = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
fun generateCodeVerifier(): String {
val codeVerifier = ByteArray(50)
SecureRandom().nextBytes(codeVerifier)
return Base64.encodeToString(codeVerifier, PKCE_BASE64_ENCODE_SETTINGS)
}
}

View File

@ -14,14 +14,17 @@ import android.content.res.Resources
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@ -235,14 +238,37 @@ fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
.any { className == it.service.className } .any { className == it.service.className }
} }
fun Context.openInBrowser(url: String, @ColorInt toolbarColor: Int? = null) {
this.openInBrowser(url.toUri(), toolbarColor)
}
fun Context.openInBrowser(uri: Uri, @ColorInt toolbarColor: Int? = null) {
try {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor ?: getResourceColor(R.attr.colorPrimaryVariant))
.build()
)
.build()
intent.launchUrl(this, uri)
} catch (e: Exception) {
toast(e.message)
}
}
/** /**
* Opens a URL in a custom tab. * Opens a URL in a custom tab.
*/ */
fun Context.openInBrowser(url: String, forceBrowser: Boolean = false): Boolean { fun Context.openInBrowser(url: String, forceBrowser: Boolean): Boolean {
try { try {
val parsedUrl = url.toUri() val parsedUrl = url.toUri()
val intent = CustomTabsIntent.Builder() val intent = CustomTabsIntent.Builder()
.setToolbarColor(getResourceColor(R.attr.colorPrimaryVariant)) .setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(getResourceColor(R.attr.colorPrimaryVariant))
.build()
)
.build() .build()
if (forceBrowser) { if (forceBrowser) {
val packages = getCustomTabsPackages().maxBy { it.preferredOrder } val packages = getCustomTabsPackages().maxBy { it.preferredOrder }

View File

@ -6,9 +6,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = fun launchUI(block: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block)
fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = fun launchNow(block: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block)
fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.IO, block = block)
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block)

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.widget.preference
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.StringRes
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView import com.afollestad.materialdialogs.customview.customView
import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeHandler
@ -19,8 +20,8 @@ import rx.Subscription
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
abstract class LoginDialogPreference( abstract class LoginDialogPreference(
private val usernameLabel: String? = null, @StringRes private val usernameLabelRes: Int? = null,
bundle: Bundle? = null bundle: Bundle? = null
) : ) :
DialogController(bundle) { DialogController(bundle) {
@ -48,8 +49,8 @@ abstract class LoginDialogPreference(
fun onViewCreated(view: View) { fun onViewCreated(view: View) {
v = view.apply { v = view.apply {
if (!usernameLabel.isNullOrEmpty()) { if (usernameLabelRes != null) {
username_input.hint = usernameLabel username_input.hint = view.context.getString(usernameLabelRes)
} }
login.setOnClickListener { login.setOnClickListener {

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.widget.preference
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.StringRes
import br.com.simplepass.loadingbutton.animatedDrawables.ProgressType import br.com.simplepass.loadingbutton.animatedDrawables.ProgressType
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
@ -12,15 +13,15 @@ import kotlinx.coroutines.launch
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) : class TrackLoginDialog(@StringRes usernameLabelRes: Int? = null, bundle: Bundle? = null) :
LoginDialogPreference(usernameLabel, bundle) { LoginDialogPreference(usernameLabelRes, bundle) {
private val service = Injekt.get<TrackManager>().getService(args.getInt("key"))!! private val service = Injekt.get<TrackManager>().getService(args.getInt("key"))!!
override var canLogout = true override var canLogout = true
constructor(service: TrackService, usernameLabel: String?) : constructor(service: TrackService, @StringRes usernameLabelRes: Int?) :
this(usernameLabel, Bundle().apply { putInt("key", service.id) }) this(usernameLabelRes, Bundle().apply { putInt("key", service.id) })
override fun setCredentialsOnView(view: View) = with(view) { override fun setCredentialsOnView(view: View) = with(view) {
val serviceName = context.getString(service.nameRes()) val serviceName = context.getString(service.nameRes())

View File

@ -433,6 +433,7 @@
<string name="kitsu" translatable="false">Kitsu</string> <string name="kitsu" translatable="false">Kitsu</string>
<string name="bangumi" translatable="false">Bangumi</string> <string name="bangumi" translatable="false">Bangumi</string>
<string name="shikimori" translatable="false">Shikimori</string> <string name="shikimori" translatable="false">Shikimori</string>
<string name="myanimelist_relogin">Please login to MAL again</string>
<!-- Migration --> <!-- Migration -->
<string name="select_sources">Select sources</string> <string name="select_sources">Select sources</string>