diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index b1417fe92..f106b3b7f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -17,12 +17,13 @@ class NetworkHelper(context: Context) { private val preferences: PreferencesHelper by injectLazy() private val cacheDir = File(context.cacheDir, "network_cache") - private val cacheSize = 5L * 1024 * 1024 // 5 MiB val cookieManager = AndroidCookieJar() - private val http103Interceptor = Http103Interceptor(context) + private val userAgentInterceptor by lazy { UserAgentInterceptor() } + private val http103Interceptor by lazy { Http103Interceptor(context) } + private val cloudflareInterceptor by lazy { CloudflareInterceptor(context) } private val baseClientBuilder: OkHttpClient.Builder get() { @@ -32,7 +33,7 @@ class NetworkHelper(context: Context) { .readTimeout(30, TimeUnit.SECONDS) .callTimeout(2, TimeUnit.MINUTES) // .fastFallback(true) // TODO: re-enable when OkHttp 5 is stabler - .addInterceptor(UserAgentInterceptor()) + .addInterceptor(userAgentInterceptor) .addNetworkInterceptor(http103Interceptor) if (preferences.verboseLogging()) { @@ -64,7 +65,7 @@ class NetworkHelper(context: Context) { @Suppress("UNUSED") val cloudflareClient by lazy { client.newBuilder() - .addInterceptor(CloudflareInterceptor(context)) + .addInterceptor(cloudflareInterceptor) .build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt index 2e43e01b7..683732a74 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt @@ -2,17 +2,12 @@ package eu.kanade.tachiyomi.network.interceptor import android.annotation.SuppressLint import android.content.Context -import android.os.Build -import android.webkit.WebSettings import android.webkit.WebView import android.widget.Toast import androidx.core.content.ContextCompat import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.util.lang.launchUI -import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.WebViewClientCompat -import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.isOutdated import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.toast @@ -26,56 +21,26 @@ import java.io.IOException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -class CloudflareInterceptor(private val context: Context) : Interceptor { +class CloudflareInterceptor(private val context: Context) : WebViewInterceptor(context) { private val executor = ContextCompat.getMainExecutor(context) private val networkHelper: NetworkHelper by injectLazy() - /** - * When this is called, it initializes the WebView if it wasn't already. We use this to avoid - * blocking the main thread too much. If used too often we could consider moving it to the - * Application class. - */ - private val initWebView by lazy { - // Crashes on some devices. We skip this in some cases since the only impact is slower - // WebView init in those rare cases. - // See https://bugs.chromium.org/p/chromium/issues/detail?id=1279562 - if (DeviceUtil.isMiui || Build.VERSION.SDK_INT == Build.VERSION_CODES.S && DeviceUtil.isSamsung) { - return@lazy - } - - WebSettings.getDefaultUserAgent(context) + override fun shouldIntercept(response: Response): Boolean { + // Check if Cloudflare anti-bot is on + return response.code in ERROR_CODES && response.header("Server") in SERVER_CHECK } - @Synchronized - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest = chain.request() - - if (!WebViewUtil.supportsWebView(context)) { - launchUI { - context.toast(R.string.information_webview_required, Toast.LENGTH_LONG) - } - return chain.proceed(originalRequest) - } - - initWebView - - val response = chain.proceed(originalRequest) - - // Check if Cloudflare anti-bot is on - if (response.code !in ERROR_CODES || response.header("Server") !in SERVER_CHECK) { - return response - } - + override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response { try { response.close() - networkHelper.cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0) - val oldCookie = networkHelper.cookieManager.get(originalRequest.url) + networkHelper.cookieManager.remove(request.url, COOKIE_NAMES, 0) + val oldCookie = networkHelper.cookieManager.get(request.url) .firstOrNull { it.name == "cf_clearance" } - resolveWithWebView(originalRequest, oldCookie) + resolveWithWebView(request, oldCookie) - return chain.proceed(originalRequest) + return chain.proceed(request) } // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that // we don't crash the entire app @@ -87,7 +52,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { } @SuppressLint("SetJavaScriptEnabled") - private fun resolveWithWebView(request: Request, oldCookie: Cookie?) { + private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) { // We need to lock this thread until the WebView finds the challenge solution url, because // OkHttp doesn't support asynchronous interceptors. val latch = CountDownLatch(1) @@ -98,8 +63,8 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { var cloudflareBypassed = false var isWebViewOutdated = false - val origRequestUrl = request.url.toString() - val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap() + val origRequestUrl = originalRequest.url.toString() + val headers = originalRequest.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap() executor.execute { val webview = WebView(context) @@ -107,7 +72,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { webview.setDefaultSettings() // Avoid sending empty User-Agent, Chromium WebView will reset to default if empty - webview.settings.userAgentString = request.header("User-Agent") + webview.settings.userAgentString = originalRequest.header("User-Agent") ?: networkHelper.defaultUserAgent webview.webViewClient = object : WebViewClientCompat() { @@ -175,12 +140,10 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { throw CloudflareBypassException() } } - - companion object { - private val ERROR_CODES = listOf(403, 503) - private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") - private val COOKIE_NAMES = listOf("cf_clearance") - } } +private val ERROR_CODES = listOf(403, 503) +private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") +private val COOKIE_NAMES = listOf("cf_clearance") + private class CloudflareBypassException : Exception() diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt index 081555b18..8a7e66491 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt @@ -2,67 +2,31 @@ package eu.kanade.tachiyomi.network.interceptor import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.webkit.JavascriptInterface -import android.webkit.WebSettings import android.webkit.WebView -import android.widget.Toast import androidx.core.content.ContextCompat -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.util.lang.launchUI -import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.WebViewClientCompat -import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.logcat -import eu.kanade.tachiyomi.util.system.toast import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody -import uy.kohesive.injekt.injectLazy import java.io.IOException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -// TODO: Remove when OkHttp can handle http 103 responses -class Http103Interceptor(private val context: Context) : Interceptor { +// TODO: Remove when OkHttp can handle HTTP 103 responses +class Http103Interceptor(context: Context) : WebViewInterceptor(context) { private val executor = ContextCompat.getMainExecutor(context) - private val networkHelper: NetworkHelper by injectLazy() - - /** - * When this is called, it initializes the WebView if it wasn't already. We use this to avoid - * blocking the main thread too much. If used too often we could consider moving it to the - * Application class. - */ - private val initWebView by lazy { - // Crashes on some devices. We skip this in some cases since the only impact is slower - // WebView init in those rare cases. - // See https://bugs.chromium.org/p/chromium/issues/detail?id=1279562 - if (DeviceUtil.isMiui || Build.VERSION.SDK_INT == Build.VERSION_CODES.S && DeviceUtil.isSamsung) { - return@lazy - } - - WebSettings.getDefaultUserAgent(context) + override fun shouldIntercept(response: Response): Boolean { + return response.code == 103 } - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - val response = chain.proceed(request) - if (response.code != 103) return response - if (!WebViewUtil.supportsWebView(context)) { - launchUI { - context.toast(R.string.information_webview_required, Toast.LENGTH_LONG) - } - return response - } - - initWebView - + override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response { logcat { "Proceeding with WebView for request $request" } try { return proceedWithWebView(request, response) @@ -71,23 +35,9 @@ class Http103Interceptor(private val context: Context) : Interceptor { } } - internal class JsInterface(private val latch: CountDownLatch, var payload: String? = null) { - @JavascriptInterface - fun passPayload(passedPayload: String) { - payload = passedPayload - latch.countDown() - } - } - - companion object { - const val jsScript = "window.android.passPayload(document.querySelector('html').outerHTML)" - - val htmlMediaType = "text/html".toMediaType() - } - @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") - private fun proceedWithWebView(ogRequest: Request, ogResponse: Response): Response { - // We need to lock this thread until the WebView finds the challenge solution url, because + private fun proceedWithWebView(originalRequest: Request, originalResponse: Response): Response { + // We need to lock this thread until the WebView loads the page, because // OkHttp doesn't support asynchronous interceptors. val latch = CountDownLatch(1) @@ -97,16 +47,11 @@ class Http103Interceptor(private val context: Context) : Interceptor { var exception: Exception? = null - val requestUrl = ogRequest.url.toString() - val headers = ogRequest.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap() + val requestUrl = originalRequest.url.toString() + val headers = originalRequest.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap() executor.execute { - val webview = WebView(context).also { outerWebView = it } - with(webview.settings) { - javaScriptEnabled = true - userAgentString = ogRequest.header("User-Agent") ?: networkHelper.defaultUserAgent - } - + val webview = createWebView(originalRequest).also { outerWebView = it } webview.addJavascriptInterface(jsInterface, "android") webview.webViewClient = object : WebViewClientCompat() { @@ -143,13 +88,25 @@ class Http103Interceptor(private val context: Context) : Interceptor { exception?.let { throw it } - val payload = jsInterface.payload ?: throw Exception("Couldn't fetch site through webview") + val responseHtml = jsInterface.responseHtml ?: throw Exception("Couldn't fetch site through webview") - return ogResponse.newBuilder() + return originalResponse.newBuilder() .code(200) .protocol(Protocol.HTTP_1_1) .message("OK") - .body(payload.toResponseBody(htmlMediaType)) + .body(responseHtml.toResponseBody(htmlMediaType)) .build() } } + +internal class JsInterface(private val latch: CountDownLatch, var responseHtml: String? = null) { + @Suppress("UNUSED") + @JavascriptInterface + fun passPayload(passedPayload: String) { + responseHtml = passedPayload + latch.countDown() + } +} + +private const val jsScript = "window.android.passPayload(document.querySelector('html').outerHTML)" +private val htmlMediaType = "text/html".toMediaType() diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt new file mode 100644 index 000000000..4955c47f5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt @@ -0,0 +1,68 @@ +package eu.kanade.tachiyomi.network.interceptor + +import android.content.Context +import android.os.Build +import android.webkit.WebSettings +import android.webkit.WebView +import android.widget.Toast +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.system.DeviceUtil +import eu.kanade.tachiyomi.util.system.WebViewUtil +import eu.kanade.tachiyomi.util.system.setDefaultSettings +import eu.kanade.tachiyomi.util.system.toast +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy + +abstract class WebViewInterceptor(private val context: Context) : Interceptor { + + private val networkHelper: NetworkHelper by injectLazy() + + /** + * When this is called, it initializes the WebView if it wasn't already. We use this to avoid + * blocking the main thread too much. If used too often we could consider moving it to the + * Application class. + */ + private val initWebView by lazy { + // Crashes on some devices. We skip this in some cases since the only impact is slower + // WebView init in those rare cases. + // See https://bugs.chromium.org/p/chromium/issues/detail?id=1279562 + if (DeviceUtil.isMiui || Build.VERSION.SDK_INT == Build.VERSION_CODES.S && DeviceUtil.isSamsung) { + return@lazy + } + + WebSettings.getDefaultUserAgent(context) + } + + abstract fun shouldIntercept(response: Response): Boolean + + abstract fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + if (!shouldIntercept(response)) { + return response + } + + if (!WebViewUtil.supportsWebView(context)) { + launchUI { + context.toast(R.string.information_webview_required, Toast.LENGTH_LONG) + } + return response + } + initWebView + + return intercept(chain, request, response) + } + + fun createWebView(request: Request): WebView { + val webview = WebView(context) + webview.setDefaultSettings() + webview.settings.userAgentString = request.header("User-Agent") ?: networkHelper.defaultUserAgent + return webview + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt index 7cc8f3fba..901460aab 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -11,7 +11,7 @@ import logcat.LogPriority object WebViewUtil { const val SPOOF_PACKAGE_NAME = "org.chromium.chrome" - const val MINIMUM_WEBVIEW_VERSION = 99 + const val MINIMUM_WEBVIEW_VERSION = 100 fun supportsWebView(context: Context): Boolean { try {