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