diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7b6632b7dc..c7f5f7c4e2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -95,7 +95,18 @@ android:theme="@style/FilePickerTheme" /> + android:label="MyAnimeList"> + + + + + + + + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 7604dc159a..9bc823f7a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.chapter.ChapterFilter import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.addSingleton @@ -49,6 +50,8 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { Gson() } + addSingletonFactory { Json { ignoreUnknownKeys = true } } + addSingletonFactory { ChapterFilter() } // Asynchronously init expensive components for a faster cold start diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 47b82af9e6..a44c199c3b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.updater.UpdaterJob import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.ui.library.LibraryPresenter +import eu.kanade.tachiyomi.util.system.toast import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -84,10 +85,15 @@ object Migrations { if (oldVersion < 66) { LibraryPresenter.updateCustoms() } - if (oldVersion < 67) { + if (oldVersion < 68) { // Force MAL log out due to login flow change + // v67: switched from scraping to WebView + // v68: switched from WebView to OAuth val trackManager = Injekt.get() - trackManager.myAnimeList.logout() + if (trackManager.myAnimeList.isLogged) { + trackManager.myAnimeList.logout() + context.toast(R.string.myanimelist_relogin) + } } return true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 6749def311..fdb77c9a99 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -150,7 +150,7 @@ class PreferencesHelper(val context: Context) { .apply() } - fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "") + fun trackToken(sync: TrackService) = flowPrefs.getString(Keys.trackToken(sync.id), "") fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10") diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index 9d9336196e..f753e663a8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -22,6 +22,9 @@ abstract class TrackService(val id: Int) { @StringRes abstract fun nameRes(): Int + // Application and remote support for reading dates + open val supportsReadingDates: Boolean = false + @DrawableRes abstract fun getLogo(): Int @@ -43,6 +46,8 @@ abstract class TrackService(val id: Int) { abstract fun displayScore(track: Track): String + abstract suspend fun add(track: Track): Track + abstract suspend fun update(track: Track): Track abstract suspend fun bind(track: Track): Track diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 1855bc8542..9bb16daf50 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -122,6 +122,12 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) { } } + override suspend fun add(track: Track): Track { + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + return api.addLibManga(track) + } + override suspend fun update(track: Track): Track { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { track.status = COMPLETED @@ -145,10 +151,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) { track.library_id = remoteTrack.library_id update(track) } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - api.addLibManga(track) + add(track) } } @@ -187,7 +190,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) { override fun logout() { super.logout() - preferences.trackToken(this).set(null) + preferences.trackToken(this).delete() interceptor.setAuth(null) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt index faee9d9086..96d92c4a23 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt @@ -37,22 +37,25 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) { return api.updateLibManga(track) } + override suspend fun add(track: Track): Track { + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + api.addLibManga(track) + return update(track) + } + override suspend fun bind(track: Track): Track { val statusTrack = api.statusLibManga(track) val remoteTrack = api.findLibManga(track) - if (statusTrack != null && remoteTrack != null) { + return if (statusTrack != null && remoteTrack != null) { track.copyPersonalFrom(remoteTrack) track.library_id = remoteTrack.library_id track.status = remoteTrack.status track.last_chapter_read = remoteTrack.last_chapter_read refresh(track) } else { - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - api.addLibManga(track) - update(track) + add(track) } - return track } override suspend fun search(query: String): List { @@ -133,8 +136,7 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) { override fun logout() { super.logout() - preferences.trackToken(this).set(null) - interceptor.clearOauth() + preferences.trackToken(this).delete() } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index feb5cee3b7..cf1bee44b5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -92,16 +92,20 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) { return api.updateLibManga(track) } + override suspend fun add(track: Track): Track { + track.score = DEFAULT_SCORE + track.status = DEFAULT_STATUS + return api.addLibManga(track, getUserId()) + } + override suspend fun bind(track: Track): Track { val remoteTrack = api.findLibManga(track, getUserId()) - if (remoteTrack != null) { + return if (remoteTrack != null) { track.copyPersonalFrom(remoteTrack) track.media_id = remoteTrack.media_id - return update(track) + update(track) } else { - track.score = DEFAULT_SCORE - track.status = DEFAULT_STATUS - return api.addLibManga(track, getUserId()) + add(track) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt index fe8bb99aef..f69d876486 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt @@ -36,6 +36,10 @@ class TrackSearch : Track { var start_date: String = "" + override var started_reading_date: Long = 0 + + override var finished_reading_date: Long = 0 + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || javaClass != other.javaClass) return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index c763106b35..6af160cdeb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -8,17 +8,24 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import timber.log.Timber +import uy.kohesive.injekt.injectLazy class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { - private val interceptor by lazy { MyAnimeListInterceptor(this) } + private val json: Json by injectLazy() + private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) } private val api by lazy { MyAnimeListApi(client, interceptor) } @StringRes override fun nameRes() = R.string.myanimelist + override val supportsReadingDates: Boolean = true + override fun getLogo() = R.drawable.ic_tracker_mal override fun getLogoColor() = Color.rgb(46, 81, 162) @@ -59,26 +66,26 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { return track.score.toInt().toString() } - override suspend fun update(track: Track): Track { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } + override suspend fun add(track: Track): Track { + track.status = READING + track.score = 0F + return api.updateItem(track) + } - return api.updateLibManga(track) + override suspend fun update(track: Track): Track { + return api.updateItem(track) } override suspend fun bind(track: Track): Track { - val remoteTrack = api.findLibManga(track) - if (remoteTrack != null) { + val remoteTrack = api.findListItem(track) + return 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 - return api.addLibManga(track) + add(track) } - return track + } override fun canRemoveFromService(): Boolean = true @@ -88,22 +95,33 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { } override suspend fun search(query: String): List { + if (query.startsWith(SEARCH_ID_PREFIX)) { + query.substringAfter(SEARCH_ID_PREFIX).toIntOrNull()?.let { id -> + return listOf(api.getMangaDetails(id)) + } + } + + if (query.startsWith(SEARCH_LIST_PREFIX)) { + query.substringAfter(SEARCH_LIST_PREFIX).let { title -> + return api.findListItems(title) + } + } + return api.search(query) } override suspend fun refresh(track: Track): Track { - val remoteTrack = api.getLibManga(track) - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - return track + return api.findListItem(track) ?: add(track) } - suspend fun login(csrfToken: String) = login("myanimelist", csrfToken) + override suspend fun login(username: String, password: String) = login(password) - override suspend fun login(username: String, password: String): Boolean { + suspend fun login(authCode: String): Boolean { return try { - saveCSRF(password) - saveCredentials(username, password) + val oauth = api.getAccessToken(authCode) + interceptor.setAuth(oauth) + val username = api.getCurrentUser() + saveCredentials(username, oauth.access_token) true } catch (e: Exception) { Timber.e(e) @@ -112,45 +130,37 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { } } - // Attempt to login again if cookies have been cleared but credentials are still filled - suspend fun ensureLoggedIn() { - if (isAuthorized) return - if (!isLogged) throw Exception("MAL Login Credentials not found") - } - override fun logout() { 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).getOrDefault() - - private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) - - private fun checkCookies(): Boolean { - var ckCount = 0 - val url = BASE_URL.toHttpUrlOrNull()!! - for (ck in networkService.cookieManager.get(url)) { - if (ck.name == USER_SESSION_COOKIE || ck.name == LOGGED_IN_COOKIE) ckCount++ + fun loadOAuth(): OAuth? { + return try { + json.decodeFromString(preferences.trackToken(this).get()) + } catch (e: Exception) { + null } - - return ckCount == 2 } - companion object { const val READING = 1 const val COMPLETED = 2 const val ON_HOLD = 3 const val DROPPED = 4 const val PLAN_TO_READ = 6 + const val REREADING = 7 const val DEFAULT_STATUS = READING const val DEFAULT_SCORE = 0 + private const val SEARCH_ID_PREFIX = "id:" + private const val SEARCH_LIST_PREFIX = "my:" + const val BASE_URL = "https://myanimelist.net" const val USER_SESSION_COOKIE = "MALSESSIONID" const val LOGGED_IN_COOKIE = "is_logged_in" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index 21ad64b35b..fe6d91338d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -1,5 +1,6 @@ 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 @@ -9,13 +10,26 @@ import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.consumeBody import eu.kanade.tachiyomi.network.consumeXmlBody +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.util.PkceUtil import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectText +import eu.kanade.tachiyomi.util.system.withIOContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import okhttp3.FormBody import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient +import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject @@ -24,52 +38,202 @@ import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.parser.Parser import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.* class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { private val authClient = client.newBuilder().addInterceptor(interceptor).build() - suspend fun search(query: String): List { - return withContext(Dispatchers.IO) { - if (query.startsWith(PREFIX_MY)) { - queryUsersList(query) - } else { - val realQuery = query.take(100) - val response = client.newCall(GET(searchUrl(realQuery))).await() - val matches = Jsoup.parse(response.consumeBody()) - .select("div.js-categories-seasonal.js-block-list.list").select("table") - .select("tbody").select("tr").drop(1) + suspend fun getAccessToken(authCode: String): OAuth { + return withIOContext { + 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() + .parseAs() + } + } - matches.filter { row -> row.select(TD)[2].text() != "Novel" }.map { row -> - TrackSearch.create(TrackManager.MYANIMELIST).apply { - title = row.searchTitle() - media_id = row.searchMediaId() - total_chapters = row.searchTotalChapters() - summary = row.searchSummary() - cover_url = row.searchCoverUrl() - tracking_url = mangaUrl(media_id) - publishing_status = row.searchPublishingStatus() - publishing_type = row.searchPublishingType() - start_date = row.searchStartDate() + suspend fun getCurrentUser(): String { + return withIOContext { + val request = Request.Builder() + .url("$baseApiUrl/users/@me") + .get() + .build() + authClient.newCall(request) + .await() + .parseAs() + .let { it["name"]!!.jsonPrimitive.content } + } + } + + suspend fun search(query: String): List { + return withIOContext { + val url = "$baseApiUrl/manga".toUri().buildUpon() + .appendQueryParameter("q", query) + .appendQueryParameter("nsfw", "true") + .build() + authClient.newCall(GET(url.toString())) + .await() + .parseAs() + .let { + it["data"]!!.jsonArray + .map { data -> data.jsonObject["node"]!!.jsonObject } + .map { node -> + val id = node["id"]!!.jsonPrimitive.int + async { getMangaDetails(id) } + } + .awaitAll() + .filter { trackSearch -> trackSearch.publishing_type != "novel" } } - }.toList() + } + } + + + suspend fun getMangaDetails(id: Int): TrackSearch { + return withIOContext { + val url = "$baseApiUrl/manga".toUri().buildUpon() + .appendPath(id.toString()) + .appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date") + .build() + authClient.newCall(GET(url.toString())) + .await() + .parseAs() + .let { + val obj = it.jsonObject + TrackSearch.create(TrackManager.MYANIMELIST).apply { + media_id = obj["id"]!!.jsonPrimitive.int + title = obj["title"]!!.jsonPrimitive.content + summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" + total_chapters = obj["num_chapters"]!!.jsonPrimitive.int + cover_url = obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content ?: "" + tracking_url = "https://myanimelist.net/manga/$media_id" + publishing_status = obj["status"]!!.jsonPrimitive.content.replace("_", " ") + publishing_type = obj["media_type"]!!.jsonPrimitive.content.replace("_", " ") + start_date = try { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(obj["start_date"]!!) + } catch (e: Exception) { + "" + } + } + } + } + } + + suspend fun updateItem(track: Track): Track { + return withIOContext { + val formBodyBuilder = FormBody.Builder() + .add("status", track.toMyAnimeListStatus() ?: "reading") + .add("is_rereading", (track.status == MyAnimeList.REREADING).toString()) + .add("score", track.score.toString()) + .add("num_chapters_read", track.last_chapter_read.toString()) + + val request = Request.Builder() + .url(mangaUrl(track.media_id).toString()) + .put(formBodyBuilder.build()) + .build() + authClient.newCall(request) + .await() + .parseAs() + .let { parseMangaItem(it, track) } + } + } + + + suspend fun findListItem(track: Track): Track? { + return withIOContext { + val uri = "$baseApiUrl/manga".toUri().buildUpon() + .appendPath(track.media_id.toString()) + .appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}") + .build() + authClient.newCall(GET(uri.toString())) + .await() + .parseAs() + .let { obj -> + track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int + obj.jsonObject["my_list_status"]?.jsonObject?.let { + parseMangaItem(it, track) + } + } + } + } + + suspend fun findListItems(query: String, offset: Int = 0): List { + return withIOContext { + val json = getListPage(offset) + val obj = json.jsonObject + + val matches = obj["data"]!!.jsonArray + .filter { + it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains( + query, + ignoreCase = true + ) + } + .map { + val id = it.jsonObject["node"]!!.jsonObject["id"]!!.jsonPrimitive.int + async { getMangaDetails(id) } + } + .awaitAll() + + // Check next page if there's more + if (!obj["paging"]!!.jsonObject["next"]?.jsonPrimitive?.contentOrNull.isNullOrBlank()) { + matches + findListItems(query, offset + listPaginationAmount) + } else { + matches } } } - private suspend fun queryUsersList(query: String): List { - val realQuery = query.removePrefix(PREFIX_MY).take(100) - return getList().filter { it.title.contains(realQuery, true) }.toList() + private suspend fun getListPage(offset: Int): JsonObject { + return withIOContext { + val urlBuilder = "$baseApiUrl/users/@me/mangalist".toUri().buildUpon() + .appendQueryParameter("fields", "list_status{start_date,finish_date}") + .appendQueryParameter("limit", listPaginationAmount.toString()) + if (offset > 0) { + urlBuilder.appendQueryParameter("offset", offset.toString()) + } + + val request = Request.Builder() + .url(urlBuilder.build().toString()) + .get() + .build() + authClient.newCall(request) + .await() + .parseAs() + } } - suspend fun addLibManga(track: Track): Track { - authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))).await() - return track + private fun parseMangaItem(response: JsonObject, track: Track): Track { + val obj = response.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() + } } - suspend fun updateLibManga(track: Track): Track { - authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await() - return track + private fun parseDate(isoDate: String): Long { + return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L + } + + private fun convertToIsoDate(epochTime: Long): String? { + if (epochTime == 0L) { + return "" + } + return try { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(epochTime) + } catch (e: Exception) { + null + } } suspend fun remove(track: Track): Boolean { @@ -82,152 +246,43 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI return false } - suspend fun findLibManga(track: Track): Track? { - return withContext(Dispatchers.IO) { - val response = authClient.newCall(GET(url = listEntryUrl(track.media_id))).await() - var remoteTrack: Track? = null - response.use { - if (it.priorResponse?.isRedirect != true) { - val trackForm = Jsoup.parse(it.consumeBody()) - - remoteTrack = Track.create(TrackManager.MYANIMELIST).apply { - last_chapter_read = - trackForm.select("#add_manga_num_read_chapters").`val`().toInt() - total_chapters = trackForm.select("#totalChap").text().toInt() - status = - trackForm.select("#add_manga_status > option[selected]").`val`().toInt() - score = trackForm.select("#add_manga_score > option[selected]").`val`() - .toFloatOrNull() ?: 0f - } - } - } - remoteTrack - } - } - - suspend fun getLibManga(track: Track): Track { - val result = findLibManga(track) - if (result == null) { - throw Exception("Could not find manga") - } else { - return result - } - } - - private suspend fun getList(): List { - val results = getListXml(getListUrl()).select("manga") - - return results.map { - TrackSearch.create(TrackManager.MYANIMELIST).apply { - title = it.selectText("manga_title")!! - media_id = it.selectInt("manga_mangadb_id") - last_chapter_read = it.selectInt("my_read_chapters") - status = getStatus(it.selectText("my_status")!!) - score = it.selectInt("my_score").toFloat() - total_chapters = it.selectInt("manga_chapters") - tracking_url = mangaUrl(media_id) - } - }.toList() - } - - private suspend fun getListUrl(): String { - return withContext(Dispatchers.IO) { - val response = - authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())).execute() - - baseUrl + Jsoup.parse(response.consumeBody()).select("div.goodresult").select("a") - .attr("href") - } - } - - private suspend fun getListXml(url: String): Document { - val response = authClient.newCall(GET(url)).await() - return Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser()) - } - - companion object { - const val CSRF = "csrf_token" - - const val baseUrl = "https://myanimelist.net" - private const val baseMangaUrl = "$baseUrl/manga/" - private const val baseModifyListUrl = "$baseUrl/ownlist/manga" - private const val PREFIX_MY = "my:" - private const val TD = "td" - - private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId - - fun loginUrl() = baseUrl.toUri().buildUpon().appendPath("login.php").build() - - private fun searchUrl(query: String): String { - val col = "c[]" - return baseUrl.toUri().buildUpon().appendPath("manga.php") - .appendQueryParameter("q", query).appendQueryParameter(col, "a") - .appendQueryParameter(col, "b").appendQueryParameter(col, "c") - .appendQueryParameter(col, "d").appendQueryParameter(col, "e") - .appendQueryParameter(col, "g").toString() - } - - private fun exportListUrl() = baseUrl.toUri().buildUpon().appendPath("panel.php") - .appendQueryParameter("go", "export").toString() - - private fun updateUrl() = - baseModifyListUrl.toUri().buildUpon().appendPath("edit.json").toString() - - private fun removeUrl(mediaId: Int) = baseModifyListUrl.toUri().buildUpon().appendPath(mediaId.toString()) + private fun removeUrl(mediaId: Int) = "$baseApiUrl/manga".toUri().buildUpon().appendPath(mediaId.toString()) .appendPath("delete").toString() - private fun addUrl() = - baseModifyListUrl.toUri().buildUpon().appendPath("add.json").toString() + companion object { + // Registered under jay's MAL account + private const val clientId = "8d3821c90edb495432a5ecb61de59200" - private fun listEntryUrl(mediaId: Int) = - baseModifyListUrl.toUri().buildUpon().appendPath(mediaId.toString()) - .appendPath("edit").toString() + private const val baseOAuthUrl = "https://myanimelist.net/v1/oauth2" + private const val baseApiUrl = "https://api.myanimelist.net/v2" - private fun loginPostBody(username: String, password: String, csrf: String): RequestBody { - return FormBody.Builder().add("user_name", username).add("password", password) - .add("cookie", "1").add("sublogin", "Login").add("submit", "1").add(CSRF, csrf) + private const val listPaginationAmount = 250 + + private var codeVerifier: String = "" + + fun authUrl(): Uri = "$baseOAuthUrl/authorize".toUri().buildUpon() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("code_challenge", getPkceChallengeCode()) + .appendQueryParameter("response_type", "code") .build() + + fun mangaUrl(id: Int): Uri = "$baseApiUrl/manga".toUri().buildUpon() + .appendPath(id.toString()) + .appendPath("my_list_status") + .build() + + fun refreshTokenRequest(refreshToken: String): Request { + val formBody: RequestBody = FormBody.Builder() + .add("client_id", clientId) + .add("refresh_token", refreshToken) + .add("grant_type", "refresh_token") + .build() + return POST("$baseOAuthUrl/token", body = formBody) } - private fun exportPostBody(): RequestBody { - return FormBody.Builder().add("type", "2").add("subexport", "Export My List").build() - } - - private fun mangaPostPayload(track: Track): RequestBody { - val body = JSONObject().put("manga_id", track.media_id).put("status", track.status) - .put("score", track.score).put("num_read_chapters", track.last_chapter_read) - - return body.toString() - .toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - } - - private fun Element.searchTitle() = select("strong").text()!! - - private fun Element.searchTotalChapters() = - if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt() - - private fun Element.searchCoverUrl() = - select("img").attr("data-src").split("\\?")[0].replace("/r/50x70/", "/") - - private fun Element.searchMediaId() = - select("div.picSurround").select("a").attr("id").replace("sarea", "").toInt() - - private fun Element.searchSummary() = select("div.pt4").first().ownText()!! - - private fun Element.searchPublishingStatus() = - if (select(TD).last().text() == "-") "Publishing" else "Finished" - - private fun Element.searchPublishingType() = select(TD)[2].text()!! - - private fun Element.searchStartDate() = select(TD)[6].text()!! - - private fun getStatus(status: String) = when (status) { - "Reading" -> 1 - "Completed" -> 2 - "On-Hold" -> 3 - "Dropped" -> 4 - "Plan to Read" -> 6 - else -> 1 + private fun getPkceChallengeCode(): String { + codeVerifier = PkceUtil.generateCodeVerifier() + return codeVerifier } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt index 03e3c6a95c..fb7038e9a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Request import okhttp3.RequestBody @@ -11,51 +13,57 @@ 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 { val scope = CoroutineScope(Job() + Dispatchers.Main) + private val json: Json by injectLazy() + + private var oauth: OAuth? = null + set(value) { + field = value?.copy(expires_in = System.currentTimeMillis() + (value.expires_in * 1000)) + } + override fun intercept(chain: Interceptor.Chain): Response { - runBlocking { - myanimelist.ensureLoggedIn() - } - val request = chain.request() - return chain.proceed(updateRequest(request)) - } + val originalRequest = chain.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 + 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())) + } } - request.newBuilder().post(updatedBody).build() - } ?: request - } - - private fun bodyToString(requestBody: RequestBody): String { - Buffer().use { - requestBody.writeTo(it) - return it.readUtf8() } + + // Throw on null auth. + if (oauth == null) { + throw Exception("No authentication token") + } + + // Add the authorization header to the original request. + val authRequest = originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .build() + + return chain.proceed(authRequest) } - private fun updateFormBody(requestBody: RequestBody): RequestBody { - val formString = bodyToString(requestBody) - - return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody( - requestBody.contentType() - ) - } - - private fun updateJsonBody(requestBody: RequestBody): RequestBody { - val jsonString = bodyToString(requestBody) - val newBody = JSONObject(jsonString) - .put(MyAnimeListApi.CSRF, myanimelist.getCSRF()) - - return newBody.toString().toRequestBody(requestBody.contentType()) + /** + * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token + * and the oauth object. + */ + fun setAuth(oauth: OAuth?) { + token = oauth?.access_token + this.oauth = oauth + myanimelist.saveOAuth(oauth) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt new file mode 100644 index 0000000000..ea8366716b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.data.track.myanimelist + +import eu.kanade.tachiyomi.data.database.models.Track + +fun Track.toMyAnimeListStatus() = when (status) { + MyAnimeList.READING -> "reading" + MyAnimeList.COMPLETED -> "completed" + MyAnimeList.ON_HOLD -> "on_hold" + MyAnimeList.DROPPED -> "dropped" + MyAnimeList.PLAN_TO_READ -> "plan_to_read" + MyAnimeList.REREADING -> "reading" + else -> null +} + +fun getStatus(status: String) = when (status) { + "reading" -> MyAnimeList.READING + "completed" -> MyAnimeList.COMPLETED + "on_hold" -> MyAnimeList.ON_HOLD + "dropped" -> MyAnimeList.DROPPED + "plan_to_read" -> MyAnimeList.PLAN_TO_READ + else -> MyAnimeList.READING +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/OAuth.kt new file mode 100644 index 0000000000..6f0a02d1ac --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/OAuth.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.data.track.myanimelist + +import kotlinx.serialization.Serializable + +@Serializable +data class OAuth( + val refresh_token: String, + val access_token: String, + val token_type: String, + val expires_in: Long +) { + + fun isExpired() = System.currentTimeMillis() > expires_in +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt index 82ebc9bbe8..432de40d01 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt @@ -70,20 +70,21 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) { return api.updateLibManga(track, getUsername()) } + override suspend fun add(track: Track): Track { + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + return api.addLibManga(track, getUsername()) + } override suspend fun bind(track: Track): Track { val remoteTrack = api.findLibManga(track, getUsername()) - if (remoteTrack != null) { + return if (remoteTrack != null) { track.copyPersonalFrom(remoteTrack) track.library_id = remoteTrack.library_id update(track) } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - return api.addLibManga(track, getUsername()) + add(track) } - return track } override suspend fun search(query: String) = api.search(query) @@ -130,7 +131,7 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) { override fun logout() { super.logout() - preferences.trackToken(this).set(null) + preferences.trackToken(this).delete() interceptor.newAuth(null) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 975ae6250b..5ce6f622a5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.network import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import okhttp3.Call import okhttp3.Callback import okhttp3.MediaType @@ -10,6 +12,8 @@ import okhttp3.Response import rx.Observable import rx.Producer import rx.Subscription +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.fullType import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -105,6 +109,15 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene return progressClient.newCall(request) } +inline fun Response.parseAs(): T { + // Avoiding Injekt.get() due to compiler issues + val json = Injekt.getInstance(fullType().type) + this.use { + val responseBody = it.body?.string().orEmpty() + return json.decodeFromString(responseBody) + } +} + fun MediaType.Companion.jsonType(): MediaType = "application/json; charset=utf-8".toMediaTypeOrNull()!! fun Response.consumeBody(): String? { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseThemedActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseThemedActivity.kt new file mode 100644 index 0000000000..fa9d257a07 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseThemedActivity.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.ui.base.activity + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.system.ThemeUtil +import uy.kohesive.injekt.injectLazy + +abstract class BaseThemedActivity : AppCompatActivity() { + + val preferences: PreferencesHelper by injectLazy() + + override fun onCreate(savedInstanceState: Bundle?) { + AppCompatDelegate.setDefaultNightMode(ThemeUtil.nightMode(preferences.theme())) + setTheme(ThemeUtil.theme(preferences.theme())) + + super.onCreate(savedInstanceState) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt index 4d8ea28218..b3cd669aee 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt @@ -46,5 +46,7 @@ class TrackAdapter(controller: OnClickListener) : RecyclerView.Adapter Unit + crossinline login: () -> Unit ): LoginPreference { return initThenAdd( - LoginPreference(context).apply { - key = Keys.trackUsername(service.id) - title = context.getString(service.nameRes()) - }, - block + LoginPreference(context).apply { + key = Keys.trackUsername(service.id) + title = context.getString(service.nameRes()) + }, + { + onClick { + if (service.isLogged) { + val dialog = TrackLogoutDialog(service) + dialog.targetController = this@SettingsTrackingController + dialog.showDialog(router) + } else { + login() + } + } + } ) } @@ -92,24 +89,6 @@ class SettingsTrackingController : updatePreference(trackManager.bangumi.id) } - private fun showDialog(trackService: TrackService, url: Uri? = null, userNameLabel: String? = null) { - if (trackService.isLogged) { - val dialog = TrackLogoutDialog(trackService) - dialog.targetController = this@SettingsTrackingController - dialog.showDialog(router) - } else if (url == null) { - val dialog = TrackLoginDialog(trackService, userNameLabel) - dialog.targetController = this@SettingsTrackingController - dialog.showDialog(router) - } else { - val tabsIntent = CustomTabsIntent.Builder() - .setToolbarColor(activity!!.getResourceColor(R.attr.colorPrimaryVariant)) - .build() - tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - tabsIntent.launchUrl(activity!!, url) - } - } - private fun updatePreference(id: Int) { val pref = findPreference(Keys.trackUsername(id)) as? LoginPreference pref?.notifyChanged() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt index cdee2846a0..e0c85741cb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt @@ -1,14 +1,17 @@ package eu.kanade.tachiyomi.ui.setting.track import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.Gravity.CENTER import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import android.widget.ProgressBar import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.system.launchIO import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -16,22 +19,13 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import uy.kohesive.injekt.injectLazy -class AnilistLoginActivity : AppCompatActivity() { - - private val trackManager: TrackManager by injectLazy() - - private val scope = CoroutineScope(Job() + Dispatchers.Main) - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - val view = ProgressBar(this) - setContentView(view, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER)) +class AnilistLoginActivity : BaseOAuthLoginActivity() { + override fun handleResult(data: Uri?) { val regex = "(?:access_token=)(.*?)(?:&)".toRegex() - val matchResult = regex.find(intent.data?.fragment.toString()) + val matchResult = regex.find(data?.fragment.toString()) if (matchResult?.groups?.get(1) != null) { - scope.launch { + lifecycleScope.launchIO { trackManager.aniList.login(matchResult.groups[1]!!.value) returnToSettings() } @@ -40,17 +34,4 @@ class AnilistLoginActivity : AppCompatActivity() { returnToSettings() } } - - override fun onDestroy() { - super.onDestroy() - scope.cancel() - } - - private fun returnToSettings() { - finish() - - val intent = Intent(this, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - startActivity(intent) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt index 2987245515..90b1aca5df 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt @@ -1,35 +1,29 @@ package eu.kanade.tachiyomi.ui.setting.track import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.Gravity.CENTER import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import android.widget.ProgressBar import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.system.launchIO import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import uy.kohesive.injekt.injectLazy -class BangumiLoginActivity : AppCompatActivity() { +class BangumiLoginActivity : BaseOAuthLoginActivity() { - private val trackManager: TrackManager by injectLazy() - - private val scope = CoroutineScope(Job() + Dispatchers.Main) - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - val view = ProgressBar(this) - setContentView(view, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER)) - - val code = intent.data?.getQueryParameter("code") + override fun handleResult(data: Uri?) { + val code = data?.getQueryParameter("code") if (code != null) { - scope.launch { + lifecycleScope.launchIO { trackManager.bangumi.login(code) returnToSettings() } @@ -38,12 +32,4 @@ class BangumiLoginActivity : AppCompatActivity() { returnToSettings() } } - - private fun returnToSettings() { - finish() - - val intent = Intent(this, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - startActivity(intent) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BaseOAuthLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BaseOAuthLoginActivity.kt new file mode 100644 index 0000000000..3ae2b6105f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BaseOAuthLoginActivity.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.ui.setting.track + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ProgressBar +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.activity.BaseThemedActivity +import eu.kanade.tachiyomi.ui.main.MainActivity +import uy.kohesive.injekt.injectLazy + +abstract class BaseOAuthLoginActivity : BaseThemedActivity() { + + internal val trackManager: TrackManager by injectLazy() + + abstract fun handleResult(data: Uri?) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val view = ProgressBar(this) + setContentView( + view, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.CENTER + ) + ) + + handleResult(intent.data) + } + + internal fun returnToSettings() { + val intent = Intent(this, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(intent) + finishAfterTransition() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/MyAnimeListLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/MyAnimeListLoginActivity.kt index bcec7b8b9a..8cb66799b8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/MyAnimeListLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/MyAnimeListLoginActivity.kt @@ -1,77 +1,21 @@ 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.data.track.TrackManager -import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi -import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.webview.BaseWebViewActivity -import eu.kanade.tachiyomi.util.system.WebViewClientCompat -import kotlinx.android.synthetic.main.webview_activity.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import uy.kohesive.injekt.injectLazy +import android.net.Uri +import androidx.lifecycle.lifecycleScope +import eu.kanade.tachiyomi.util.system.launchIO -class MyAnimeListLoginActivity : BaseWebViewActivity() { +class MyAnimeListLoginActivity : BaseOAuthLoginActivity() { - private val trackManager: TrackManager by injectLazy() - - private val scope = CoroutineScope(Job() + Dispatchers.Main) - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - title = "MyAnimeList" - - webview.webViewClient = object : WebViewClientCompat() { - override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { - view.loadUrl(url) - return true + override fun handleResult(data: Uri?) { + val code = data?.getQueryParameter("code") + if (code != null) { + lifecycleScope.launchIO { + trackManager.myAnimeList.login(code) + returnToSettings() } - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - - // Get CSRF token from HTML after post-login redirect - if (url == MyAnimeListApi.baseUrl + "/") { - view?.evaluateJavascript( - "(function(){return document.querySelector('meta[name=csrf_token]').getAttribute('content')})();" - ) { - scope.launch { - withContext(Dispatchers.IO) { trackManager.myAnimeList.login(it.replace("\"", "")) } - returnToSettings() - } - } - } - } - } - webview.loadUrl(MyAnimeListApi.loginUrl().toString()) - } - - override fun onDestroy() { - super.onDestroy() - scope.cancel() - } - - private fun returnToSettings() { - finish() - - val intent = Intent(this, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - startActivity(intent) - } - - companion object { - fun newIntent(context: Context): Intent { - val intent = Intent(context, MyAnimeListLoginActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - return intent + } else { + trackManager.myAnimeList.logout() + returnToSettings() } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt index 25fed5e2d8..ccbc444389 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt @@ -1,35 +1,28 @@ package eu.kanade.tachiyomi.ui.setting.track import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.Gravity.CENTER import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import android.widget.ProgressBar import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.system.launchIO import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import uy.kohesive.injekt.injectLazy +class ShikimoriLoginActivity : BaseOAuthLoginActivity() { -class ShikimoriLoginActivity : AppCompatActivity() { - - private val trackManager: TrackManager by injectLazy() - - private val scope = CoroutineScope(Job() + Dispatchers.Main) - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - val view = ProgressBar(this) - setContentView(view, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER)) - - val code = intent.data?.getQueryParameter("code") + override fun handleResult(data: Uri?) { + val code = data?.getQueryParameter("code") if (code != null) { - scope.launch { + lifecycleScope.launchIO { trackManager.shikimori.login(code) returnToSettings() } @@ -38,12 +31,4 @@ class ShikimoriLoginActivity : AppCompatActivity() { returnToSettings() } } - - private fun returnToSettings() { - finish() - - val intent = Intent(this, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - startActivity(intent) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt new file mode 100644 index 0000000000..e8d165f57e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.util + +import android.util.Base64 +import java.security.SecureRandom + +object PkceUtil { + + private const val PKCE_BASE64_ENCODE_SETTINGS = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE + + fun generateCodeVerifier(): String { + val codeVerifier = ByteArray(50) + SecureRandom().nextBytes(codeVerifier) + return Base64.encodeToString(codeVerifier, PKCE_BASE64_ENCODE_SETTINGS) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index 0cc4fc7dde..cd2bb232bf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -14,14 +14,17 @@ import android.content.res.Resources import android.graphics.drawable.Drawable import android.net.ConnectivityManager import android.net.NetworkCapabilities +import android.net.Uri import android.os.Build import android.os.PowerManager import android.view.View import android.widget.Toast import androidx.annotation.AttrRes +import androidx.annotation.ColorInt import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION import androidx.core.app.NotificationCompat @@ -235,14 +238,37 @@ fun Context.isServiceRunning(serviceClass: Class<*>): Boolean { .any { className == it.service.className } } +fun Context.openInBrowser(url: String, @ColorInt toolbarColor: Int? = null) { + this.openInBrowser(url.toUri(), toolbarColor) +} + +fun Context.openInBrowser(uri: Uri, @ColorInt toolbarColor: Int? = null) { + try { + val intent = CustomTabsIntent.Builder() + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(toolbarColor ?: getResourceColor(R.attr.colorPrimaryVariant)) + .build() + ) + .build() + intent.launchUrl(this, uri) + } catch (e: Exception) { + toast(e.message) + } +} + /** * Opens a URL in a custom tab. */ -fun Context.openInBrowser(url: String, forceBrowser: Boolean = false): Boolean { +fun Context.openInBrowser(url: String, forceBrowser: Boolean): Boolean { try { val parsedUrl = url.toUri() val intent = CustomTabsIntent.Builder() - .setToolbarColor(getResourceColor(R.attr.colorPrimaryVariant)) + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(getResourceColor(R.attr.colorPrimaryVariant)) + .build() + ) .build() if (forceBrowser) { val packages = getCustomTabsPackages().maxBy { it.preferredOrder } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt index b3ef44617f..b2ae8d5cc8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt @@ -6,9 +6,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) + +fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = + launch(Dispatchers.IO, block = block) + +suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) + +suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block) diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt index d4ab96e2c1..bb2753dc8a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.widget.preference import android.app.Dialog import android.os.Bundle import android.view.View +import androidx.annotation.StringRes import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.customview.customView import com.bluelinelabs.conductor.ControllerChangeHandler @@ -19,8 +20,8 @@ import rx.Subscription import uy.kohesive.injekt.injectLazy abstract class LoginDialogPreference( - private val usernameLabel: String? = null, - bundle: Bundle? = null + @StringRes private val usernameLabelRes: Int? = null, + bundle: Bundle? = null ) : DialogController(bundle) { @@ -48,8 +49,8 @@ abstract class LoginDialogPreference( fun onViewCreated(view: View) { v = view.apply { - if (!usernameLabel.isNullOrEmpty()) { - username_input.hint = usernameLabel + if (usernameLabelRes != null) { + username_input.hint = view.context.getString(usernameLabelRes) } login.setOnClickListener { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt index 1838e16b4c..3179650f5f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.widget.preference import android.os.Bundle import android.view.View +import androidx.annotation.StringRes import br.com.simplepass.loadingbutton.animatedDrawables.ProgressType import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackManager @@ -12,15 +13,15 @@ import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) : - LoginDialogPreference(usernameLabel, bundle) { +class TrackLoginDialog(@StringRes usernameLabelRes: Int? = null, bundle: Bundle? = null) : + LoginDialogPreference(usernameLabelRes, bundle) { private val service = Injekt.get().getService(args.getInt("key"))!! override var canLogout = true - constructor(service: TrackService, usernameLabel: String?) : - this(usernameLabel, Bundle().apply { putInt("key", service.id) }) + constructor(service: TrackService, @StringRes usernameLabelRes: Int?) : + this(usernameLabelRes, Bundle().apply { putInt("key", service.id) }) override fun setCredentialsOnView(view: View) = with(view) { val serviceName = context.getString(service.nameRes()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d119964c6a..a82c13fb08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -433,6 +433,7 @@ Kitsu Bangumi Shikimori + Please login to MAL again Select sources