mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	Migrate to official MyAnimeList API (closes #4140)
This commit is contained in:
		| @@ -96,7 +96,18 @@ | ||||
|         </activity> | ||||
|         <activity | ||||
|             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="tachiyomi" /> | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|         <activity | ||||
|             android:name=".ui.setting.track.ShikimoriLoginActivity" | ||||
|             android:label="Shikimori"> | ||||
|   | ||||
| @@ -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<Int> | ||||
|   | ||||
| @@ -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<Int> { | ||||
|         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<Track> { | ||||
|         return api.addLibManga(track) | ||||
|         return runAsObservable { api.addItemToList(track) } | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         return api.updateLibManga(track) | ||||
|         return runAsObservable { api.updateItem(track) } | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track) | ||||
|             .flatMap { remoteTrack -> | ||||
|                 if (remoteTrack != null) { | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     update(track) | ||||
|                 } else { | ||||
|                     // Set default fields if it's not found in the list | ||||
|                     track.score = DEFAULT_SCORE.toFloat() | ||||
|                     track.status = DEFAULT_STATUS | ||||
|                     add(track) | ||||
|                 } | ||||
|             } | ||||
|         return runAsObservable { api.getListItem(track) } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|         return runAsObservable { api.search(query) } | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track) | ||||
|             .map { remoteTrack -> | ||||
|                 track.copyPersonalFrom(remoteTrack) | ||||
|                 track.total_chapters = remoteTrack.total_chapters | ||||
|                 track | ||||
|             } | ||||
|         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<OAuth>(preferences.trackToken(this).get()) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|         return ckCount == 2 | ||||
|     private fun <T> runAsObservable(block: suspend () -> T): Observable<T> { | ||||
|         return Observable.fromCallable { runBlocking(Dispatchers.IO) { block() } } | ||||
|             .subscribeOn(Schedulers.io()) | ||||
|             .observeOn(AndroidSchedulers.mainThread()) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<List<TrackSearch>> { | ||||
|         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<Track> { | ||||
|         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<JsonObject>(responseBody) | ||||
|                 response["name"]!!.jsonPrimitive.content | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun updateLibManga(track: Track): Observable<Track> { | ||||
|         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<TrackSearch> { | ||||
|         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<JsonObject>(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<Track?> { | ||||
|         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<Track> { | ||||
|         return findLibManga(track) | ||||
|             .map { it ?: throw Exception("Could not find manga") } | ||||
|     } | ||||
|  | ||||
|     private fun getList(): Observable<List<TrackSearch>> { | ||||
|         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<JsonObject>(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<String> { | ||||
|         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<Document> { | ||||
|         return authClient.newCall(GET(url)) | ||||
|             .asObservable() | ||||
|             .map { response -> | ||||
|                 Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser()) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     private fun Response.consumeBody(): String? { | ||||
|         use { | ||||
|             if (it.code != 200) throw Exception("HTTP error ${it.code}") | ||||
|             return it.body?.string() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun Response.consumeXmlBody(): String? { | ||||
|         use { res -> | ||||
|             if (res.code != 200) throw Exception("Export list error") | ||||
|             BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader -> | ||||
|                 val sb = StringBuilder() | ||||
|                 reader.forEachLine { line -> | ||||
|                     sb.append(line) | ||||
|                 } | ||||
|                 return sb.toString() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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<JsonObject>(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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										15
									
								
								app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt
									
									
									
									
									
										Normal 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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user