mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-11-04 08:08:55 +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