MangaDex OAuth
Co-authored-by: Carlos <2092019+CarlosEsco@users.noreply.github.com>
This commit is contained in:
parent
54c9ef51a6
commit
708b868e7b
@ -31,7 +31,7 @@ android {
|
|||||||
applicationId = "eu.kanade.tachiyomi.sy"
|
applicationId = "eu.kanade.tachiyomi.sy"
|
||||||
minSdk = AndroidConfig.minSdk
|
minSdk = AndroidConfig.minSdk
|
||||||
targetSdk = AndroidConfig.targetSdk
|
targetSdk = AndroidConfig.targetSdk
|
||||||
versionCode = 44
|
versionCode = 45
|
||||||
versionName = "1.8.5"
|
versionName = "1.8.5"
|
||||||
|
|
||||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||||
|
@ -381,6 +381,22 @@
|
|||||||
<data android:pathPattern="/chapter/..*" />
|
<data android:pathPattern="/chapter/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="exh.md.MangaDexLoginActivity"
|
||||||
|
android:label="MangaDexLogin"
|
||||||
|
android:exported="true">
|
||||||
|
<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="mangadex-auth"
|
||||||
|
android:scheme="tachiyomisy" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -2,25 +2,15 @@ package eu.kanade.presentation.more.settings.screen
|
|||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -35,15 +25,11 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.UnsortedPreferences
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
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.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
import eu.kanade.presentation.util.padding
|
import eu.kanade.presentation.util.padding
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
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.launchIO
|
||||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||||
import eu.kanade.tachiyomi.util.system.logcat
|
import eu.kanade.tachiyomi.util.system.logcat
|
||||||
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import exh.log.xLogW
|
import exh.md.utils.MdConstants
|
||||||
import exh.md.utils.MdUtil
|
import exh.md.utils.MdUtil
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
@ -71,134 +58,17 @@ object SettingsMangadexScreen : SearchableSettings {
|
|||||||
override fun getPreferences(): List<Preference> {
|
override fun getPreferences(): List<Preference> {
|
||||||
val sourcePreferences: SourcePreferences = remember { Injekt.get() }
|
val sourcePreferences: SourcePreferences = remember { Injekt.get() }
|
||||||
val unsortedPreferences: UnsortedPreferences = remember { Injekt.get() }
|
val unsortedPreferences: UnsortedPreferences = remember { Injekt.get() }
|
||||||
|
val trackPreferences: TrackPreferences = remember { Injekt.get() }
|
||||||
val mdex = remember { MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences) } ?: return emptyList()
|
val mdex = remember { MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences) } ?: return emptyList()
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
loginPreference(mdex),
|
loginPreference(mdex, trackPreferences),
|
||||||
preferredMangaDexId(unsortedPreferences, sourcePreferences),
|
preferredMangaDexId(unsortedPreferences, sourcePreferences),
|
||||||
syncMangaDexIntoThis(unsortedPreferences),
|
syncMangaDexIntoThis(unsortedPreferences),
|
||||||
syncLibraryToMangaDex(),
|
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
|
@Composable
|
||||||
fun LogoutDialog(
|
fun LogoutDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
@ -223,18 +93,10 @@ object SettingsMangadexScreen : SearchableSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun loginPreference(mdex: MangaDex): Preference.PreferenceItem.MangaDexPreference {
|
fun loginPreference(mdex: MangaDex, trackPreferences: TrackPreferences): Preference.PreferenceItem.MangaDexPreference {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var loggedIn by remember { mutableStateOf(mdex.isLogged()) }
|
val loggedIn by remember { trackPreferences.trackToken(mdex.mdList) }.collectAsState()
|
||||||
var loginDialogOpen by remember { mutableStateOf(false) }
|
|
||||||
if (loginDialogOpen) {
|
|
||||||
LoginDialog(
|
|
||||||
mdex = mdex,
|
|
||||||
onDismissRequest = { loginDialogOpen = false },
|
|
||||||
onLoginSuccess = { loggedIn = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var logoutDialogOpen by remember { mutableStateOf(false) }
|
var logoutDialogOpen by remember { mutableStateOf(false) }
|
||||||
if (logoutDialogOpen) {
|
if (logoutDialogOpen) {
|
||||||
LogoutDialog(
|
LogoutDialog(
|
||||||
@ -244,7 +106,6 @@ object SettingsMangadexScreen : SearchableSettings {
|
|||||||
scope.launchIO {
|
scope.launchIO {
|
||||||
try {
|
try {
|
||||||
if (mdex.logout()) {
|
if (mdex.logout()) {
|
||||||
loggedIn = false
|
|
||||||
withUIContext {
|
withUIContext {
|
||||||
context.toast(R.string.logout_success)
|
context.toast(R.string.logout_success)
|
||||||
}
|
}
|
||||||
@ -265,9 +126,12 @@ object SettingsMangadexScreen : SearchableSettings {
|
|||||||
}
|
}
|
||||||
return Preference.PreferenceItem.MangaDexPreference(
|
return Preference.PreferenceItem.MangaDexPreference(
|
||||||
title = mdex.name + " Login",
|
title = mdex.name + " Login",
|
||||||
loggedIn = loggedIn,
|
loggedIn = loggedIn.isNotEmpty(),
|
||||||
login = {
|
login = {
|
||||||
loginDialogOpen = true
|
context.openInBrowser(
|
||||||
|
MdConstants.Login.authUrl(MdUtil.getPkceChallengeCode()),
|
||||||
|
forceDefaultBrowser = true,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
logout = {
|
logout = {
|
||||||
logoutDialogOpen = true
|
logoutDialogOpen = true
|
||||||
|
@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.source.model.SManga
|
|||||||
import eu.kanade.tachiyomi.util.lang.awaitSingle
|
import eu.kanade.tachiyomi.util.lang.awaitSingle
|
||||||
import eu.kanade.tachiyomi.util.lang.runAsObservable
|
import eu.kanade.tachiyomi.util.lang.runAsObservable
|
||||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
|
import exh.md.network.MangaDexAuthInterceptor
|
||||||
import exh.md.utils.FollowStatus
|
import exh.md.utils.FollowStatus
|
||||||
import exh.md.utils.MdUtil
|
import exh.md.utils.MdUtil
|
||||||
import uy.kohesive.injekt.Injekt
|
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()) }
|
private val mdex by lazy { MdUtil.getEnabledMangaDex(Injekt.get()) }
|
||||||
|
|
||||||
|
val interceptor = MangaDexAuthInterceptor(trackPreferences, this)
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
override fun nameRes(): Int = R.string.mdlist
|
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()
|
trackPreferences.trackToken(this).delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val isLogged: Boolean
|
||||||
|
get() = trackPreferences.trackToken(this).get().isNotEmpty()
|
||||||
|
|
||||||
class MangaDexNotFoundException : Exception("Mangadex not enabled")
|
class MangaDexNotFoundException : Exception("Mangadex not enabled")
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,6 @@ import exh.md.handlers.MangaPlusHandler
|
|||||||
import exh.md.handlers.PageHandler
|
import exh.md.handlers.PageHandler
|
||||||
import exh.md.handlers.SimilarHandler
|
import exh.md.handlers.SimilarHandler
|
||||||
import exh.md.network.MangaDexLoginHelper
|
import exh.md.network.MangaDexLoginHelper
|
||||||
import exh.md.network.TokenAuthenticator
|
|
||||||
import exh.md.service.MangaDexAuthService
|
import exh.md.service.MangaDexAuthService
|
||||||
import exh.md.service.MangaDexService
|
import exh.md.service.MangaDexService
|
||||||
import exh.md.service.SimilarService
|
import exh.md.service.SimilarService
|
||||||
@ -76,14 +75,10 @@ class MangaDex(delegate: HttpSource, val context: Context) :
|
|||||||
context.getSharedPreferences("source_$id", 0x0000)
|
context.getSharedPreferences("source_$id", 0x0000)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mangadexAuthServiceLazy = lazy { MangaDexAuthService(baseHttpClient, headers, trackPreferences, mdList) }
|
private val loginHelper = MangaDexLoginHelper(network.client, trackPreferences, mdList, mdList.interceptor)
|
||||||
|
|
||||||
private val loginHelper = MangaDexLoginHelper(mangadexAuthServiceLazy, trackPreferences, mdList)
|
|
||||||
|
|
||||||
override val baseHttpClient: OkHttpClient = delegate.client.newBuilder()
|
override val baseHttpClient: OkHttpClient = delegate.client.newBuilder()
|
||||||
.authenticator(
|
.addInterceptor(mdList.interceptor)
|
||||||
TokenAuthenticator(loginHelper),
|
|
||||||
)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private fun dataSaver() = sourcePreferences.getBoolean(getDataSaverPreferenceKey(mdLang.lang), false)
|
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 {
|
private val mangadexService by lazy {
|
||||||
MangaDexService(client)
|
MangaDexService(client)
|
||||||
}
|
}
|
||||||
private val mangadexAuthService by mangadexAuthServiceLazy
|
private val mangadexAuthService by lazy {
|
||||||
|
MangaDexAuthService(baseHttpClient, headers)
|
||||||
|
}
|
||||||
private val similarService by lazy {
|
private val similarService by lazy {
|
||||||
SimilarService(client)
|
SimilarService(client)
|
||||||
}
|
}
|
||||||
@ -232,24 +229,12 @@ class MangaDex(delegate: HttpSource, val context: Context) :
|
|||||||
return mdList.getPassword()
|
return mdList.getPassword()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun login(
|
override suspend fun login(authCode: String): Boolean {
|
||||||
username: String,
|
return loginHelper.login(authCode)
|
||||||
password: String,
|
|
||||||
twoFactorCode: String?,
|
|
||||||
): Boolean {
|
|
||||||
val result = loginHelper.login(username, password)
|
|
||||||
return if (result) {
|
|
||||||
mdList.saveCredentials(username, password)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun logout(): Boolean {
|
override suspend fun logout(): Boolean {
|
||||||
loginHelper.logout()
|
return loginHelper.logout()
|
||||||
mdList.logout()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FollowsSource methods
|
// FollowsSource methods
|
||||||
|
@ -510,6 +510,11 @@ object EXHMigrations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (oldVersion under 45) {
|
||||||
|
// Force MangaDex log out due to login flow change
|
||||||
|
val trackManager = Injekt.get<TrackManager>()
|
||||||
|
trackManager.mdList.logout()
|
||||||
|
}
|
||||||
|
|
||||||
// if (oldVersion under 1) { } (1 is current release version)
|
// if (oldVersion under 1) { } (1 is current release version)
|
||||||
// do stuff here when releasing changed crap
|
// do stuff here when releasing changed crap
|
||||||
|
27
app/src/main/java/exh/md/MangaDexLoginActivity.kt
Normal file
27
app/src/main/java/exh/md/MangaDexLoginActivity.kt
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
|
96
app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt
Normal file
96
app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt
Normal file
@ -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<OAuth>()
|
||||||
|
} else {
|
||||||
|
oauthResponse.close()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newOauth.getOrNull() == null) {
|
||||||
|
throw IOException("Failed to refresh the access token", newOauth.exceptionOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
return newOauth.getOrNull()!!
|
||||||
|
}
|
||||||
|
}
|
@ -2,88 +2,88 @@ package exh.md.network
|
|||||||
|
|
||||||
import eu.kanade.domain.track.service.TrackPreferences
|
import eu.kanade.domain.track.service.TrackPreferences
|
||||||
import eu.kanade.tachiyomi.data.track.mdlist.MdList
|
import eu.kanade.tachiyomi.data.track.mdlist.MdList
|
||||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
import eu.kanade.tachiyomi.data.track.myanimelist.OAuth
|
||||||
import exh.log.xLogE
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import exh.log.xLogI
|
import eu.kanade.tachiyomi.network.await
|
||||||
import exh.md.dto.LoginRequestDto
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import exh.md.dto.RefreshTokenDto
|
import eu.kanade.tachiyomi.util.system.logcat
|
||||||
import exh.md.service.MangaDexAuthService
|
import exh.md.utils.MdApi
|
||||||
|
import exh.md.utils.MdConstants
|
||||||
import exh.md.utils.MdUtil
|
import exh.md.utils.MdUtil
|
||||||
import kotlinx.coroutines.CancellationException
|
import logcat.LogPriority
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import okhttp3.FormBody
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import okhttp3.Headers
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
class MangaDexLoginHelper(authServiceLazy: Lazy<MangaDexAuthService>, val preferences: TrackPreferences, val mdList: MdList) {
|
class MangaDexLoginHelper(
|
||||||
private val authService by authServiceLazy
|
private val client: OkHttpClient,
|
||||||
suspend fun isAuthenticated(): Boolean {
|
private val preferences: TrackPreferences,
|
||||||
return runCatching { authService.checkToken().isAuthenticated }
|
private val mdList: MdList,
|
||||||
.getOrElse { e ->
|
private val mangaDexAuthInterceptor: MangaDexAuthInterceptor,
|
||||||
xLogE("error authenticating", e)
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<OAuth>()
|
||||||
|
mangaDexAuthInterceptor.setAuth(data)
|
||||||
|
}.exceptionOrNull()
|
||||||
|
|
||||||
|
return when (error == null) {
|
||||||
|
true -> true
|
||||||
|
false -> {
|
||||||
|
logcat(LogPriority.ERROR, error) { "Error logging in" }
|
||||||
|
mdList.logout()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refreshToken(): Boolean {
|
suspend fun logout(): Boolean {
|
||||||
val refreshToken = MdUtil.refreshToken(preferences, mdList)
|
val oauth = MdUtil.loadOAuth(preferences, mdList)
|
||||||
if (refreshToken.isNullOrEmpty()) {
|
val sessionToken = oauth?.access_token
|
||||||
return false
|
val refreshToken = oauth?.refresh_token
|
||||||
}
|
if (refreshToken.isNullOrEmpty() || sessionToken.isNullOrEmpty()) {
|
||||||
val refresh = runCatching {
|
mdList.logout()
|
||||||
val jsonResponse = authService.refreshToken(RefreshTokenDto(refreshToken))
|
return true
|
||||||
MdUtil.updateLoginToken(jsonResponse.token, preferences, mdList)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val e = refresh.exceptionOrNull()
|
val formBody = FormBody.Builder()
|
||||||
if (e is CancellationException) throw e
|
.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(
|
return when (error == null) {
|
||||||
username: String,
|
true -> {
|
||||||
password: String,
|
mangaDexAuthInterceptor.setAuth(null)
|
||||||
): 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,
|
|
||||||
)
|
|
||||||
true
|
true
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
false -> {
|
||||||
}
|
logcat(LogPriority.ERROR, error) { "Error logging out" }
|
||||||
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +1,19 @@
|
|||||||
package exh.md.service
|
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.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.await
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
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.MangaListDto
|
||||||
import exh.md.dto.RatingDto
|
import exh.md.dto.RatingDto
|
||||||
import exh.md.dto.RatingResponseDto
|
import exh.md.dto.RatingResponseDto
|
||||||
import exh.md.dto.ReadChapterDto
|
import exh.md.dto.ReadChapterDto
|
||||||
import exh.md.dto.ReadingStatusDto
|
import exh.md.dto.ReadingStatusDto
|
||||||
import exh.md.dto.ReadingStatusMapDto
|
import exh.md.dto.ReadingStatusMapDto
|
||||||
import exh.md.dto.RefreshTokenDto
|
|
||||||
import exh.md.dto.ResultDto
|
import exh.md.dto.ResultDto
|
||||||
import exh.md.utils.MdApi
|
import exh.md.utils.MdApi
|
||||||
import exh.md.utils.MdConstants
|
import exh.md.utils.MdConstants
|
||||||
import exh.md.utils.MdUtil
|
import exh.md.utils.MdUtil
|
||||||
import okhttp3.Authenticator
|
|
||||||
import okhttp3.CacheControl
|
import okhttp3.CacheControl
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
@ -31,65 +23,13 @@ import okhttp3.Request
|
|||||||
class MangaDexAuthService(
|
class MangaDexAuthService(
|
||||||
private val client: OkHttpClient,
|
private val client: OkHttpClient,
|
||||||
private val headers: Headers,
|
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 {
|
suspend fun userFollowList(offset: Int): MangaListDto {
|
||||||
return client.newCall(
|
return client.newCall(
|
||||||
GET(
|
GET(
|
||||||
"${MdApi.userFollows}?limit=100&offset=$offset&includes[]=${MdConstants.Types.coverArt}",
|
"${MdApi.userFollows}?limit=100&offset=$offset&includes[]=${MdConstants.Types.coverArt}",
|
||||||
getHeaders(),
|
headers,
|
||||||
CacheControl.FORCE_NETWORK,
|
CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -99,7 +39,7 @@ class MangaDexAuthService(
|
|||||||
return client.newCall(
|
return client.newCall(
|
||||||
GET(
|
GET(
|
||||||
"${MdApi.manga}/$mangaId/status",
|
"${MdApi.manga}/$mangaId/status",
|
||||||
getHeaders(),
|
headers,
|
||||||
CacheControl.FORCE_NETWORK,
|
CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -109,7 +49,7 @@ class MangaDexAuthService(
|
|||||||
return client.newCall(
|
return client.newCall(
|
||||||
GET(
|
GET(
|
||||||
"${MdApi.manga}/$mangaId/read",
|
"${MdApi.manga}/$mangaId/read",
|
||||||
getHeaders(),
|
headers,
|
||||||
CacheControl.FORCE_NETWORK,
|
CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -122,7 +62,7 @@ class MangaDexAuthService(
|
|||||||
return client.newCall(
|
return client.newCall(
|
||||||
POST(
|
POST(
|
||||||
"${MdApi.manga}/$mangaId/status",
|
"${MdApi.manga}/$mangaId/status",
|
||||||
getHeaders(),
|
headers,
|
||||||
body = MdUtil.encodeToBody(readingStatusDto),
|
body = MdUtil.encodeToBody(readingStatusDto),
|
||||||
cache = CacheControl.FORCE_NETWORK,
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
@ -133,7 +73,7 @@ class MangaDexAuthService(
|
|||||||
return client.newCall(
|
return client.newCall(
|
||||||
GET(
|
GET(
|
||||||
MdApi.readingStatusForAllManga,
|
MdApi.readingStatusForAllManga,
|
||||||
getHeaders(),
|
headers,
|
||||||
cache = CacheControl.FORCE_NETWORK,
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -143,7 +83,7 @@ class MangaDexAuthService(
|
|||||||
return client.newCall(
|
return client.newCall(
|
||||||
GET(
|
GET(
|
||||||
"${MdApi.readingStatusForAllManga}?status=$status",
|
"${MdApi.readingStatusForAllManga}?status=$status",
|
||||||
getHeaders(),
|
headers,
|
||||||
cache = CacheControl.FORCE_NETWORK,
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -153,7 +93,7 @@ class MangaDexAuthService(
|
|||||||
return client.newCall(
|
return client.newCall(
|
||||||
POST(
|
POST(
|
||||||
"${MdApi.chapter}/$chapterId/read",
|
"${MdApi.chapter}/$chapterId/read",
|
||||||
getHeaders(),
|
headers,
|
||||||
cache = CacheControl.FORCE_NETWORK,
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -164,7 +104,7 @@ class MangaDexAuthService(
|
|||||||
Request.Builder()
|
Request.Builder()
|
||||||
.url("${MdApi.chapter}/$chapterId/read")
|
.url("${MdApi.chapter}/$chapterId/read")
|
||||||
.delete()
|
.delete()
|
||||||
.headers(getHeaders())
|
.headers(headers)
|
||||||
.cacheControl(CacheControl.FORCE_NETWORK)
|
.cacheControl(CacheControl.FORCE_NETWORK)
|
||||||
.build(),
|
.build(),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -174,7 +114,7 @@ class MangaDexAuthService(
|
|||||||
return client.newCall(
|
return client.newCall(
|
||||||
POST(
|
POST(
|
||||||
"${MdApi.manga}/$mangaId/follow",
|
"${MdApi.manga}/$mangaId/follow",
|
||||||
getHeaders(),
|
headers,
|
||||||
cache = CacheControl.FORCE_NETWORK,
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -185,7 +125,7 @@ class MangaDexAuthService(
|
|||||||
Request.Builder()
|
Request.Builder()
|
||||||
.url("${MdApi.manga}/$mangaId/follow")
|
.url("${MdApi.manga}/$mangaId/follow")
|
||||||
.delete()
|
.delete()
|
||||||
.headers(getHeaders())
|
.headers(headers)
|
||||||
.cacheControl(CacheControl.FORCE_NETWORK)
|
.cacheControl(CacheControl.FORCE_NETWORK)
|
||||||
.build(),
|
.build(),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -195,7 +135,7 @@ class MangaDexAuthService(
|
|||||||
return client.newCall(
|
return client.newCall(
|
||||||
POST(
|
POST(
|
||||||
"${MdApi.rating}/$mangaId",
|
"${MdApi.rating}/$mangaId",
|
||||||
getHeaders(),
|
headers,
|
||||||
body = MdUtil.encodeToBody(RatingDto(rating)),
|
body = MdUtil.encodeToBody(RatingDto(rating)),
|
||||||
cache = CacheControl.FORCE_NETWORK,
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
@ -207,7 +147,7 @@ class MangaDexAuthService(
|
|||||||
Request.Builder()
|
Request.Builder()
|
||||||
.delete()
|
.delete()
|
||||||
.url("${MdApi.rating}/$mangaId")
|
.url("${MdApi.rating}/$mangaId")
|
||||||
.headers(getHeaders())
|
.headers(headers)
|
||||||
.cacheControl(CacheControl.FORCE_NETWORK)
|
.cacheControl(CacheControl.FORCE_NETWORK)
|
||||||
.build(),
|
.build(),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
@ -224,7 +164,7 @@ class MangaDexAuthService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.build(),
|
.build(),
|
||||||
getHeaders(),
|
headers,
|
||||||
cache = CacheControl.FORCE_NETWORK,
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
),
|
),
|
||||||
).await().parseAs(MdUtil.jsonParser)
|
).await().parseAs(MdUtil.jsonParser)
|
||||||
|
@ -2,10 +2,6 @@ package exh.md.utils
|
|||||||
|
|
||||||
object MdApi {
|
object MdApi {
|
||||||
const val baseUrl = "https://api.mangadex.org"
|
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 manga = "$baseUrl/manga"
|
||||||
const val chapter = "$baseUrl/chapter"
|
const val chapter = "$baseUrl/chapter"
|
||||||
const val group = "$baseUrl/group"
|
const val group = "$baseUrl/group"
|
||||||
@ -18,4 +14,11 @@ object MdApi {
|
|||||||
const val atHomeServer = "$baseUrl/at-home/server"
|
const val atHomeServer = "$baseUrl/at-home/server"
|
||||||
|
|
||||||
const val legacyMapping = "$baseUrl/legacy/mapping"
|
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"
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
package exh.md.utils
|
package exh.md.utils
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import java.security.MessageDigest
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
object MdConstants {
|
object MdConstants {
|
||||||
@ -16,4 +19,28 @@ object MdConstants {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val mdAtHomeTokenLifespan = 5.minutes.inWholeMilliseconds
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,26 +4,27 @@ import eu.kanade.domain.UnsortedPreferences
|
|||||||
import eu.kanade.domain.source.service.SourcePreferences
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
import eu.kanade.domain.track.service.TrackPreferences
|
import eu.kanade.domain.track.service.TrackPreferences
|
||||||
import eu.kanade.tachiyomi.data.track.mdlist.MdList
|
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.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.all.MangaDex
|
import eu.kanade.tachiyomi.source.online.all.MangaDex
|
||||||
import exh.log.xLogD
|
import eu.kanade.tachiyomi.util.PkceUtil
|
||||||
import exh.md.dto.LoginBodyTokenDto
|
|
||||||
import exh.md.dto.MangaAttributesDto
|
import exh.md.dto.MangaAttributesDto
|
||||||
import exh.md.dto.MangaDataDto
|
import exh.md.dto.MangaDataDto
|
||||||
import exh.md.network.NoSessionException
|
import exh.md.network.NoSessionException
|
||||||
import exh.source.getMainSource
|
import exh.source.getMainSource
|
||||||
import exh.util.dropBlank
|
import exh.util.dropBlank
|
||||||
import exh.util.floor
|
import exh.util.floor
|
||||||
import exh.util.nullIfBlank
|
|
||||||
import exh.util.nullIfZero
|
import exh.util.nullIfZero
|
||||||
import kotlinx.serialization.SerializationException
|
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.FormBody
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.jsoup.parser.Parser
|
import org.jsoup.parser.Parser
|
||||||
@ -190,32 +191,57 @@ class MdUtil {
|
|||||||
return "$cdnUrl/covers/$dexId/$fileName"
|
return "$cdnUrl/covers/$dexId/$fileName"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLoginBody(preferences: TrackPreferences, mdList: MdList) = preferences.trackToken(mdList)
|
fun saveOAuth(preferences: TrackPreferences, mdList: MdList, oAuth: OAuth?) {
|
||||||
.get()
|
if (oAuth == null) {
|
||||||
.nullIfBlank()
|
preferences.trackToken(mdList).delete()
|
||||||
?.let {
|
} else {
|
||||||
try {
|
preferences.trackToken(mdList).set(jsonParser.encodeToString(oAuth))
|
||||||
jsonParser.decodeFromString<LoginBodyTokenDto>(it)
|
|
||||||
} catch (e: SerializationException) {
|
|
||||||
xLogD("Unable to load login body")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<OAuth>(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) =
|
fun getAuthHeaders(headers: Headers, preferences: TrackPreferences, mdList: MdList) =
|
||||||
headers.newBuilder().add(
|
headers.newBuilder().add(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"Bearer " + (sessionToken(preferences, mdList) ?: throw NoSessionException()),
|
"Bearer " + (sessionToken(preferences, mdList) ?: throw NoSessionException()),
|
||||||
).build()
|
).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? {
|
fun getEnabledMangaDex(preferences: UnsortedPreferences, sourcePreferences: SourcePreferences = Injekt.get(), sourceManager: SourceManager = Injekt.get()): MangaDex? {
|
||||||
return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs ->
|
return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs ->
|
||||||
preferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero()
|
preferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero()
|
||||||
|
@ -13,7 +13,9 @@ interface LoginSource : Source {
|
|||||||
|
|
||||||
fun getPassword(): String
|
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
|
suspend fun logout(): Boolean
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user