mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	Test solving Cloudflare's challenge with WebView
This commit is contained in:
		| @@ -7,9 +7,9 @@ import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import okhttp3.HttpUrl | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import java.net.URI | ||||
|  | ||||
| class Myanimelist(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
| @@ -114,23 +114,23 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).delete() | ||||
|         networkService.cookies.remove(URI(BASE_URL)) | ||||
|         networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!) | ||||
|     } | ||||
|  | ||||
|     override val isLogged: Boolean | ||||
|         get() = !getUsername().isEmpty() && | ||||
|                 !getPassword().isEmpty() && | ||||
|                 checkCookies(URI(BASE_URL)) && | ||||
|                 checkCookies() && | ||||
|                 !getCSRF().isEmpty() | ||||
|  | ||||
|     private fun getCSRF(): String = preferences.trackToken(this).getOrDefault() | ||||
|  | ||||
|     private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) | ||||
|  | ||||
|     private fun checkCookies(uri: URI): Boolean { | ||||
|     private fun checkCookies(): Boolean { | ||||
|         var ckCount = 0 | ||||
|  | ||||
|         for (ck in networkService.cookies.get(uri)) { | ||||
|         val url = HttpUrl.parse(BASE_URL)!! | ||||
|         for (ck in networkService.cookieManager.get(url)) { | ||||
|             if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE) | ||||
|                 ckCount++ | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,63 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import android.webkit.CookieManager | ||||
| import android.webkit.CookieSyncManager | ||||
| import okhttp3.Cookie | ||||
| import okhttp3.CookieJar | ||||
| import okhttp3.HttpUrl | ||||
|  | ||||
| class AndroidCookieJar(context: Context) : CookieJar { | ||||
|  | ||||
|     private val manager = CookieManager.getInstance() | ||||
|  | ||||
|     private val syncManager by lazy { CookieSyncManager.createInstance(context) } | ||||
|  | ||||
|     override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) { | ||||
|         val urlString = url.toString() | ||||
|  | ||||
|         for (cookie in cookies) { | ||||
|             manager.setCookie(urlString, cookie.toString()) | ||||
|         } | ||||
|         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { | ||||
|             syncManager.sync() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun loadForRequest(url: HttpUrl): List<Cookie> { | ||||
|         return get(url) | ||||
|     } | ||||
|  | ||||
|     fun get(url: HttpUrl): List<Cookie> { | ||||
|         val cookies = manager.getCookie(url.toString()) | ||||
|  | ||||
|         return if (cookies != null && !cookies.isEmpty()) { | ||||
|             cookies.split(";").mapNotNull { Cookie.parse(url, it) } | ||||
|         } else { | ||||
|             emptyList() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun remove(url: HttpUrl) { | ||||
|         val cookies = manager.getCookie(url.toString()) ?: return | ||||
|         val domain = ".${url.host()}" | ||||
|         cookies.split(";") | ||||
|             .map { it.substringBefore("=") } | ||||
|             .onEach { manager.setCookie(domain, "$it=;Max-Age=-1") } | ||||
|  | ||||
|         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { | ||||
|             syncManager.sync() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun removeAll() { | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { | ||||
|             manager.removeAllCookies {} | ||||
|         } else { | ||||
|             manager.removeAllCookie() | ||||
|             syncManager.sync() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,31 +1,32 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import com.squareup.duktape.Duktape | ||||
| import okhttp3.* | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.Context | ||||
| import android.os.Handler | ||||
| import android.os.HandlerThread | ||||
| import android.webkit.WebResourceResponse | ||||
| import android.webkit.WebView | ||||
| import eu.kanade.tachiyomi.util.WebViewClientCompat | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import timber.log.Timber | ||||
| import java.io.IOException | ||||
| import java.util.concurrent.CountDownLatch | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class CloudflareInterceptor : Interceptor { | ||||
|  | ||||
|     private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""") | ||||
|      | ||||
|     private val passPattern = Regex("""name="pass" value="(.+?)"""") | ||||
|  | ||||
|     private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""") | ||||
|  | ||||
|     private val sPattern = Regex("""name="s" value="([^"]+)""") | ||||
|  | ||||
|     private val kPattern = Regex("""k\s+=\s+'([^']+)';""") | ||||
| class CloudflareInterceptor(private val context: Context) : Interceptor { | ||||
|  | ||||
|     private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") | ||||
|  | ||||
|     private interface IBase64 { | ||||
|         fun decode(input: String): String | ||||
|     } | ||||
|  | ||||
|     private val b64: IBase64 = object : IBase64 { | ||||
|         override fun decode(input: String): String { | ||||
|             return okio.ByteString.decodeBase64(input)!!.utf8() | ||||
|     private val handler by lazy { | ||||
|         val thread = HandlerThread("WebViewThread").apply { | ||||
|             uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, e -> | ||||
|                 Timber.e(e) | ||||
|             } | ||||
|             start() | ||||
|         } | ||||
|         Handler(thread.looper) | ||||
|     } | ||||
|  | ||||
|     @Synchronized | ||||
| @@ -34,8 +35,14 @@ class CloudflareInterceptor : Interceptor { | ||||
|  | ||||
|         // Check if Cloudflare anti-bot is on | ||||
|         if (response.code() == 503 && response.header("Server") in serverCheck) { | ||||
|             return try { | ||||
|                 chain.proceed(resolveChallenge(response)) | ||||
|             try { | ||||
|                 response.close() | ||||
|                 if (resolveWithWebView(chain.request())) { | ||||
|                     // Retry original request | ||||
|                     return chain.proceed(chain.request()) | ||||
|                 } else { | ||||
|                     throw Exception("Failed resolving Cloudflare challenge") | ||||
|                 } | ||||
|             } catch (e: Exception) { | ||||
|                 // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that | ||||
|                 // we don't crash the entire app | ||||
| @@ -46,65 +53,76 @@ class CloudflareInterceptor : Interceptor { | ||||
|         return response | ||||
|     } | ||||
|  | ||||
|     private fun resolveChallenge(response: Response): Request { | ||||
|         Duktape.create().use { duktape -> | ||||
|             val originalRequest = response.request() | ||||
|             val url = originalRequest.url() | ||||
|             val domain = url.host() | ||||
|             val content = response.body()!!.string() | ||||
|     private fun isChallengeResolverUrl(url: String): Boolean { | ||||
|         return "chk_jschl" in url | ||||
|     } | ||||
|  | ||||
|             // CloudFlare requires waiting 4 seconds before resolving the challenge | ||||
|             Thread.sleep(4000) | ||||
|     @SuppressLint("SetJavaScriptEnabled") | ||||
|     private fun resolveWithWebView(request: Request): Boolean { | ||||
|         val latch = CountDownLatch(1) | ||||
|  | ||||
|             val operation = operationPattern.find(content)?.groups?.get(1)?.value | ||||
|             val challenge = challengePattern.find(content)?.groups?.get(1)?.value | ||||
|             val pass = passPattern.find(content)?.groups?.get(1)?.value | ||||
|             val s = sPattern.find(content)?.groups?.get(1)?.value | ||||
|         var result = false | ||||
|         var isResolvingChallenge = false | ||||
|  | ||||
|             // If `k` is null, it uses old methods. | ||||
|             val k = kPattern.find(content)?.groups?.get(1)?.value ?: "" | ||||
|             val innerHTMLValue = Regex("""<div(.*)id="$k"(.*)>(.*)</div>""") | ||||
|                     .find(content)?.groups?.get(3)?.value ?: "" | ||||
|         val requestUrl = request.url().toString() | ||||
|         val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" } | ||||
|  | ||||
|             if (operation == null || challenge == null || pass == null || s == null) { | ||||
|                 throw Exception("Failed resolving Cloudflare challenge") | ||||
|         handler.post { | ||||
|             val view = WebView(context) | ||||
|             view.settings.javaScriptEnabled = true | ||||
|             view.settings.userAgentString = request.header("User-Agent") | ||||
|             view.webViewClient = object : WebViewClientCompat() { | ||||
|  | ||||
|                 override fun shouldInterceptRequestCompat( | ||||
|                         view: WebView, | ||||
|                         url: String | ||||
|                 ): WebResourceResponse? { | ||||
|                     val isChallengeResolverUrl = isChallengeResolverUrl(url) | ||||
|                     if (requestUrl != url && !isChallengeResolverUrl) { | ||||
|                         return WebResourceResponse("text/plain", "UTF-8", null) | ||||
|                     } | ||||
|  | ||||
|                     if (isChallengeResolverUrl) { | ||||
|                         isResolvingChallenge = true | ||||
|                     } | ||||
|                     return null | ||||
|                 } | ||||
|  | ||||
|                 override fun onPageFinished(view: WebView, url: String) { | ||||
|                     super.onPageFinished(view, url) | ||||
|                     if (isResolvingChallenge && url == requestUrl) { | ||||
|                         setResultAndFinish(true) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onReceivedErrorCompat( | ||||
|                         view: WebView, | ||||
|                         errorCode: Int, | ||||
|                         description: String?, | ||||
|                         failingUrl: String, | ||||
|                         isMainFrame: Boolean | ||||
|                 ) { | ||||
|                     if ((errorCode != 503 && requestUrl == failingUrl) || | ||||
|                         isChallengeResolverUrl(failingUrl) | ||||
|                     ) { | ||||
|                         setResultAndFinish(false) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 private fun setResultAndFinish(resolved: Boolean) { | ||||
|                     result = resolved | ||||
|                     latch.countDown() | ||||
|                     view.stopLoading() | ||||
|                     view.destroy() | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Export native Base64 decode function to js object. | ||||
|             duktape.set("b64", IBase64::class.java, b64) | ||||
|  | ||||
|             // Return simulated innerHTML when call DOM. | ||||
|             val simulatedDocumentJS = """var document = { getElementById: function (x) { return { innerHTML: "$innerHTMLValue" }; } }""" | ||||
|  | ||||
|             val js = operation | ||||
|                     .replace(Regex("""a\.value = (.+\.toFixed\(10\);).+"""), "$1") | ||||
|                     .replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "") | ||||
|                     .replace("t.length", "${domain.length}") | ||||
|                     .replace("\n", "") | ||||
|  | ||||
|             val result = duktape.evaluate("""$simulatedDocumentJS;$ATOB_JS;var t="$domain";$js""") as String | ||||
|  | ||||
|             val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!! | ||||
|                     .newBuilder() | ||||
|                     .addQueryParameter("jschl_vc", challenge) | ||||
|                     .addQueryParameter("pass", pass) | ||||
|                     .addQueryParameter("s", s) | ||||
|                     .addQueryParameter("jschl_answer", result) | ||||
|                     .toString() | ||||
|  | ||||
|             val cloudflareHeaders = originalRequest.headers() | ||||
|                     .newBuilder() | ||||
|                     .add("Referer", url.toString()) | ||||
|                     .add("Accept", "text/html,application/xhtml+xml,application/xml") | ||||
|                     .add("Accept-Language", "en") | ||||
|                     .build() | ||||
|  | ||||
|             return GET(cloudflareUrl, cloudflareHeaders, cache = CacheControl.Builder().build()) | ||||
|             view.loadUrl(requestUrl, headers) | ||||
|         } | ||||
|  | ||||
|         latch.await(12, TimeUnit.SECONDS) | ||||
|  | ||||
|         return result | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         // atob() is browser API, Using Android's own function. (java.util.Base64 can't be used because of min API level) | ||||
|         private const val ATOB_JS = """var atob = function (input) { return b64.decode(input) }""" | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -2,11 +2,7 @@ package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import okhttp3.Cache | ||||
| import okhttp3.CipherSuite | ||||
| import okhttp3.ConnectionSpec | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.TlsVersion | ||||
| import okhttp3.* | ||||
| import java.io.File | ||||
| import java.io.IOException | ||||
| import java.net.InetAddress | ||||
| @@ -15,11 +11,7 @@ import java.net.UnknownHostException | ||||
| import java.security.KeyManagementException | ||||
| import java.security.KeyStore | ||||
| import java.security.NoSuchAlgorithmException | ||||
| import javax.net.ssl.SSLContext | ||||
| import javax.net.ssl.SSLSocket | ||||
| import javax.net.ssl.SSLSocketFactory | ||||
| import javax.net.ssl.TrustManagerFactory | ||||
| import javax.net.ssl.X509TrustManager | ||||
| import javax.net.ssl.* | ||||
|  | ||||
| class NetworkHelper(context: Context) { | ||||
|  | ||||
| @@ -27,7 +19,7 @@ class NetworkHelper(context: Context) { | ||||
|  | ||||
|     private val cacheSize = 5L * 1024 * 1024 // 5 MiB | ||||
|  | ||||
|     private val cookieManager = PersistentCookieJar(context) | ||||
|     val cookieManager = AndroidCookieJar(context) | ||||
|  | ||||
|     val client = OkHttpClient.Builder() | ||||
|             .cookieJar(cookieManager) | ||||
| @@ -36,12 +28,9 @@ class NetworkHelper(context: Context) { | ||||
|             .build() | ||||
|  | ||||
|     val cloudflareClient = client.newBuilder() | ||||
|             .addInterceptor(CloudflareInterceptor()) | ||||
|             .addInterceptor(CloudflareInterceptor(context)) | ||||
|             .build() | ||||
|  | ||||
|     val cookies: PersistentCookieStore | ||||
|         get() = cookieManager.store | ||||
|  | ||||
|     private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { | ||||
|         if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { | ||||
|             return this | ||||
|   | ||||
| @@ -1,19 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.content.Context | ||||
| import okhttp3.Cookie | ||||
| import okhttp3.CookieJar | ||||
| import okhttp3.HttpUrl | ||||
|  | ||||
| class PersistentCookieJar(context: Context) : CookieJar { | ||||
|  | ||||
|     val store = PersistentCookieStore(context) | ||||
|  | ||||
|     override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) { | ||||
|         store.addAll(url, cookies) | ||||
|     } | ||||
|  | ||||
|     override fun loadForRequest(url: HttpUrl): List<Cookie> { | ||||
|         return store.get(url) | ||||
|     } | ||||
| } | ||||
| @@ -1,78 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.content.Context | ||||
| import okhttp3.Cookie | ||||
| import okhttp3.HttpUrl | ||||
| import java.net.URI | ||||
| import java.util.concurrent.ConcurrentHashMap | ||||
|  | ||||
| class PersistentCookieStore(context: Context) { | ||||
|  | ||||
|     private val cookieMap = ConcurrentHashMap<String, List<Cookie>>() | ||||
|     private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE) | ||||
|  | ||||
|     init { | ||||
|         for ((key, value) in prefs.all) { | ||||
|             @Suppress("UNCHECKED_CAST") | ||||
|             val cookies = value as? Set<String> | ||||
|             if (cookies != null) { | ||||
|                 try { | ||||
|                     val url = HttpUrl.parse("http://$key") ?: continue | ||||
|                     val nonExpiredCookies = cookies.mapNotNull { Cookie.parse(url, it) } | ||||
|                             .filter { !it.hasExpired() } | ||||
|                     cookieMap.put(key, nonExpiredCookies) | ||||
|                 } catch (e: Exception) { | ||||
|                     // Ignore | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Synchronized | ||||
|     fun addAll(url: HttpUrl, cookies: List<Cookie>) { | ||||
|         val key = url.uri().host | ||||
|  | ||||
|         // Append or replace the cookies for this domain. | ||||
|         val cookiesForDomain = cookieMap[key].orEmpty().toMutableList() | ||||
|         for (cookie in cookies) { | ||||
|             // Find a cookie with the same name. Replace it if found, otherwise add a new one. | ||||
|             val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() } | ||||
|             if (pos == -1) { | ||||
|                 cookiesForDomain.add(cookie) | ||||
|             } else { | ||||
|                 cookiesForDomain[pos] = cookie | ||||
|             } | ||||
|         } | ||||
|         cookieMap.put(key, cookiesForDomain) | ||||
|  | ||||
|         // Get cookies to be stored in disk | ||||
|         val newValues = cookiesForDomain.asSequence() | ||||
|                 .filter { it.persistent() && !it.hasExpired() } | ||||
|                 .map(Cookie::toString) | ||||
|                 .toSet() | ||||
|  | ||||
|         prefs.edit().putStringSet(key, newValues).apply() | ||||
|     } | ||||
|  | ||||
|     @Synchronized | ||||
|     fun removeAll() { | ||||
|         prefs.edit().clear().apply() | ||||
|         cookieMap.clear() | ||||
|     } | ||||
|  | ||||
|     fun remove(uri: URI) { | ||||
|         prefs.edit().remove(uri.host).apply() | ||||
|         cookieMap.remove(uri.host) | ||||
|     } | ||||
|  | ||||
|     fun get(url: HttpUrl) = get(url.uri().host) | ||||
|  | ||||
|     fun get(uri: URI) = get(uri.host) | ||||
|  | ||||
|     private fun get(url: String): List<Cookie> { | ||||
|         return cookieMap[url].orEmpty().filter { !it.hasExpired() } | ||||
|     } | ||||
|  | ||||
|     private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt() | ||||
|  | ||||
| } | ||||
| @@ -43,7 +43,7 @@ class SettingsAdvancedController : SettingsController() { | ||||
|             titleRes = R.string.pref_clear_cookies | ||||
|  | ||||
|             onClick { | ||||
|                 network.cookies.removeAll() | ||||
|                 network.cookieManager.removeAll() | ||||
|                 activity?.toast(R.string.cookies_cleared) | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,83 @@ | ||||
| package eu.kanade.tachiyomi.util | ||||
|  | ||||
| import android.annotation.TargetApi | ||||
| import android.os.Build | ||||
| import android.webkit.* | ||||
|  | ||||
| @Suppress("OverridingDeprecatedMember") | ||||
| abstract class WebViewClientCompat : WebViewClient() { | ||||
|  | ||||
|     open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? { | ||||
|         return null | ||||
|     } | ||||
|  | ||||
|     open fun onReceivedErrorCompat( | ||||
|             view: WebView, | ||||
|             errorCode: Int, | ||||
|             description: String?, | ||||
|             failingUrl: String, | ||||
|             isMainFrame: Boolean) { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     @TargetApi(Build.VERSION_CODES.N) | ||||
|     final override fun shouldOverrideUrlLoading( | ||||
|             view: WebView, | ||||
|             request: WebResourceRequest | ||||
|     ): Boolean { | ||||
|         return shouldOverrideUrlCompat(view, request.url.toString()) | ||||
|     } | ||||
|  | ||||
|     final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { | ||||
|         return shouldOverrideUrlCompat(view, url) | ||||
|     } | ||||
|  | ||||
|     @TargetApi(Build.VERSION_CODES.LOLLIPOP) | ||||
|     final override fun shouldInterceptRequest( | ||||
|             view: WebView, | ||||
|             request: WebResourceRequest | ||||
|     ): WebResourceResponse? { | ||||
|         return shouldInterceptRequestCompat(view, request.url.toString()) | ||||
|     } | ||||
|  | ||||
|     final override fun shouldInterceptRequest( | ||||
|             view: WebView, | ||||
|             url: String | ||||
|     ): WebResourceResponse? { | ||||
|         return shouldInterceptRequestCompat(view, url) | ||||
|     } | ||||
|  | ||||
|     @TargetApi(Build.VERSION_CODES.M) | ||||
|     final override fun onReceivedError( | ||||
|             view: WebView, | ||||
|             request: WebResourceRequest, | ||||
|             error: WebResourceError | ||||
|     ) { | ||||
|         onReceivedErrorCompat(view, error.errorCode, error.description?.toString(), | ||||
|                 request.url.toString(), request.isForMainFrame) | ||||
|     } | ||||
|  | ||||
|     final override fun onReceivedError( | ||||
|             view: WebView, | ||||
|             errorCode: Int, | ||||
|             description: String?, | ||||
|             failingUrl: String | ||||
|     ) { | ||||
|         onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url) | ||||
|     } | ||||
|  | ||||
|     @TargetApi(Build.VERSION_CODES.M) | ||||
|     final override fun onReceivedHttpError( | ||||
|             view: WebView, | ||||
|             request: WebResourceRequest, | ||||
|             error: WebResourceResponse | ||||
|     ) { | ||||
|         onReceivedErrorCompat(view, error.statusCode, error.reasonPhrase, request.url | ||||
|             .toString(), request.isForMainFrame) | ||||
|     } | ||||
|  | ||||
| } | ||||
		Reference in New Issue
	
	Block a user