diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d497c2b7f..c1600907c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -31,7 +31,7 @@ android {
applicationId = "eu.kanade.tachiyomi.sy"
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
- versionCode = 44
+ versionCode = 45
versionName = "1.8.5"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e087f9843..0faef9846 100755
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -381,6 +381,22 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt
index 168763008..3709a2dfe 100644
--- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt
+++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt
@@ -2,25 +2,15 @@ package eu.kanade.presentation.more.settings.screen
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Close
-import androidx.compose.material.icons.outlined.Visibility
-import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -35,15 +25,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.input.TextFieldValue
-import androidx.compose.ui.text.input.VisualTransformation
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.DialogProperties
import eu.kanade.domain.UnsortedPreferences
import eu.kanade.domain.source.service.SourcePreferences
+import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.presentation.more.settings.Preference
+import eu.kanade.presentation.util.collectAsState
import eu.kanade.presentation.util.padding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
@@ -51,8 +37,9 @@ import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
+import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
-import exh.log.xLogW
+import exh.md.utils.MdConstants
import exh.md.utils.MdUtil
import logcat.LogPriority
import uy.kohesive.injekt.Injekt
@@ -71,134 +58,17 @@ object SettingsMangadexScreen : SearchableSettings {
override fun getPreferences(): List {
val sourcePreferences: SourcePreferences = remember { Injekt.get() }
val unsortedPreferences: UnsortedPreferences = remember { Injekt.get() }
+ val trackPreferences: TrackPreferences = remember { Injekt.get() }
val mdex = remember { MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences) } ?: return emptyList()
return listOf(
- loginPreference(mdex),
+ loginPreference(mdex, trackPreferences),
preferredMangaDexId(unsortedPreferences, sourcePreferences),
syncMangaDexIntoThis(unsortedPreferences),
syncLibraryToMangaDex(),
)
}
- @Composable
- fun LoginDialog(
- mdex: MangaDex,
- onDismissRequest: () -> Unit,
- onLoginSuccess: () -> Unit,
- ) {
- val context = LocalContext.current
- val scope = rememberCoroutineScope()
-
- var username by remember { mutableStateOf(TextFieldValue(mdex.getUsername())) }
- var password by remember { mutableStateOf(TextFieldValue(mdex.getPassword())) }
- var processing by remember { mutableStateOf(false) }
- var inputError by remember { mutableStateOf(false) }
-
- AlertDialog(
- onDismissRequest = onDismissRequest,
- title = {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = stringResource(R.string.login_title, mdex.name),
- modifier = Modifier.weight(1f),
- )
- IconButton(onClick = onDismissRequest) {
- Icon(
- imageVector = Icons.Outlined.Close,
- contentDescription = stringResource(R.string.action_close),
- )
- }
- }
- },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = username,
- onValueChange = { username = it },
- label = { Text(text = stringResource(R.string.username)) },
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
- singleLine = true,
- isError = inputError && username.text.isEmpty(),
- )
-
- var hidePassword by remember { mutableStateOf(true) }
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = password,
- onValueChange = { password = it },
- label = { Text(text = stringResource(R.string.password)) },
- trailingIcon = {
- IconButton(onClick = { hidePassword = !hidePassword }) {
- Icon(
- imageVector = if (hidePassword) {
- Icons.Outlined.Visibility
- } else {
- Icons.Outlined.VisibilityOff
- },
- contentDescription = null,
- )
- }
- },
- visualTransformation = if (hidePassword) {
- PasswordVisualTransformation()
- } else {
- VisualTransformation.None
- },
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
- singleLine = true,
- isError = inputError && password.text.isEmpty(),
- )
- }
- },
- confirmButton = {
- Button(
- modifier = Modifier.fillMaxWidth(),
- enabled = !processing,
- onClick = {
- if (username.text.isEmpty() || password.text.isEmpty()) {
- inputError = true
- return@Button
- }
- scope.launchIO {
- try {
- inputError = false
- processing = true
- val result = mdex.login(
- username = username.text,
- password = password.text,
- twoFactorCode = null,
- )
- if (result) {
- onDismissRequest()
- onLoginSuccess()
- withUIContext {
- context.toast(R.string.login_success)
- }
- }
- } catch (e: Exception) {
- xLogW("Login to Mangadex error", e)
- withUIContext {
- e.message?.let { context.toast(it) }
- }
- } finally {
- processing = false
- }
- }
- },
- ) {
- val id = if (processing) R.string.loading else R.string.login
- Text(text = stringResource(id))
- }
- },
- properties = DialogProperties(
- dismissOnBackPress = !processing,
- dismissOnClickOutside = !processing,
- ),
- )
- }
-
@Composable
fun LogoutDialog(
onDismissRequest: () -> Unit,
@@ -223,18 +93,10 @@ object SettingsMangadexScreen : SearchableSettings {
}
@Composable
- fun loginPreference(mdex: MangaDex): Preference.PreferenceItem.MangaDexPreference {
+ fun loginPreference(mdex: MangaDex, trackPreferences: TrackPreferences): Preference.PreferenceItem.MangaDexPreference {
val context = LocalContext.current
val scope = rememberCoroutineScope()
- var loggedIn by remember { mutableStateOf(mdex.isLogged()) }
- var loginDialogOpen by remember { mutableStateOf(false) }
- if (loginDialogOpen) {
- LoginDialog(
- mdex = mdex,
- onDismissRequest = { loginDialogOpen = false },
- onLoginSuccess = { loggedIn = true },
- )
- }
+ val loggedIn by remember { trackPreferences.trackToken(mdex.mdList) }.collectAsState()
var logoutDialogOpen by remember { mutableStateOf(false) }
if (logoutDialogOpen) {
LogoutDialog(
@@ -244,7 +106,6 @@ object SettingsMangadexScreen : SearchableSettings {
scope.launchIO {
try {
if (mdex.logout()) {
- loggedIn = false
withUIContext {
context.toast(R.string.logout_success)
}
@@ -265,9 +126,12 @@ object SettingsMangadexScreen : SearchableSettings {
}
return Preference.PreferenceItem.MangaDexPreference(
title = mdex.name + " Login",
- loggedIn = loggedIn,
+ loggedIn = loggedIn.isNotEmpty(),
login = {
- loginDialogOpen = true
+ context.openInBrowser(
+ MdConstants.Login.authUrl(MdUtil.getPkceChallengeCode()),
+ forceDefaultBrowser = true,
+ )
},
logout = {
logoutDialogOpen = true
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt
index ed3e46d48..199984fba 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt
@@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.awaitSingle
import eu.kanade.tachiyomi.util.lang.runAsObservable
import eu.kanade.tachiyomi.util.lang.withIOContext
+import exh.md.network.MangaDexAuthInterceptor
import exh.md.utils.FollowStatus
import exh.md.utils.MdUtil
import uy.kohesive.injekt.Injekt
@@ -23,6 +24,8 @@ class MdList(private val context: Context, id: Long) : TrackService(id) {
private val mdex by lazy { MdUtil.getEnabledMangaDex(Injekt.get()) }
+ val interceptor = MangaDexAuthInterceptor(trackPreferences, this)
+
@StringRes
override fun nameRes(): Int = R.string.mdlist
@@ -157,5 +160,8 @@ class MdList(private val context: Context, id: Long) : TrackService(id) {
trackPreferences.trackToken(this).delete()
}
+ override val isLogged: Boolean
+ get() = trackPreferences.trackToken(this).get().isNotEmpty()
+
class MangaDexNotFoundException : Exception("Mangadex not enabled")
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt
index 8d6605399..f2e454208 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt
@@ -34,7 +34,6 @@ import exh.md.handlers.MangaPlusHandler
import exh.md.handlers.PageHandler
import exh.md.handlers.SimilarHandler
import exh.md.network.MangaDexLoginHelper
-import exh.md.network.TokenAuthenticator
import exh.md.service.MangaDexAuthService
import exh.md.service.MangaDexService
import exh.md.service.SimilarService
@@ -76,14 +75,10 @@ class MangaDex(delegate: HttpSource, val context: Context) :
context.getSharedPreferences("source_$id", 0x0000)
}
- private val mangadexAuthServiceLazy = lazy { MangaDexAuthService(baseHttpClient, headers, trackPreferences, mdList) }
-
- private val loginHelper = MangaDexLoginHelper(mangadexAuthServiceLazy, trackPreferences, mdList)
+ private val loginHelper = MangaDexLoginHelper(network.client, trackPreferences, mdList, mdList.interceptor)
override val baseHttpClient: OkHttpClient = delegate.client.newBuilder()
- .authenticator(
- TokenAuthenticator(loginHelper),
- )
+ .addInterceptor(mdList.interceptor)
.build()
private fun dataSaver() = sourcePreferences.getBoolean(getDataSaverPreferenceKey(mdLang.lang), false)
@@ -94,7 +89,9 @@ class MangaDex(delegate: HttpSource, val context: Context) :
private val mangadexService by lazy {
MangaDexService(client)
}
- private val mangadexAuthService by mangadexAuthServiceLazy
+ private val mangadexAuthService by lazy {
+ MangaDexAuthService(baseHttpClient, headers)
+ }
private val similarService by lazy {
SimilarService(client)
}
@@ -232,24 +229,12 @@ class MangaDex(delegate: HttpSource, val context: Context) :
return mdList.getPassword()
}
- override suspend fun login(
- username: String,
- password: String,
- twoFactorCode: String?,
- ): Boolean {
- val result = loginHelper.login(username, password)
- return if (result) {
- mdList.saveCredentials(username, password)
- true
- } else {
- false
- }
+ override suspend fun login(authCode: String): Boolean {
+ return loginHelper.login(authCode)
}
override suspend fun logout(): Boolean {
- loginHelper.logout()
- mdList.logout()
- return true
+ return loginHelper.logout()
}
// FollowsSource methods
diff --git a/app/src/main/java/exh/EXHMigrations.kt b/app/src/main/java/exh/EXHMigrations.kt
index d7e1cae40..cf81a6dcc 100644
--- a/app/src/main/java/exh/EXHMigrations.kt
+++ b/app/src/main/java/exh/EXHMigrations.kt
@@ -510,6 +510,11 @@ object EXHMigrations {
}
}
}
+ if (oldVersion under 45) {
+ // Force MangaDex log out due to login flow change
+ val trackManager = Injekt.get()
+ trackManager.mdList.logout()
+ }
// if (oldVersion under 1) { } (1 is current release version)
// do stuff here when releasing changed crap
diff --git a/app/src/main/java/exh/md/MangaDexLoginActivity.kt b/app/src/main/java/exh/md/MangaDexLoginActivity.kt
new file mode 100644
index 000000000..28f540209
--- /dev/null
+++ b/app/src/main/java/exh/md/MangaDexLoginActivity.kt
@@ -0,0 +1,27 @@
+package exh.md
+
+import android.net.Uri
+import androidx.lifecycle.lifecycleScope
+import eu.kanade.tachiyomi.ui.setting.track.BaseOAuthLoginActivity
+import eu.kanade.tachiyomi.util.lang.launchIO
+import exh.md.utils.MdUtil
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class MangaDexLoginActivity : BaseOAuthLoginActivity() {
+
+ override fun handleResult(data: Uri?) {
+ val code = data?.getQueryParameter("code")
+ if (code != null) {
+ lifecycleScope.launchIO {
+ MdUtil.getEnabledMangaDex(Injekt.get())?.login(code)
+ returnToSettings()
+ }
+ } else {
+ lifecycleScope.launchIO {
+ MdUtil.getEnabledMangaDex(Injekt.get())?.logout()
+ returnToSettings()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/exh/md/dto/AuthDto.kt b/app/src/main/java/exh/md/dto/AuthDto.kt
deleted file mode 100644
index 85789b371..000000000
--- a/app/src/main/java/exh/md/dto/AuthDto.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package exh.md.dto
-
-import kotlinx.serialization.Serializable
-
-/**
- * Login Request object for Dex Api
- */
-@Serializable
-data class LoginRequestDto(val username: String, val password: String)
-
-/**
- * Response after login
- */
-@Serializable
-data class LoginResponseDto(val result: String, val token: LoginBodyTokenDto)
-
-/**
- * Tokens for the logins
- */
-@Serializable
-data class LoginBodyTokenDto(val session: String, val refresh: String)
-
-/**
- * Response after logout
- */
-@Serializable
-data class LogoutDto(val result: String)
-
-/**
- * Check if session token is valid
- */
-@Serializable
-data class CheckTokenDto(val isAuthenticated: Boolean)
-
-/**
- * Request to refresh token
- */
-@Serializable
-data class RefreshTokenDto(val token: String)
diff --git a/app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt b/app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt
new file mode 100644
index 000000000..4e6d87a99
--- /dev/null
+++ b/app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt
@@ -0,0 +1,96 @@
+package exh.md.network
+
+import eu.kanade.domain.track.service.TrackPreferences
+import eu.kanade.tachiyomi.data.track.mdlist.MdList
+import eu.kanade.tachiyomi.data.track.myanimelist.OAuth
+import eu.kanade.tachiyomi.data.track.myanimelist.isExpired
+import eu.kanade.tachiyomi.network.parseAs
+import exh.md.utils.MdUtil
+import exh.util.nullIfBlank
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.io.IOException
+
+class MangaDexAuthInterceptor(
+ private val trackPreferences: TrackPreferences,
+ private val mdList: MdList,
+) : Interceptor {
+
+ var token = trackPreferences.trackToken(mdList).get().nullIfBlank()
+
+ private var oauth: OAuth? = null
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ if (token.isNullOrEmpty()) {
+ return chain.proceed(originalRequest)
+ }
+ if (oauth == null) {
+ oauth = MdUtil.loadOAuth(trackPreferences, mdList)
+ }
+ // Refresh access token if expired
+ if (oauth != null && oauth!!.isExpired()) {
+ setAuth(refreshToken(chain))
+ }
+
+ if (oauth == null) {
+ throw IOException("No authentication token")
+ }
+
+ // Add the authorization header to the original request
+ val authRequest = originalRequest.newBuilder()
+ .addHeader("Authorization", "Bearer ${oauth!!.access_token}")
+ .build()
+
+ val response = chain.proceed(authRequest)
+ val tokenIsExpired = response.headers["www-authenticate"]
+ ?.contains("The access token expired") ?: false
+
+ // Retry the request once with a new token in case it was not already refreshed
+ // by the is expired check before.
+ if (response.code == 401 && tokenIsExpired) {
+ response.close()
+
+ val newToken = refreshToken(chain)
+ setAuth(newToken)
+
+ val newRequest = originalRequest.newBuilder()
+ .addHeader("Authorization", "Bearer ${newToken.access_token}")
+ .build()
+
+ return chain.proceed(newRequest)
+ }
+
+ return response
+ }
+
+ /**
+ * Called when the user authenticates with MangaDex for the first time. Sets the refresh token
+ * and the oauth object.
+ */
+ fun setAuth(oauth: OAuth?) {
+ token = oauth?.access_token
+ this.oauth = oauth
+ MdUtil.saveOAuth(trackPreferences, mdList, oauth)
+ }
+
+ private fun refreshToken(chain: Interceptor.Chain): OAuth {
+ val newOauth = runCatching {
+ val oauthResponse = chain.proceed(MdUtil.refreshTokenRequest(oauth!!))
+
+ if (oauthResponse.isSuccessful) {
+ oauthResponse.parseAs()
+ } else {
+ oauthResponse.close()
+ null
+ }
+ }
+
+ if (newOauth.getOrNull() == null) {
+ throw IOException("Failed to refresh the access token", newOauth.exceptionOrNull())
+ }
+
+ return newOauth.getOrNull()!!
+ }
+}
diff --git a/app/src/main/java/exh/md/network/MangaDexLoginHelper.kt b/app/src/main/java/exh/md/network/MangaDexLoginHelper.kt
index ff9e3e524..3404cd1a2 100644
--- a/app/src/main/java/exh/md/network/MangaDexLoginHelper.kt
+++ b/app/src/main/java/exh/md/network/MangaDexLoginHelper.kt
@@ -2,88 +2,88 @@ package exh.md.network
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.track.mdlist.MdList
-import eu.kanade.tachiyomi.util.lang.withIOContext
-import exh.log.xLogE
-import exh.log.xLogI
-import exh.md.dto.LoginRequestDto
-import exh.md.dto.RefreshTokenDto
-import exh.md.service.MangaDexAuthService
+import eu.kanade.tachiyomi.data.track.myanimelist.OAuth
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.await
+import eu.kanade.tachiyomi.network.parseAs
+import eu.kanade.tachiyomi.util.system.logcat
+import exh.md.utils.MdApi
+import exh.md.utils.MdConstants
import exh.md.utils.MdUtil
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.withTimeoutOrNull
-import kotlin.time.Duration.Companion.seconds
+import logcat.LogPriority
+import okhttp3.FormBody
+import okhttp3.Headers
+import okhttp3.OkHttpClient
-class MangaDexLoginHelper(authServiceLazy: Lazy, val preferences: TrackPreferences, val mdList: MdList) {
- private val authService by authServiceLazy
- suspend fun isAuthenticated(): Boolean {
- return runCatching { authService.checkToken().isAuthenticated }
- .getOrElse { e ->
- xLogE("error authenticating", e)
+class MangaDexLoginHelper(
+ private val client: OkHttpClient,
+ private val preferences: TrackPreferences,
+ private val mdList: MdList,
+ private val mangaDexAuthInterceptor: MangaDexAuthInterceptor,
+) {
+
+ /**
+ * Login given the generated authorization code
+ */
+ suspend fun login(authorizationCode: String): Boolean {
+ val loginFormBody = FormBody.Builder()
+ .add("client_id", MdConstants.Login.clientId)
+ .add("grant_type", MdConstants.Login.authorizationCode)
+ .add("code", authorizationCode)
+ .add("code_verifier", MdUtil.getPkceChallengeCode())
+ .add("redirect_uri", MdConstants.Login.redirectUri)
+ .build()
+
+ val error = kotlin.runCatching {
+ val data = client.newCall(POST(MdApi.baseAuthUrl + MdApi.token, body = loginFormBody)).await().parseAs()
+ mangaDexAuthInterceptor.setAuth(data)
+ }.exceptionOrNull()
+
+ return when (error == null) {
+ true -> true
+ false -> {
+ logcat(LogPriority.ERROR, error) { "Error logging in" }
+ mdList.logout()
false
}
+ }
}
- suspend fun refreshToken(): Boolean {
- val refreshToken = MdUtil.refreshToken(preferences, mdList)
- if (refreshToken.isNullOrEmpty()) {
- return false
- }
- val refresh = runCatching {
- val jsonResponse = authService.refreshToken(RefreshTokenDto(refreshToken))
- MdUtil.updateLoginToken(jsonResponse.token, preferences, mdList)
+ suspend fun logout(): Boolean {
+ val oauth = MdUtil.loadOAuth(preferences, mdList)
+ val sessionToken = oauth?.access_token
+ val refreshToken = oauth?.refresh_token
+ if (refreshToken.isNullOrEmpty() || sessionToken.isNullOrEmpty()) {
+ mdList.logout()
+ return true
}
- val e = refresh.exceptionOrNull()
- if (e is CancellationException) throw e
+ val formBody = FormBody.Builder()
+ .add("client_id", MdConstants.Login.clientId)
+ .add("refresh_token", refreshToken)
+ .add("redirect_uri", MdConstants.Login.redirectUri)
+ .build()
- return refresh.isSuccess
- }
+ val error = kotlin.runCatching {
+ client.newCall(
+ POST(
+ url = MdApi.baseAuthUrl + MdApi.logout,
+ headers = Headers.Builder().add("Authorization", "Bearer $sessionToken")
+ .build(),
+ body = formBody,
+ ),
+ ).await()
+ mdList.logout()
+ }.exceptionOrNull()
- suspend fun login(
- username: String,
- password: String,
- ): Boolean {
- return withIOContext {
- val loginRequest = LoginRequestDto(username, password)
- val loginResult = runCatching { authService.login(loginRequest) }
- .onFailure { this@MangaDexLoginHelper.xLogE("Error logging in", it) }
-
- val e = loginResult.exceptionOrNull()
- if (e is CancellationException) throw e
-
- val loginResponseDto = loginResult.getOrNull()
- if (loginResponseDto != null) {
- MdUtil.updateLoginToken(
- loginResponseDto.token,
- preferences,
- mdList,
- )
+ return when (error == null) {
+ true -> {
+ mangaDexAuthInterceptor.setAuth(null)
true
- } else {
- false
}
- }
- }
-
- suspend fun login(): Boolean {
- val username = preferences.trackUsername(mdList).get()
- val password = preferences.trackPassword(mdList).get()
- if (username.isBlank() || password.isBlank()) {
- xLogI("No username or password stored, can't login")
- return false
- }
- return login(username, password)
- }
-
- suspend fun logout() {
- return withIOContext {
- withTimeoutOrNull(10.seconds) {
- runCatching {
- authService.logout()
- }.onFailure {
- if (it is CancellationException) throw it
- this@MangaDexLoginHelper.xLogE("Error logging out", it)
- }
+ false -> {
+ logcat(LogPriority.ERROR, error) { "Error logging out" }
+ false
}
}
}
diff --git a/app/src/main/java/exh/md/network/TokenAuthenticator.kt b/app/src/main/java/exh/md/network/TokenAuthenticator.kt
deleted file mode 100644
index 69d6619c0..000000000
--- a/app/src/main/java/exh/md/network/TokenAuthenticator.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package exh.md.network
-
-import exh.log.xLogI
-import exh.md.utils.MdUtil
-import kotlinx.coroutines.runBlocking
-import okhttp3.Authenticator
-import okhttp3.Request
-import okhttp3.Response
-import okhttp3.Route
-import java.io.IOException
-
-class TokenAuthenticator(private val loginHelper: MangaDexLoginHelper) : Authenticator {
- override fun authenticate(route: Route?, response: Response): Request? {
- xLogI("Detected Auth error ${response.code} on ${response.request.url}")
-
- val token = try {
- refreshToken(loginHelper)
- } catch (e: Exception) {
- throw IOException(e)
- }
- return if (token != null) {
- response.request.newBuilder().header("Authorization", token).build()
- } else {
- null
- }
- }
-
- @Synchronized
- fun refreshToken(loginHelper: MangaDexLoginHelper): String? {
- var validated = false
-
- runBlocking {
- val checkToken = loginHelper.isAuthenticated()
-
- if (checkToken) {
- this@TokenAuthenticator.xLogI("Token is valid, other thread must have refreshed it")
- validated = true
- }
- if (validated.not()) {
- this@TokenAuthenticator.xLogI("Token is invalid trying to refresh")
- validated = loginHelper.refreshToken()
- }
-
- if (validated.not()) {
- this@TokenAuthenticator.xLogI("Did not refresh token, trying to login")
- validated = loginHelper.login()
- }
- }
- return when {
- validated -> "Bearer ${MdUtil.sessionToken(loginHelper.preferences, loginHelper.mdList)!!}"
- else -> null
- }
- }
-}
diff --git a/app/src/main/java/exh/md/service/MangaDexAuthService.kt b/app/src/main/java/exh/md/service/MangaDexAuthService.kt
index 155486bcf..d95d2001c 100644
--- a/app/src/main/java/exh/md/service/MangaDexAuthService.kt
+++ b/app/src/main/java/exh/md/service/MangaDexAuthService.kt
@@ -1,27 +1,19 @@
package exh.md.service
-import eu.kanade.domain.track.service.TrackPreferences
-import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
-import exh.md.dto.CheckTokenDto
-import exh.md.dto.LoginRequestDto
-import exh.md.dto.LoginResponseDto
-import exh.md.dto.LogoutDto
import exh.md.dto.MangaListDto
import exh.md.dto.RatingDto
import exh.md.dto.RatingResponseDto
import exh.md.dto.ReadChapterDto
import exh.md.dto.ReadingStatusDto
import exh.md.dto.ReadingStatusMapDto
-import exh.md.dto.RefreshTokenDto
import exh.md.dto.ResultDto
import exh.md.utils.MdApi
import exh.md.utils.MdConstants
import exh.md.utils.MdUtil
-import okhttp3.Authenticator
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -31,65 +23,13 @@ import okhttp3.Request
class MangaDexAuthService(
private val client: OkHttpClient,
private val headers: Headers,
- private val preferences: TrackPreferences,
- private val mdList: MdList,
) {
- private val noAuthenticatorClient = client.newBuilder()
- .authenticator(Authenticator.NONE)
- .build()
-
- fun getHeaders() = MdUtil.getAuthHeaders(
- headers,
- preferences,
- mdList,
- )
-
- suspend fun login(request: LoginRequestDto): LoginResponseDto {
- return noAuthenticatorClient.newCall(
- POST(
- MdApi.login,
- body = MdUtil.encodeToBody(request),
- cache = CacheControl.FORCE_NETWORK,
- ),
- ).await().parseAs(MdUtil.jsonParser)
- }
-
- suspend fun logout(): LogoutDto {
- return client.newCall(
- POST(
- MdApi.logout,
- getHeaders(),
- cache = CacheControl.FORCE_NETWORK,
- ),
- ).await().parseAs(MdUtil.jsonParser)
- }
-
- suspend fun checkToken(): CheckTokenDto {
- return noAuthenticatorClient.newCall(
- GET(
- MdApi.checkToken,
- getHeaders(),
- CacheControl.FORCE_NETWORK,
- ),
- ).await().parseAs(MdUtil.jsonParser)
- }
-
- suspend fun refreshToken(request: RefreshTokenDto): LoginResponseDto {
- return noAuthenticatorClient.newCall(
- POST(
- MdApi.refreshToken,
- getHeaders(),
- body = MdUtil.encodeToBody(request),
- cache = CacheControl.FORCE_NETWORK,
- ),
- ).await().parseAs(MdUtil.jsonParser)
- }
suspend fun userFollowList(offset: Int): MangaListDto {
return client.newCall(
GET(
"${MdApi.userFollows}?limit=100&offset=$offset&includes[]=${MdConstants.Types.coverArt}",
- getHeaders(),
+ headers,
CacheControl.FORCE_NETWORK,
),
).await().parseAs(MdUtil.jsonParser)
@@ -99,7 +39,7 @@ class MangaDexAuthService(
return client.newCall(
GET(
"${MdApi.manga}/$mangaId/status",
- getHeaders(),
+ headers,
CacheControl.FORCE_NETWORK,
),
).await().parseAs(MdUtil.jsonParser)
@@ -109,7 +49,7 @@ class MangaDexAuthService(
return client.newCall(
GET(
"${MdApi.manga}/$mangaId/read",
- getHeaders(),
+ headers,
CacheControl.FORCE_NETWORK,
),
).await().parseAs(MdUtil.jsonParser)
@@ -122,7 +62,7 @@ class MangaDexAuthService(
return client.newCall(
POST(
"${MdApi.manga}/$mangaId/status",
- getHeaders(),
+ headers,
body = MdUtil.encodeToBody(readingStatusDto),
cache = CacheControl.FORCE_NETWORK,
),
@@ -133,7 +73,7 @@ class MangaDexAuthService(
return client.newCall(
GET(
MdApi.readingStatusForAllManga,
- getHeaders(),
+ headers,
cache = CacheControl.FORCE_NETWORK,
),
).await().parseAs(MdUtil.jsonParser)
@@ -143,7 +83,7 @@ class MangaDexAuthService(
return client.newCall(
GET(
"${MdApi.readingStatusForAllManga}?status=$status",
- getHeaders(),
+ headers,
cache = CacheControl.FORCE_NETWORK,
),
).await().parseAs(MdUtil.jsonParser)
@@ -153,7 +93,7 @@ class MangaDexAuthService(
return client.newCall(
POST(
"${MdApi.chapter}/$chapterId/read",
- getHeaders(),
+ headers,
cache = CacheControl.FORCE_NETWORK,
),
).await().parseAs(MdUtil.jsonParser)
@@ -164,7 +104,7 @@ class MangaDexAuthService(
Request.Builder()
.url("${MdApi.chapter}/$chapterId/read")
.delete()
- .headers(getHeaders())
+ .headers(headers)
.cacheControl(CacheControl.FORCE_NETWORK)
.build(),
).await().parseAs(MdUtil.jsonParser)
@@ -174,7 +114,7 @@ class MangaDexAuthService(
return client.newCall(
POST(
"${MdApi.manga}/$mangaId/follow",
- getHeaders(),
+ headers,
cache = CacheControl.FORCE_NETWORK,
),
).await().parseAs(MdUtil.jsonParser)
@@ -185,7 +125,7 @@ class MangaDexAuthService(
Request.Builder()
.url("${MdApi.manga}/$mangaId/follow")
.delete()
- .headers(getHeaders())
+ .headers(headers)
.cacheControl(CacheControl.FORCE_NETWORK)
.build(),
).await().parseAs(MdUtil.jsonParser)
@@ -195,7 +135,7 @@ class MangaDexAuthService(
return client.newCall(
POST(
"${MdApi.rating}/$mangaId",
- getHeaders(),
+ headers,
body = MdUtil.encodeToBody(RatingDto(rating)),
cache = CacheControl.FORCE_NETWORK,
),
@@ -207,7 +147,7 @@ class MangaDexAuthService(
Request.Builder()
.delete()
.url("${MdApi.rating}/$mangaId")
- .headers(getHeaders())
+ .headers(headers)
.cacheControl(CacheControl.FORCE_NETWORK)
.build(),
).await().parseAs(MdUtil.jsonParser)
@@ -224,7 +164,7 @@ class MangaDexAuthService(
}
}
.build(),
- getHeaders(),
+ headers,
cache = CacheControl.FORCE_NETWORK,
),
).await().parseAs(MdUtil.jsonParser)
diff --git a/app/src/main/java/exh/md/utils/MdApi.kt b/app/src/main/java/exh/md/utils/MdApi.kt
index e7f1e94d4..1e441d7e0 100644
--- a/app/src/main/java/exh/md/utils/MdApi.kt
+++ b/app/src/main/java/exh/md/utils/MdApi.kt
@@ -2,10 +2,6 @@ package exh.md.utils
object MdApi {
const val baseUrl = "https://api.mangadex.org"
- const val login = "$baseUrl/auth/login"
- const val checkToken = "$baseUrl/auth/check"
- const val refreshToken = "$baseUrl/auth/refresh"
- const val logout = "$baseUrl/auth/logout"
const val manga = "$baseUrl/manga"
const val chapter = "$baseUrl/chapter"
const val group = "$baseUrl/group"
@@ -18,4 +14,11 @@ object MdApi {
const val atHomeServer = "$baseUrl/at-home/server"
const val legacyMapping = "$baseUrl/legacy/mapping"
+
+ const val baseAuthUrl = "https://auth.mangadex.org"
+ private const val auth = "/realms/mangadex/protocol/openid-connect"
+ const val login = "$auth/auth"
+ const val logout = "$auth/logout"
+ const val token = "$auth/token"
+ const val userInfo = "$auth/userinfo"
}
diff --git a/app/src/main/java/exh/md/utils/MdConstants.kt b/app/src/main/java/exh/md/utils/MdConstants.kt
index eabacdd72..8a84284dd 100644
--- a/app/src/main/java/exh/md/utils/MdConstants.kt
+++ b/app/src/main/java/exh/md/utils/MdConstants.kt
@@ -1,5 +1,8 @@
package exh.md.utils
+import android.util.Base64
+import androidx.core.net.toUri
+import java.security.MessageDigest
import kotlin.time.Duration.Companion.minutes
object MdConstants {
@@ -16,4 +19,28 @@ object MdConstants {
}
val mdAtHomeTokenLifespan = 5.minutes.inWholeMilliseconds
+
+ object Login {
+ const val redirectUri = "tachiyomisy://mangadex-auth"
+ const val clientId = "tachiyomisy"
+ const val authorizationCode = "authorization_code"
+ const val refreshToken = "refresh_token"
+
+ fun authUrl(codeVerifier: String): String {
+ val bytes = codeVerifier.toByteArray()
+ val messageDigest = MessageDigest.getInstance("SHA-256")
+ messageDigest.update(bytes)
+ val digest = messageDigest.digest()
+ val encoding = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
+ val codeChallenge = Base64.encodeToString(digest, encoding)
+
+ return (MdApi.baseAuthUrl + MdApi.login).toUri().buildUpon()
+ .appendQueryParameter("client_id", clientId)
+ .appendQueryParameter("response_type", "code")
+ .appendQueryParameter("redirect_uri", redirectUri)
+ .appendQueryParameter("code_challenge", codeChallenge)
+ .appendQueryParameter("code_challenge_method", "S256")
+ .build().toString()
+ }
+ }
}
diff --git a/app/src/main/java/exh/md/utils/MdUtil.kt b/app/src/main/java/exh/md/utils/MdUtil.kt
index 2fda6c309..1b71d27be 100644
--- a/app/src/main/java/exh/md/utils/MdUtil.kt
+++ b/app/src/main/java/exh/md/utils/MdUtil.kt
@@ -4,26 +4,27 @@ import eu.kanade.domain.UnsortedPreferences
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.track.mdlist.MdList
+import eu.kanade.tachiyomi.data.track.myanimelist.OAuth
+import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.all.MangaDex
-import exh.log.xLogD
-import exh.md.dto.LoginBodyTokenDto
+import eu.kanade.tachiyomi.util.PkceUtil
import exh.md.dto.MangaAttributesDto
import exh.md.dto.MangaDataDto
import exh.md.network.NoSessionException
import exh.source.getMainSource
import exh.util.dropBlank
import exh.util.floor
-import exh.util.nullIfBlank
import exh.util.nullIfZero
-import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
+import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.jsoup.parser.Parser
@@ -190,32 +191,57 @@ class MdUtil {
return "$cdnUrl/covers/$dexId/$fileName"
}
- fun getLoginBody(preferences: TrackPreferences, mdList: MdList) = preferences.trackToken(mdList)
- .get()
- .nullIfBlank()
- ?.let {
- try {
- jsonParser.decodeFromString(it)
- } catch (e: SerializationException) {
- xLogD("Unable to load login body")
- null
- }
+ fun saveOAuth(preferences: TrackPreferences, mdList: MdList, oAuth: OAuth?) {
+ if (oAuth == null) {
+ preferences.trackToken(mdList).delete()
+ } else {
+ preferences.trackToken(mdList).set(jsonParser.encodeToString(oAuth))
}
-
- fun sessionToken(preferences: TrackPreferences, mdList: MdList) = getLoginBody(preferences, mdList)?.session
-
- fun refreshToken(preferences: TrackPreferences, mdList: MdList) = getLoginBody(preferences, mdList)?.refresh
-
- fun updateLoginToken(token: LoginBodyTokenDto, preferences: TrackPreferences, mdList: MdList) {
- preferences.trackToken(mdList).set(jsonParser.encodeToString(token))
}
+ fun loadOAuth(preferences: TrackPreferences, mdList: MdList): OAuth? {
+ return try {
+ jsonParser.decodeFromString(preferences.trackToken(mdList).get())
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ fun sessionToken(preferences: TrackPreferences, mdList: MdList) = loadOAuth(preferences, mdList)?.access_token
+
+ fun refreshToken(preferences: TrackPreferences, mdList: MdList) = loadOAuth(preferences, mdList)?.refresh_token
+
fun getAuthHeaders(headers: Headers, preferences: TrackPreferences, mdList: MdList) =
headers.newBuilder().add(
"Authorization",
"Bearer " + (sessionToken(preferences, mdList) ?: throw NoSessionException()),
).build()
+ private var codeVerifier: String? = null
+
+ fun refreshTokenRequest(oauth: OAuth): Request {
+ val formBody = FormBody.Builder()
+ .add("client_id", MdConstants.Login.clientId)
+ .add("grant_type", MdConstants.Login.refreshToken)
+ .add("refresh_token", oauth.refresh_token)
+ .add("code_verifier", getPkceChallengeCode())
+ .add("redirect_uri", MdConstants.Login.redirectUri)
+ .build()
+
+ // Add the Authorization header manually as this particular
+ // request is called by the interceptor itself so it doesn't reach
+ // the part where the token is added automatically.
+ val headers = Headers.Builder()
+ .add("Authorization", "Bearer ${oauth.access_token}")
+ .build()
+
+ return POST(MdApi.baseAuthUrl + MdApi.token, body = formBody, headers = headers)
+ }
+
+ fun getPkceChallengeCode(): String {
+ return codeVerifier ?: PkceUtil.generateCodeVerifier().also { codeVerifier = it }
+ }
+
fun getEnabledMangaDex(preferences: UnsortedPreferences, sourcePreferences: SourcePreferences = Injekt.get(), sourceManager: SourceManager = Injekt.get()): MangaDex? {
return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs ->
preferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero()
diff --git a/source-api/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt b/source-api/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt
index 363fa67dd..f5c7e0c10 100644
--- a/source-api/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt
+++ b/source-api/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt
@@ -13,7 +13,9 @@ interface LoginSource : Source {
fun getPassword(): String
- suspend fun login(username: String, password: String, twoFactorCode: String?): Boolean
+ suspend fun login(username: String, password: String, twoFactorCode: String?): Boolean = false
+
+ suspend fun login(authCode: String): Boolean = false
suspend fun logout(): Boolean