From 0affc0d58bde6b1533d585bb0c0cbd103e0c59a9 Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 14 Dec 2020 17:57:35 -0500 Subject: [PATCH] Migrate to official MyAnimeList API (closes #4140) --- app/src/main/AndroidManifest.xml | 13 +- .../tachiyomi/data/track/TrackService.kt | 2 + .../data/track/myanimelist/MyAnimeList.kt | 105 ++-- .../data/track/myanimelist/MyAnimeListApi.kt | 581 +++++------------- .../myanimelist/MyAnimeListInterceptor.kt | 84 +-- .../track/myanimelist/MyAnimeListModels.kt | 22 + .../tachiyomi/data/track/myanimelist/OAuth.kt | 14 + .../ui/setting/SettingsTrackingController.kt | 17 +- .../setting/track/MyAnimeListLoginActivity.kt | 83 +-- .../java/eu/kanade/tachiyomi/util/PkceUtil.kt | 15 + .../util/system/ContextExtensions.kt | 7 +- 11 files changed, 342 insertions(+), 601 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/OAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 19800e17c..cb1177a70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,7 +96,18 @@ + android:label="MyAnimeList"> + + + + + + + + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index 8c2c54da8..5e2611942 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.track import androidx.annotation.CallSuper +import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -28,6 +29,7 @@ abstract class TrackService(val id: Int) { @DrawableRes abstract fun getLogo(): Int + @ColorInt abstract fun getLogoColor(): Int abstract fun getStatusList(): List diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index 9559e4de7..bcfe72920 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -6,9 +6,17 @@ 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 okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import rx.Completable import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import timber.log.Timber +import uy.kohesive.injekt.injectLazy class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { @@ -18,29 +26,23 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { const val ON_HOLD = 3 const val DROPPED = 4 const val PLAN_TO_READ = 6 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0 - - const val BASE_URL = "https://myanimelist.net" - const val USER_SESSION_COOKIE = "MALSESSIONID" - const val LOGGED_IN_COOKIE = "is_logged_in" + const val REREADING = 7 } - 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) } override val name: String get() = "MyAnimeList" - override val supportsReadingDates: Boolean = true - override fun getLogo() = R.drawable.ic_tracker_mal override fun getLogoColor() = Color.rgb(46, 81, 162) override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING) } override fun getStatus(status: Int): String = with(context) { @@ -50,6 +52,7 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { ON_HOLD -> getString(R.string.on_hold) DROPPED -> getString(R.string.dropped) PLAN_TO_READ -> getString(R.string.plan_to_read) + REREADING -> getString(R.string.repeating) else -> "" } } @@ -65,76 +68,62 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { } override fun add(track: Track): Observable { - return api.addLibManga(track) + return runAsObservable { api.addItemToList(track) } } override fun update(track: Track): Observable { - return api.updateLibManga(track) + return runAsObservable { api.updateItem(track) } } override fun bind(track: Track): Observable { - return api.findLibManga(track) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - } - } + return runAsObservable { api.getListItem(track) } } override fun search(query: String): Observable> { - return api.search(query) + return runAsObservable { api.search(query) } } override fun refresh(track: Track): Observable { - return api.getLibManga(track) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } + return runAsObservable { api.getListItem(track) } } - fun login(csrfToken: String): Completable = login("myanimelist", csrfToken) + override fun login(username: String, password: String) = login(password) - override fun login(username: String, password: String): Completable { - return Observable.fromCallable { saveCSRF(password) } - .doOnNext { saveCredentials(username, password) } - .doOnError { logout() } - .toCompletable() - } - - fun ensureLoggedIn() { - if (isAuthorized) return - if (!isLogged) throw Exception(context.getString(R.string.myanimelist_creds_missing)) + fun login(authCode: String): Completable { + return try { + val oauth = runBlocking { api.getAccessToken(authCode) } + interceptor.setAuth(oauth) + val username = runBlocking { api.getCurrentUser() } + saveCredentials(username, oauth.access_token) + return Completable.complete() + } catch (e: Exception) { + Timber.e(e) + logout() + Completable.error(e) + } } override fun logout() { super.logout() preferences.trackToken(this).delete() - networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!) + interceptor.setAuth(null) } - private val isAuthorized: Boolean - get() = super.isLogged && - getCSRF().isNotEmpty() && - checkCookies() + fun saveOAuth(oAuth: OAuth?) { + preferences.trackToken(this).set(json.encodeToString(oAuth)) + } - fun getCSRF(): String = preferences.trackToken(this).get() - - private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) - - private fun checkCookies(): Boolean { - val url = BASE_URL.toHttpUrlOrNull()!! - val ckCount = networkService.cookieManager.get(url).count { - it.name == USER_SESSION_COOKIE || it.name == LOGGED_IN_COOKIE + fun loadOAuth(): OAuth? { + return try { + json.decodeFromString(preferences.trackToken(this).get()) + } catch (e: Exception) { + null } + } - return ckCount == 2 + private fun runAsObservable(block: suspend () -> T): Observable { + return Observable.fromCallable { runBlocking(Dispatchers.IO) { block() } } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index 125b97395..be773cd24 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -1,472 +1,209 @@ package eu.kanade.tachiyomi.data.track.myanimelist +import android.net.Uri import androidx.core.net.toUri 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.GET import eu.kanade.tachiyomi.network.POST -import eu.kanade.tachiyomi.network.asObservable -import eu.kanade.tachiyomi.network.asObservableSuccess -import eu.kanade.tachiyomi.util.lang.toCalendar -import eu.kanade.tachiyomi.util.selectInt -import eu.kanade.tachiyomi.util.selectText +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.util.PkceUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +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.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import okhttp3.Request 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 uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.GregorianCalendar import java.util.Locale -import java.util.zip.GZIPInputStream class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { + private val json: Json by injectLazy() + private val authClient = client.newBuilder().addInterceptor(interceptor).build() - fun search(query: String): Observable> { - return if (query.startsWith(PREFIX_MY)) { - val realQuery = query.removePrefix(PREFIX_MY) - getList() - .flatMap { Observable.from(it) } - .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() + suspend fun getAccessToken(authCode: String): OAuth { + return withContext(Dispatchers.IO) { + val formBody: RequestBody = FormBody.Builder() + .add("client_id", clientId) + .add("code", authCode) + .add("code_verifier", codeVerifier) + .add("grant_type", "authorization_code") + .build() + client.newCall(POST("$baseOAuthUrl/token", body = formBody)).await().use { + val responseBody = it.body?.string().orEmpty() + json.decodeFromString(responseBody) + } } } - fun addLibManga(track: Track): Observable { - return Observable.defer { - authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))) - .asObservableSuccess() - .map { track } + suspend fun getCurrentUser(): String { + return withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$baseApiUrl/users/@me") + .get() + .build() + authClient.newCall(request).await().use { + val responseBody = it.body?.string().orEmpty() + val response = json.decodeFromString(responseBody) + response["name"]!!.jsonPrimitive.content + } } } - fun updateLibManga(track: Track): Observable { - return Observable.defer { - // Get track data - val response = authClient.newCall(GET(url = editPageUrl(track.media_id))).execute() - val editData = response.use { - val page = Jsoup.parse(it.consumeBody()) - - // Extract track data from MAL page - extractDataFromEditPage(page).apply { - // Apply changes to the just fetched data - copyPersonalFrom(track) - } + suspend fun search(query: String): List { + return withContext(Dispatchers.IO) { + val url = "$baseApiUrl/manga".toUri().buildUpon() + .appendQueryParameter("q", query) + .build() + authClient.newCall(GET(url.toString())).await().use { + val responseBody = it.body?.string().orEmpty() + val response = json.decodeFromString(responseBody) + response["data"]!!.jsonArray.map { + val node = it.jsonObject["node"]!!.jsonObject + val id = node["id"]!!.jsonPrimitive.int + async { getMangaDetails(id) } + }.awaitAll() } - - // Update remote - authClient.newCall(POST(url = editPageUrl(track.media_id), body = mangaEditPostBody(editData))) - .asObservableSuccess() - .map { - track - } } } - fun findLibManga(track: Track): Observable { - return authClient.newCall(GET(url = editPageUrl(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 - started_reading_date = trackForm.searchDatePicker("#add_manga_start_date") - finished_reading_date = trackForm.searchDatePicker("#add_manga_finish_date") - } - } - } - libTrack - } - } - - fun getLibManga(track: Track): Observable { - return findLibManga(track) - .map { it ?: throw Exception("Could not find manga") } - } - - private fun getList(): Observable> { - return getListUrl() - .flatMap { url -> - getListXml(url) - } - .flatMap { doc -> - Observable.from(doc.select("manga")) - } - .map { + private suspend fun getMangaDetails(id: Int): TrackSearch { + return withContext(Dispatchers.IO) { + 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().use { + val responseBody = it.body?.string().orEmpty() + val response = json.decodeFromString(responseBody) + val obj = response.jsonObject 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) - started_reading_date = it.searchDateXml("my_start_date") - finished_reading_date = it.searchDateXml("my_finish_date") + 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 + publishing_type = obj["media_type"]!!.jsonPrimitive.content + start_date = try { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(obj["start_date"]!!) + } catch (e: Exception) { + "" + } } } - .toList() - } - - private fun getListUrl(): Observable { - return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())) - .asObservable() - .map { response -> - baseUrl + Jsoup.parse(response.consumeBody()) - .select("div.goodresult") - .select("a") - .attr("href") - } - } - - private fun getListXml(url: String): Observable { - return authClient.newCall(GET(url)) - .asObservable() - .map { response -> - Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser()) - } - } - - private fun Response.consumeBody(): String? { - use { - if (it.code != 200) throw Exception("HTTP error ${it.code}") - return it.body?.string() - } - } - - private fun Response.consumeXmlBody(): String? { - use { res -> - if (res.code != 200) throw Exception("Export list error") - BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader -> - val sb = StringBuilder() - reader.forEachLine { line -> - sb.append(line) - } - return sb.toString() - } } } - private fun extractDataFromEditPage(page: Document): MyAnimeListEditData { - val tables = page.select("form#main-form table") + suspend fun getListItem(track: Track): Track { + return withContext(Dispatchers.IO) { + val formBody: RequestBody = FormBody.Builder() + .add("status", track.toMyAnimeListStatus() ?: "reading") + .build() + val request = Request.Builder() + .url(mangaUrl(track.media_id).toString()) + .put(formBody) + .build() + authClient.newCall(request).await().use { + parseMangaItem(it, track) + } + } + } - return MyAnimeListEditData( - entry_id = tables[0].select("input[name=entry_id]").`val`(), // Always 0 - manga_id = tables[0].select("#manga_id").`val`(), - status = tables[0].select("#add_manga_status > option[selected]").`val`(), - num_read_volumes = tables[0].select("#add_manga_num_read_volumes").`val`(), - last_completed_vol = tables[0].select("input[name=last_completed_vol]").`val`(), // Always empty - num_read_chapters = tables[0].select("#add_manga_num_read_chapters").`val`(), - score = tables[0].select("#add_manga_score > option[selected]").`val`(), - start_date_month = tables[0].select("#add_manga_start_date_month > option[selected]").`val`(), - start_date_day = tables[0].select("#add_manga_start_date_day > option[selected]").`val`(), - start_date_year = tables[0].select("#add_manga_start_date_year > option[selected]").`val`(), - finish_date_month = tables[0].select("#add_manga_finish_date_month > option[selected]").`val`(), - finish_date_day = tables[0].select("#add_manga_finish_date_day > option[selected]").`val`(), - finish_date_year = tables[0].select("#add_manga_finish_date_year > option[selected]").`val`(), - tags = tables[1].select("#add_manga_tags").`val`(), - priority = tables[1].select("#add_manga_priority > option[selected]").`val`(), - storage_type = tables[1].select("#add_manga_storage_type > option[selected]").`val`(), - num_retail_volumes = tables[1].select("#add_manga_num_retail_volumes").`val`(), - num_read_times = tables[1].select("#add_manga_num_read_times").`val`(), - reread_value = tables[1].select("#add_manga_reread_value > option[selected]").`val`(), - comments = tables[1].select("#add_manga_comments").`val`(), - is_asked_to_discuss = tables[1].select("#add_manga_is_asked_to_discuss > option[selected]").`val`(), - sns_post_type = tables[1].select("#add_manga_sns_post_type > option[selected]").`val`() - ) + suspend fun addItemToList(track: Track): Track { + return withContext(Dispatchers.IO) { + val formBody: RequestBody = FormBody.Builder() + .add("status", "reading") + .add("score", "0") + .build() + val request = Request.Builder() + .url(mangaUrl(track.media_id).toString()) + .put(formBody) + .build() + authClient.newCall(request).await().use { + parseMangaItem(it, track) + } + } + } + + suspend fun updateItem(track: Track): Track { + return withContext(Dispatchers.IO) { + val formBody: RequestBody = 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()) + .build() + val request = Request.Builder() + .url(mangaUrl(track.media_id).toString()) + .put(formBody) + .build() + authClient.newCall(request).await().use { + parseMangaItem(it, track) + } + } + } + + private fun parseMangaItem(response: Response, track: Track): Track { + val responseBody = response.body?.string().orEmpty() + val obj = json.decodeFromString(responseBody).jsonObject + 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() + } } companion object { - const val CSRF = "csrf_token" + // Registered under arkon's MAL account + private const val clientId = "8fd3313bc138e8b890551aa1de1a2589" - private 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 const val baseOAuthUrl = "https://myanimelist.net/v1/oauth2" + private const val baseApiUrl = "https://api.myanimelist.net/v2" - fun loginUrl() = baseUrl.toUri().buildUpon() - .appendPath("login.php") - .toString() + private var codeVerifier: String = "" - private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId + fun authUrl(): Uri = "$baseOAuthUrl/authorize".toUri().buildUpon() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("code_challenge", getPkceChallengeCode()) + .appendQueryParameter("response_type", "code") + .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() - } + fun mangaUrl(id: Int): Uri = "$baseApiUrl/manga".toUri().buildUpon() + .appendPath(id.toString()) + .appendPath("my_list_status") + .build() - private fun exportListUrl() = baseUrl.toUri().buildUpon() - .appendPath("panel.php") - .appendQueryParameter("go", "export") - .toString() - - private fun editPageUrl(mediaId: Int) = baseModifyListUrl.toUri().buildUpon() - .appendPath(mediaId.toString()) - .appendPath("edit") - .toString() - - private fun addUrl() = baseModifyListUrl.toUri().buildUpon() - .appendPath("add.json") - .toString() - - private fun exportPostBody(): RequestBody { - return FormBody.Builder() - .add("type", "2") - .add("subexport", "Export My List") + 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 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".toMediaType()) - } - - private fun mangaEditPostBody(track: MyAnimeListEditData): RequestBody { - return FormBody.Builder() - .add("entry_id", track.entry_id) - .add("manga_id", track.manga_id) - .add("add_manga[status]", track.status) - .add("add_manga[num_read_volumes]", track.num_read_volumes) - .add("last_completed_vol", track.last_completed_vol) - .add("add_manga[num_read_chapters]", track.num_read_chapters) - .add("add_manga[score]", track.score) - .add("add_manga[start_date][month]", track.start_date_month) - .add("add_manga[start_date][day]", track.start_date_day) - .add("add_manga[start_date][year]", track.start_date_year) - .add("add_manga[finish_date][month]", track.finish_date_month) - .add("add_manga[finish_date][day]", track.finish_date_day) - .add("add_manga[finish_date][year]", track.finish_date_year) - .add("add_manga[tags]", track.tags) - .add("add_manga[priority]", track.priority) - .add("add_manga[storage_type]", track.storage_type) - .add("add_manga[num_retail_volumes]", track.num_retail_volumes) - .add("add_manga[num_read_times]", track.num_read_times) - .add("add_manga[reread_value]", track.reread_value) - .add("add_manga[comments]", track.comments) - .add("add_manga[is_asked_to_discuss]", track.is_asked_to_discuss) - .add("add_manga[sns_post_type]", track.sns_post_type) - .add("submitIt", track.submitIt) - .build() - } - - private fun Element.searchDateXml(field: String): Long { - val text = selectText(field, "0000-00-00")!! - // MAL sets the data to 0000-00-00 when date is invalid or missing - if (text == "0000-00-00") { - return 0L - } - - return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(text)?.time ?: 0L - } - - private fun Element.searchDatePicker(id: String): Long { - val month = select(id + "_month > option[selected]").`val`().toIntOrNull() - val day = select(id + "_day > option[selected]").`val`().toIntOrNull() - val year = select(id + "_year > option[selected]").`val`().toIntOrNull() - if (year == null || month == null || day == null) { - return 0L - } - - return GregorianCalendar(year, month - 1, day).timeInMillis - } - - 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 - } - } - - private class MyAnimeListEditData( - // entry_id - var entry_id: String, - - // manga_id - var manga_id: String, - - // add_manga[status] - var status: String, - - // add_manga[num_read_volumes] - var num_read_volumes: String, - - // last_completed_vol - var last_completed_vol: String, - - // add_manga[num_read_chapters] - var num_read_chapters: String, - - // add_manga[score] - var score: String, - - // add_manga[start_date][month] - var start_date_month: String, // [1-12] - - // add_manga[start_date][day] - var start_date_day: String, - - // add_manga[start_date][year] - var start_date_year: String, - - // add_manga[finish_date][month] - var finish_date_month: String, // [1-12] - - // add_manga[finish_date][day] - var finish_date_day: String, - - // add_manga[finish_date][year] - var finish_date_year: String, - - // add_manga[tags] - var tags: String, - - // add_manga[priority] - var priority: String, - - // add_manga[storage_type] - var storage_type: String, - - // add_manga[num_retail_volumes] - var num_retail_volumes: String, - - // add_manga[num_read_times] - var num_read_times: String, - - // add_manga[reread_value] - var reread_value: String, - - // add_manga[comments] - var comments: String, - - // add_manga[is_asked_to_discuss] - var is_asked_to_discuss: String, - - // add_manga[sns_post_type] - var sns_post_type: String, - - // submitIt - val submitIt: String = "0" - ) { - fun copyPersonalFrom(track: Track) { - num_read_chapters = track.last_chapter_read.toString() - val numScore = track.score.toInt() - if (numScore == 0) { - score = "" - } else if (numScore in 1..10) { - score = numScore.toString() - } - status = track.status.toString() - if (track.started_reading_date == 0L) { - start_date_month = "" - start_date_day = "" - start_date_year = "" - } - if (track.finished_reading_date == 0L) { - finish_date_month = "" - finish_date_day = "" - finish_date_year = "" - } - track.started_reading_date.toCalendar()?.let { cal -> - start_date_month = (cal[Calendar.MONTH] + 1).toString() - start_date_day = cal[Calendar.DAY_OF_MONTH].toString() - start_date_year = cal[Calendar.YEAR].toString() - } - track.finished_reading_date.toCalendar()?.let { cal -> - finish_date_month = (cal[Calendar.MONTH] + 1).toString() - finish_date_day = cal[Calendar.DAY_OF_MONTH].toString() - finish_date_year = cal[Calendar.YEAR].toString() - } + private fun getPkceChallengeCode(): String { + codeVerifier = PkceUtil.generateCodeVerifier() + return codeVerifier } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt index e8d61814b..2b401f0a5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt @@ -1,52 +1,58 @@ package eu.kanade.tachiyomi.data.track.myanimelist +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import okio.Buffer -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 { + + 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 { - myanimelist.ensureLoggedIn() + val originalRequest = chain.request() - val request = chain.request() - return chain.proceed(updateRequest(request)) - } - - private fun updateRequest(request: Request): Request { - return request.body?.let { - val contentType = it.contentType().toString() - val updatedBody = when { - contentType.contains("x-www-form-urlencoded") -> updateFormBody(it) - contentType.contains("json") -> updateJsonBody(it) - else -> it - } - request.newBuilder().post(updatedBody).build() - } ?: request - } - - private fun bodyToString(requestBody: RequestBody): String { - Buffer().use { - requestBody.writeTo(it) - return it.readUtf8() + if (token.isNullOrEmpty()) { + throw Exception("Not authenticated with MyAnimeList") } + if (oauth == null) { + oauth = myanimelist.loadOAuth() + } + // 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())) + } + } + } + + // 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) - - return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody(requestBody.contentType()) - } - - 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()) + /** + * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token + * and the oauth object. + */ + fun setAuth(oauth: OAuth?) { + token = oauth?.access_token + this.oauth = oauth + myanimelist.saveOAuth(oauth) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt new file mode 100644 index 000000000..ea8366716 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/OAuth.kt new file mode 100644 index 000000000..6f0a02d1a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/OAuth.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index 787565880..5600c9bf6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -1,15 +1,14 @@ package eu.kanade.tachiyomi.ui.setting import android.app.Activity -import android.content.Intent import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.anilist.AnilistApi 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.ui.setting.track.MyAnimeListLoginActivity import eu.kanade.tachiyomi.ui.setting.track.TrackLoginDialog import eu.kanade.tachiyomi.ui.setting.track.TrackLogoutDialog import eu.kanade.tachiyomi.util.preference.defaultValue @@ -43,12 +42,10 @@ class SettingsTrackingController : titleRes = R.string.services trackPreference(trackManager.myAnimeList) { - startActivity(MyAnimeListLoginActivity.newIntent(activity!!)) + activity?.openInBrowser(MyAnimeListApi.authUrl(), trackManager.myAnimeList.getLogoColor()) } trackPreference(trackManager.aniList) { - activity?.openInBrowser(AnilistApi.authUrl(), trackManager.aniList.getLogoColor()) { - intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - } + activity?.openInBrowser(AnilistApi.authUrl(), trackManager.aniList.getLogoColor()) } trackPreference(trackManager.kitsu) { val dialog = TrackLoginDialog(trackManager.kitsu, R.string.email) @@ -56,14 +53,10 @@ class SettingsTrackingController : dialog.showDialog(router) } trackPreference(trackManager.shikimori) { - activity?.openInBrowser(ShikimoriApi.authUrl(), trackManager.shikimori.getLogoColor()) { - intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - } + activity?.openInBrowser(ShikimoriApi.authUrl(), trackManager.shikimori.getLogoColor()) } trackPreference(trackManager.bangumi) { - activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor()) { - intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - } + activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor()) } } preferenceCategory { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/MyAnimeListLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/MyAnimeListLoginActivity.kt index 4a217bd82..bf00fd8ed 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/MyAnimeListLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/MyAnimeListLoginActivity.kt @@ -1,75 +1,28 @@ package eu.kanade.tachiyomi.ui.setting.track -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.webkit.WebView -import eu.kanade.tachiyomi.R -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 android.net.Uri import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers -import uy.kohesive.injekt.injectLazy -class MyAnimeListLoginActivity : BaseWebViewActivity() { +class MyAnimeListLoginActivity : BaseOAuthLoginActivity() { - private val trackManager: TrackManager by injectLazy() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - if (bundle == null) { - binding.webview.webViewClient = object : WebViewClientCompat() { - override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { - view.loadUrl(url) - return true - } - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - - // Get CSRF token from HTML after post-login redirect - if (url == "https://myanimelist.net/") { - view?.evaluateJavascript( - "(function(){return document.querySelector('meta[name=csrf_token]').getAttribute('content')})();" - ) { - trackManager.myAnimeList.login(it.replace("\"", "")) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { - returnToSettings() - }, - { - returnToSettings() - } - ) - } + override fun handleResult(data: Uri?) { + val code = data?.getQueryParameter("code") + if (code != null) { + trackManager.myAnimeList.login(code) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + returnToSettings() + }, + { + returnToSettings() } - } - } - - binding.webview.loadUrl(MyAnimeListApi.loginUrl()) - } - } - - 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 { - return Intent(context, MyAnimeListLoginActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(TITLE_KEY, context.getString(R.string.login)) - } + ) + } else { + trackManager.myAnimeList.logout() + returnToSettings() } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt new file mode 100644 index 000000000..e8d165f57 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index ed7275170..ce6e0465a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -226,11 +226,11 @@ fun Context.isServiceRunning(serviceClass: Class<*>): Boolean { /** * Opens a URL in a custom tab. */ -fun Context.openInBrowser(url: String, @ColorInt toolbarColor: Int? = null, block: CustomTabsIntent.() -> Unit = {}) { - this.openInBrowser(url.toUri(), toolbarColor, block) +fun Context.openInBrowser(url: String, @ColorInt toolbarColor: Int? = null) { + this.openInBrowser(url.toUri(), toolbarColor) } -fun Context.openInBrowser(uri: Uri, @ColorInt toolbarColor: Int? = null, block: CustomTabsIntent.() -> Unit = {}) { +fun Context.openInBrowser(uri: Uri, @ColorInt toolbarColor: Int? = null) { try { val intent = CustomTabsIntent.Builder() .setDefaultColorSchemeParams( @@ -239,7 +239,6 @@ fun Context.openInBrowser(uri: Uri, @ColorInt toolbarColor: Int? = null, block: .build() ) .build() - block(intent) intent.launchUrl(this, uri) } catch (e: Exception) { toast(e.message)