Update to MAL's new API + Refactoring

To Arkon: no, I'm not hogging your clientId

Co-Authored-By: arkon <4098258+arkon@users.noreply.github.com>
This commit is contained in:
Jays2Kings 2021-03-21 22:22:46 -04:00
parent 77a0244373
commit 05e3437f49
31 changed files with 624 additions and 472 deletions

View File

@ -95,7 +95,18 @@
android:theme="@style/FilePickerTheme" />
<activity
android:name=".ui.setting.track.MyAnimeListLoginActivity"
android:configChanges="uiMode|orientation|screenSize" />
android:label="MyAnimeList">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="myanimelist-auth"
android:scheme="tachiyomij2k" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.AnilistLoginActivity"
android:label="Anilist">

View File

@ -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

View File

@ -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>()
trackManager.myAnimeList.logout()
if (trackManager.myAnimeList.isLogged) {
trackManager.myAnimeList.logout()
context.toast(R.string.myanimelist_relogin)
}
}
return true
}

View File

@ -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")

View File

@ -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

View File

@ -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)
}

View File

@ -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<TrackSearch> {
@ -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 {

View File

@ -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)
}
}

View File

@ -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

View File

@ -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<TrackSearch> {
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<OAuth>(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"

View File

@ -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<TrackSearch> {
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<JsonObject>()
.let { it["name"]!!.jsonPrimitive.content }
}
}
suspend fun search(query: String): List<TrackSearch> {
return withIOContext {
val url = "$baseApiUrl/manga".toUri().buildUpon()
.appendQueryParameter("q", query)
.appendQueryParameter("nsfw", "true")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.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<JsonObject>()
.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<JsonObject>()
.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<JsonObject>()
.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<TrackSearch> {
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<TrackSearch> {
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<TrackSearch> {
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
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 <reified T> Response.parseAs(): T {
// Avoiding Injekt.get<Json>() due to compiler issues
val json = Injekt.getInstance<Json>(fullType<Json>().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? {

View File

@ -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)
}
}

View File

@ -46,5 +46,7 @@ class TrackAdapter(controller: OnClickListener) : RecyclerView.Adapter<TrackHold
fun onChaptersClick(position: Int)
fun onScoreClick(position: Int)
fun onRemoveClick(position: Int)
fun onStartDateClick(position: Int)
fun onFinishDateClick(position: Int)
}
}

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.recently_read
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.injectLazy
import java.text.DateFormat

View File

@ -10,9 +10,10 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.ui.setting.track.MyAnimeListLoginActivity
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.widget.preference.LoginPreference
import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog
import eu.kanade.tachiyomi.widget.preference.TrackLogoutDialog
@ -38,49 +39,45 @@ class SettingsTrackingController :
titleRes = R.string.services
trackPreference(trackManager.myAnimeList) {
onClick {
if (trackManager.myAnimeList.isLogged) {
val dialog = TrackLogoutDialog(trackManager.myAnimeList)
dialog.targetController = this@SettingsTrackingController
dialog.showDialog(router)
} else {
startActivity(MyAnimeListLoginActivity.newIntent(context))
}
}
activity?.openInBrowser(MyAnimeListApi.authUrl(), trackManager.myAnimeList.getLogoColor())
}
trackPreference(trackManager.aniList) {
onClick {
showDialog(trackManager.aniList, AnilistApi.authUrl())
}
activity?.openInBrowser(AnilistApi.authUrl(), trackManager.aniList.getLogoColor())
}
trackPreference(trackManager.kitsu) {
onClick {
showDialog(trackManager.kitsu, userNameLabel = context.getString(R.string.email))
}
val dialog = TrackLoginDialog(trackManager.kitsu, R.string.email)
dialog.targetController = this@SettingsTrackingController
dialog.showDialog(router)
}
trackPreference(trackManager.shikimori) {
onClick {
showDialog(trackManager.shikimori, ShikimoriApi.authUrl())
}
activity?.openInBrowser(ShikimoriApi.authUrl(), trackManager.shikimori.getLogoColor())
}
trackPreference(trackManager.bangumi) {
onClick {
showDialog(trackManager.bangumi, BangumiApi.authUrl())
}
activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor())
}
}
}
inline fun PreferenceScreen.trackPreference(
private inline fun PreferenceScreen.trackPreference(
service: TrackService,
block: (@DSL LoginPreference).() -> 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()

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.util
import android.util.Base64
import java.security.SecureRandom
object PkceUtil {
private const val PKCE_BASE64_ENCODE_SETTINGS = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
fun generateCodeVerifier(): String {
val codeVerifier = ByteArray(50)
SecureRandom().nextBytes(codeVerifier)
return Base64.encodeToString(codeVerifier, PKCE_BASE64_ENCODE_SETTINGS)
}
}

View File

@ -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 }

View File

@ -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 <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block)

View File

@ -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 {

View File

@ -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<TrackManager>().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())

View File

@ -433,6 +433,7 @@
<string name="kitsu" translatable="false">Kitsu</string>
<string name="bangumi" translatable="false">Bangumi</string>
<string name="shikimori" translatable="false">Shikimori</string>
<string name="myanimelist_relogin">Please login to MAL again</string>
<!-- Migration -->
<string name="select_sources">Select sources</string>