From 81f49f34efe046bbf7cd63cbda1fea1521434a4d Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Thu, 1 Sep 2022 00:48:25 +0600 Subject: [PATCH] Manually handle HTTP 103 through WebView (#7907) * Manually handle HTTP 103 through WebView * Review Changes --- .../kanade/tachiyomi/network/NetworkHelper.kt | 4 + .../network/interceptor/Http103Interceptor.kt | 155 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt 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 9dfd2f82bf..b1417fe921 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.network import android.content.Context import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor +import eu.kanade.tachiyomi.network.interceptor.Http103Interceptor import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor import okhttp3.Cache import okhttp3.OkHttpClient @@ -21,6 +22,8 @@ class NetworkHelper(context: Context) { val cookieManager = AndroidCookieJar() + private val http103Interceptor = Http103Interceptor(context) + private val baseClientBuilder: OkHttpClient.Builder get() { val builder = OkHttpClient.Builder() @@ -30,6 +33,7 @@ class NetworkHelper(context: Context) { .callTimeout(2, TimeUnit.MINUTES) // .fastFallback(true) // TODO: re-enable when OkHttp 5 is stabler .addInterceptor(UserAgentInterceptor()) + .addNetworkInterceptor(http103Interceptor) if (preferences.verboseLogging()) { val httpLoggingInterceptor = HttpLoggingInterceptor().apply { 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 new file mode 100644 index 0000000000..081555b183 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt @@ -0,0 +1,155 @@ +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 { + + 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 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 + + logcat { "Proceeding with WebView for request $request" } + try { + return proceedWithWebView(request, response) + } catch (e: Exception) { + throw IOException(e) + } + } + + 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 + // OkHttp doesn't support asynchronous interceptors. + val latch = CountDownLatch(1) + + val jsInterface = JsInterface(latch) + + var outerWebView: WebView? = null + + var exception: Exception? = null + + val requestUrl = ogRequest.url.toString() + val headers = ogRequest.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 + } + + webview.addJavascriptInterface(jsInterface, "android") + + webview.webViewClient = object : WebViewClientCompat() { + override fun onPageFinished(view: WebView, url: String) { + view.evaluateJavascript(jsScript) {} + } + + override fun onReceivedErrorCompat( + view: WebView, + errorCode: Int, + description: String?, + failingUrl: String, + isMainFrame: Boolean, + ) { + if (isMainFrame) { + exception = Exception("Error $errorCode - $description") + latch.countDown() + } + } + } + + webview.loadUrl(requestUrl, headers) + } + + latch.await(10, TimeUnit.SECONDS) + + executor.execute { + outerWebView?.run { + stopLoading() + destroy() + } + outerWebView = null + } + + exception?.let { throw it } + + val payload = jsInterface.payload ?: throw Exception("Couldn't fetch site through webview") + + return ogResponse.newBuilder() + .code(200) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .body(payload.toResponseBody(htmlMediaType)) + .build() + } +}