Add MangaDex login

This commit is contained in:
NerdNumber9 2019-04-19 02:46:34 -04:00
parent 5c2fbec80a
commit ea7ff432b2
14 changed files with 238 additions and 95 deletions

View File

@ -219,7 +219,7 @@
</intent-filter>
</activity>
<activity
android:name="exh.ui.captcha.SolveCaptchaActivity"
android:name="exh.ui.captcha.BrowserActionActivity"
android:launchMode="singleInstance"
android:theme="@style/Theme.EHActivity" />
<activity android:name="exh.ui.webview.WebViewActivity" />

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.network
import android.content.Context
import android.os.Build
import exh.log.maybeInjectEHLogger
import exh.patch.attachMangaDexLogin
import okhttp3.*
import java.io.File
import java.io.IOException
@ -14,23 +15,24 @@ import java.security.KeyStore
import java.security.NoSuchAlgorithmException
import javax.net.ssl.*
class NetworkHelper(context: Context) {
open class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
val cookieManager = AndroidCookieJar(context)
open val cookieManager = AndroidCookieJar(context)
val client = OkHttpClient.Builder()
open val client = OkHttpClient.Builder()
.cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize))
.enableTLS12()
.maybeInjectEHLogger()
.build()
val cloudflareClient = client.newBuilder()
open val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor(context))
.attachMangaDexLogin()
.build()
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {

View File

@ -24,7 +24,6 @@ import exh.metadata.metadata.PervEdenLang
import exh.source.BlacklistedSources
import exh.source.DelegatedHttpSource
import exh.source.EnhancedHttpSource
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import kotlin.reflect.KClass

View File

@ -1,15 +1,14 @@
package eu.kanade.tachiyomi.source.online
import android.app.Application
import com.elvishew.xlog.XLog
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.network.*
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.util.asJsoup
import exh.ui.captcha.SolveCaptchaActivity
import exh.source.DelegatedHttpSource
import exh.ui.captcha.BrowserActionActivity
import exh.util.interceptAsHtml
import okhttp3.*
import rx.Observable
import uy.kohesive.injekt.Injekt
@ -28,7 +27,19 @@ abstract class HttpSource : CatalogueSource {
/**
* Network service.
*/
protected val network: NetworkHelper by injectLazy()
protected val network: NetworkHelper by lazy {
val original = Injekt.get<NetworkHelper>()
object : NetworkHelper(Injekt.get<Application>()) {
override val client: OkHttpClient?
get() = delegate?.networkHttpClient ?: original.client
override val cloudflareClient: OkHttpClient?
get() = delegate?.networkCloudflareClient ?: original.cloudflareClient
override val cookieManager: AndroidCookieJar
get() = original.cookieManager
}
}
// /**
// * Preferences that a source may need.
@ -68,36 +79,21 @@ abstract class HttpSource : CatalogueSource {
* Default network client for doing requests.
*/
open val client: OkHttpClient
get() = network.client.newBuilder().addInterceptor { chain ->
get() = delegate?.baseHttpClient ?: network.client.newBuilder().addInterceptor { chain ->
// Automatic captcha detection
val response = chain.proceed(chain.request())
val body = response.body()
if(!response.isSuccessful && body != null) {
if(body.contentType()?.type() == "text"
&& body.contentType()?.subtype() == "html") {
val bodyString = body.string()
val rebuiltResponse = response.newBuilder()
.body(ResponseBody.create(body.contentType(), bodyString))
.build()
try {
// Search for captcha
val parsed = response.asJsoup(html = bodyString)
if(parsed.getElementsByClass("g-recaptcha").isNotEmpty()) {
if(!response.isSuccessful) {
response.interceptAsHtml { doc ->
if (doc.getElementsByClass("g-recaptcha").isNotEmpty()) {
// Found it, allow the user to solve this thing
SolveCaptchaActivity.launchUniversal(
BrowserActionActivity.launchUniversal(
Injekt.get<Application>(),
this,
chain.request().url().toString()
)
}
} catch(t: Throwable) {
// Ignore all errors
XLog.w("Captcha detection error!", t)
}
return@addInterceptor rebuiltResponse
}
}
response
} else response
}.build()
/**
@ -397,4 +393,14 @@ abstract class HttpSource : CatalogueSource {
* Returns the list of filters for the source.
*/
override fun getFilterList() = FilterList()
// EXH -->
private var delegate: DelegatedHttpSource? = null
get() = if(Injekt.get<PreferencesHelper>().eh_delegateSources().getOrDefault())
field
else null
fun bindDelegate(delegate: DelegatedHttpSource) {
this.delegate = delegate
}
// EXH <--
}

View File

@ -16,8 +16,8 @@ import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.toast
import exh.TSUMINO_SOURCE_ID
import exh.ui.captcha.CaptchaCompletionVerifier
import exh.ui.captcha.SolveCaptchaActivity
import exh.ui.captcha.ActionCompletionVerifier
import exh.ui.captcha.BrowserActionActivity
import exh.metadata.metadata.TsuminoSearchMetadata
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.BASE_URL
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.TAG_TYPE_DEFAULT
@ -33,7 +33,7 @@ import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.*
class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<TsuminoSearchMetadata, Document>, CaptchaCompletionVerifier {
class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<TsuminoSearchMetadata, Document>, ActionCompletionVerifier {
override val metaClass = TsuminoSearchMetadata::class
private val preferences: PreferencesHelper by injectLazy()
@ -343,7 +343,7 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
else
emptyMap()
SolveCaptchaActivity.launch(context,
BrowserActionActivity.launchCaptcha(context,
this,
cookiesMap,
CAPTCHA_SCRIPT,
@ -356,7 +356,7 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
}
}
override fun verifyNoCaptcha(url: String): Boolean {
override fun verifyComplete(url: String): Boolean {
return Uri.parse(url).pathSegments.getOrNull(1) == "View"
}

View File

@ -121,7 +121,7 @@ class SettingsAdvancedController : SettingsController() {
title = "Enable delegated sources"
key = PreferenceKeys.eh_delegateSources
defaultValue = true
summary = "Apply ${context.getString(R.string.app_name)} enhancements to the following sources if they are installed: ${DELEGATED_SOURCES.values.joinToString { it.sourceName }}"
summary = "Apply ${context.getString(R.string.app_name)} enhancements to the following sources if they are installed: ${DELEGATED_SOURCES.values.map { it.sourceName }.distinct().joinToString()}"
}
intListPreference {

View File

@ -0,0 +1,46 @@
package exh.patch
import android.app.Application
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import exh.ui.captcha.BrowserActionActivity
import exh.util.interceptAsHtml
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
private val HIDE_SCRIPT = """
document.querySelector("#forgot_button").style.visibility = "hidden";
document.querySelector("#signup_button").style.visibility = "hidden";
document.querySelector("#announcement").style.visibility = "hidden";
document.querySelector("nav").style.visibility = "hidden";
document.querySelector("footer").style.visibility = "hidden";
""".trimIndent()
private fun verifyComplete(url: String): Boolean {
return HttpUrl.parse(url)?.let { parsed ->
parsed.host() == "mangadex.org" && parsed.pathSegments().none { it.isNotBlank() }
} ?: false
}
fun OkHttpClient.Builder.attachMangaDexLogin() =
addInterceptor { chain ->
val response = chain.proceed(chain.request())
if(response.request().url().host() == "mangadex.org") {
response.interceptAsHtml { doc ->
if (doc.title().trim().equals("Login - MangaDex", true)) {
BrowserActionActivity.launchAction(
Injekt.get<Application>(),
::verifyComplete,
HIDE_SCRIPT,
"https://mangadex.org/login",
"Login",
(Injekt.get<SourceManager>().get(2499283573021220255) as? HttpSource)?.headers?.toMultimap()?.mapValues {
it.value.joinToString(",")
} ?: emptyMap()
)
}
}
} else response
}

View File

@ -2,6 +2,7 @@ package exh.source
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
@ -93,16 +94,16 @@ abstract class DelegatedHttpSource(val delegate: HttpSource): HttpSource() {
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
override val baseUrl = delegate.baseUrl
override val baseUrl get() = delegate.baseUrl
/**
* Whether the source has support for latest updates.
*/
override val supportsLatest = delegate.supportsLatest
override val supportsLatest get() = delegate.supportsLatest
/**
* Name of the source.
*/
final override val name = delegate.name
final override val name get() = delegate.name
// ===> OPTIONAL FIELDS
@ -111,11 +112,18 @@ abstract class DelegatedHttpSource(val delegate: HttpSource): HttpSource() {
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
*/
override val id = delegate.id
override val id get() = delegate.id
/**
* Default network client for doing requests.
*/
override val client = delegate.client
final override val client get() = delegate.client
/**
* You must NEVER call super.client if you override this!
*/
open val baseHttpClient: OkHttpClient? = null
open val networkHttpClient: OkHttpClient get() = network.client
open val networkCloudflareClient: OkHttpClient get() = network.cloudflareClient
/**
* Visible name of the source.
@ -235,4 +243,8 @@ abstract class DelegatedHttpSource(val delegate: HttpSource): HttpSource() {
}
class IncompatibleDelegateException(message: String) : RuntimeException(message)
init {
delegate.bindDelegate(this)
}
}

View File

@ -96,21 +96,21 @@ class EnhancedHttpSource(val originalSource: HttpSource,
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
override val baseUrl = source().baseUrl
override val baseUrl get() = source().baseUrl
/**
* Whether the source has support for latest updates.
*/
override val supportsLatest = source().supportsLatest
override val supportsLatest get() = source().supportsLatest
/**
* Name of the source.
*/
override val name = source().name
override val name get() = source().name
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang = source().lang
override val lang get() = source().lang
// ===> OPTIONAL FIELDS
@ -119,11 +119,11 @@ class EnhancedHttpSource(val originalSource: HttpSource,
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
*/
override val id = source().id
override val id get() = source().id
/**
* Default network client for doing requests.
*/
override val client = source().client
override val client get() = source().client
/**
* Visible name of the source.

View File

@ -6,17 +6,17 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import eu.kanade.tachiyomi.util.asJsoup
import exh.ui.captcha.SolveCaptchaActivity.Companion.CROSS_WINDOW_SCRIPT_INNER
import exh.ui.captcha.BrowserActionActivity.Companion.CROSS_WINDOW_SCRIPT_INNER
import org.jsoup.nodes.DataNode
import org.jsoup.nodes.Element
import java.nio.charset.Charset
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class AutoSolvingWebViewClient(activity: SolveCaptchaActivity,
source: CaptchaCompletionVerifier,
class AutoSolvingWebViewClient(activity: BrowserActionActivity,
verifyComplete: (String) -> Boolean,
injectScript: String?,
headers: Map<String, String>)
: HeadersInjectingWebViewClient(activity, source, injectScript, headers) {
: HeadersInjectingWebViewClient(activity, verifyComplete, injectScript, headers) {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
// Inject our custom script into the recaptcha iframes

View File

@ -3,13 +3,13 @@ package exh.ui.captcha
import android.webkit.WebView
import android.webkit.WebViewClient
open class BasicWebViewClient(protected val activity: SolveCaptchaActivity,
protected val source: CaptchaCompletionVerifier,
open class BasicWebViewClient(protected val activity: BrowserActionActivity,
protected val verifyComplete: (String) -> Boolean,
private val injectScript: String?) : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
if(source.verifyNoCaptcha(url)) {
if(verifyComplete(url)) {
activity.finish()
} else {
if(injectScript != null) view.loadUrl("javascript:(function() {$injectScript})();")

View File

@ -31,8 +31,10 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import exh.source.DelegatedHttpSource
import exh.util.melt
import rx.Observable
import java.io.Serializable
import kotlin.collections.HashMap
class SolveCaptchaActivity : AppCompatActivity() {
class BrowserActionActivity : AppCompatActivity() {
private val sourceManager: SourceManager by injectLazy()
private val preferencesHelper: PreferencesHelper by injectLazy()
private val networkHelper: NetworkHelper by injectLazy()
@ -54,31 +56,38 @@ class SolveCaptchaActivity : AppCompatActivity() {
val sourceId = intent.getLongExtra(SOURCE_ID_EXTRA, -1)
val originalSource = if(sourceId != -1L) sourceManager.get(sourceId) else null
val source = if(originalSource != null) {
originalSource as? CaptchaCompletionVerifier
originalSource as? ActionCompletionVerifier
?: run {
(originalSource as? HttpSource)?.let {
NoopCaptchaCompletionVerifier(it)
NoopActionCompletionVerifier(it)
}
}
} else null
val headers = (source as? HttpSource)?.headers?.toMultimap()?.mapValues {
val headers = ((source as? HttpSource)?.headers?.toMultimap()?.mapValues {
it.value.joinToString(",")
} ?: emptyMap()
} ?: emptyMap()) + (intent.getSerializableExtra(HEADERS_EXTRA) as? HashMap<String, String> ?: emptyMap())
val cookies: HashMap<String, String>?
= intent.getSerializableExtra(COOKIES_EXTRA) as? HashMap<String, String>
val script: String? = intent.getStringExtra(SCRIPT_EXTRA)
val url: String? = intent.getStringExtra(URL_EXTRA)
val actionName = intent.getStringExtra(ACTION_NAME_EXTRA)
if(source == null || url == null) {
val verifyComplete = if(source != null) {
source::verifyComplete!!
} else intent.getSerializableExtra(VERIFY_LAMBDA_EXTRA) as? (String) -> Boolean
if(verifyComplete == null || url == null) {
finish()
return
}
toolbar.title = source.name + ": Solve captcha"
val actionStr = actionName ?: "Solve captcha"
toolbar.title = if(source != null) {
"${source.name}: $actionStr"
} else actionStr
val parsedUrl = URL(url)
@ -94,6 +103,9 @@ class SolveCaptchaActivity : AppCompatActivity() {
webview.settings.javaScriptEnabled = true
webview.settings.domStorageEnabled = true
headers.entries.find { it.key.equals("user-agent", true) }?.let {
webview.settings.userAgentString = it.value
}
var loadedInners = 0
@ -125,7 +137,7 @@ class SolveCaptchaActivity : AppCompatActivity() {
}
webview.webViewClient = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if(preferencesHelper.eh_autoSolveCaptchas().getOrDefault()) {
if(actionName == null && preferencesHelper.eh_autoSolveCaptchas().getOrDefault()) {
// Fetch auto-solve credentials early for speed
credentialsObservable = httpClient.newCall(Request.Builder()
// Rob demo credentials
@ -139,13 +151,13 @@ class SolveCaptchaActivity : AppCompatActivity() {
json["token"].string
}.melt()
webview.addJavascriptInterface(this@SolveCaptchaActivity, "exh")
AutoSolvingWebViewClient(this, source, script, headers)
webview.addJavascriptInterface(this@BrowserActionActivity, "exh")
AutoSolvingWebViewClient(this, verifyComplete, script, headers)
} else {
HeadersInjectingWebViewClient(this, source, script, headers)
HeadersInjectingWebViewClient(this, verifyComplete, script, headers)
}
} else {
BasicWebViewClient(this, source, script)
BasicWebViewClient(this, verifyComplete, script)
}
webview.loadUrl(url, headers)
@ -490,11 +502,14 @@ class SolveCaptchaActivity : AppCompatActivity() {
}
companion object {
const val VERIFY_LAMBDA_EXTRA = "verify_lambda_extra"
const val SOURCE_ID_EXTRA = "source_id_extra"
const val COOKIES_EXTRA = "cookies_extra"
const val SCRIPT_EXTRA = "script_extra"
const val URL_EXTRA = "url_extra"
const val ASBTN_EXTRA = "asbtn_extra"
const val ACTION_NAME_EXTRA = "action_name_extra"
const val HEADERS_EXTRA = "headers_extra"
const val STAGE_CHECKBOX = 0
const val STAGE_GET_AUDIO_BTN_LOCATION = 1
@ -600,13 +615,13 @@ class SolveCaptchaActivity : AppCompatActivity() {
val TRANSCRIPT_CLEANER_REGEX = Regex("[^0-9a-zA-Z_ -]")
val SPACE_DEDUPE_REGEX = Regex(" +")
fun launch(context: Context,
source: CaptchaCompletionVerifier,
fun launchCaptcha(context: Context,
source: ActionCompletionVerifier,
cookies: Map<String, String>,
script: String,
script: String?,
url: String,
autoSolveSubmitBtnSelector: String? = null) {
val intent = Intent(context, SolveCaptchaActivity::class.java).apply {
val intent = Intent(context, BrowserActionActivity::class.java).apply {
putExtra(SOURCE_ID_EXTRA, source.id)
putExtra(COOKIES_EXTRA, HashMap(cookies))
putExtra(SCRIPT_EXTRA, script)
@ -620,25 +635,57 @@ class SolveCaptchaActivity : AppCompatActivity() {
fun launchUniversal(context: Context,
source: HttpSource,
url: String) {
val intent = Intent(context, SolveCaptchaActivity::class.java).apply {
val intent = Intent(context, BrowserActionActivity::class.java).apply {
putExtra(SOURCE_ID_EXTRA, source.id)
putExtra(URL_EXTRA, url)
}
context.startActivity(intent)
}
fun launchAction(context: Context,
completionVerifier: ActionCompletionVerifier,
script: String?,
url: String,
actionName: String) {
val intent = Intent(context, BrowserActionActivity::class.java).apply {
putExtra(SOURCE_ID_EXTRA, completionVerifier.id)
putExtra(SCRIPT_EXTRA, script)
putExtra(URL_EXTRA, url)
putExtra(ACTION_NAME_EXTRA, actionName)
}
context.startActivity(intent)
}
fun launchAction(context: Context,
completionVerifier: (String) -> Boolean,
script: String?,
url: String,
actionName: String,
headers: Map<String, String>? = emptyMap()) {
val intent = Intent(context, BrowserActionActivity::class.java).apply {
putExtra(HEADERS_EXTRA, HashMap(headers))
putExtra(VERIFY_LAMBDA_EXTRA, completionVerifier as Serializable)
putExtra(SCRIPT_EXTRA, script)
putExtra(URL_EXTRA, url)
putExtra(ACTION_NAME_EXTRA, actionName)
}
context.startActivity(intent)
}
}
}
class NoopCaptchaCompletionVerifier(private val source: HttpSource): DelegatedHttpSource(source),
CaptchaCompletionVerifier {
class NoopActionCompletionVerifier(private val source: HttpSource): DelegatedHttpSource(source),
ActionCompletionVerifier {
override val versionId get() = source.versionId
override val lang: String get() = source.lang
override fun verifyNoCaptcha(url: String) = false
override fun verifyComplete(url: String) = false
}
interface CaptchaCompletionVerifier : Source {
fun verifyNoCaptcha(url: String): Boolean
interface ActionCompletionVerifier : Source {
fun verifyComplete(url: String): Boolean
}

View File

@ -7,11 +7,11 @@ import android.webkit.WebResourceResponse
import android.webkit.WebView
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
open class HeadersInjectingWebViewClient(activity: SolveCaptchaActivity,
source: CaptchaCompletionVerifier,
open class HeadersInjectingWebViewClient(activity: BrowserActionActivity,
verifyComplete: (String) -> Boolean,
injectScript: String?,
private val headers: Map<String, String>)
: BasicWebViewClient(activity, source, injectScript) {
: BasicWebViewClient(activity, verifyComplete, injectScript) {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
// Temp disabled as it's unreliable

View File

@ -0,0 +1,31 @@
package exh.util
import com.elvishew.xlog.XLog
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import okhttp3.ResponseBody
import org.jsoup.nodes.Document
fun Response.interceptAsHtml(block: (Document) -> Unit): Response {
val body = body()
if(body != null) {
if (body.contentType()?.type() == "text"
&& body.contentType()?.subtype() == "html") {
val bodyString = body.string()
val rebuiltResponse = newBuilder()
.body(ResponseBody.create(body.contentType(), bodyString))
.build()
try {
// Search for captcha
val parsed = asJsoup(html = bodyString)
block(parsed)
} catch (t: Throwable) {
// Ignore all errors
XLog.w("Interception error!", t)
}
return rebuiltResponse
}
}
return this
}