diff --git a/app/src/main/assets/captcha.html b/app/src/main/assets/captcha.html index 8c7dbcb252..5bb9ef0b95 100644 --- a/app/src/main/assets/captcha.html +++ b/app/src/main/assets/captcha.html @@ -18,48 +18,147 @@ #gt diff --git a/app/src/main/java/one/mixin/android/widget/CaptchaLoadDecision.kt b/app/src/main/java/one/mixin/android/widget/CaptchaLoadDecision.kt new file mode 100644 index 0000000000..e9d2167757 --- /dev/null +++ b/app/src/main/java/one/mixin/android/widget/CaptchaLoadDecision.kt @@ -0,0 +1,42 @@ +package one.mixin.android.widget + +internal enum class CaptchaLoadEvent { + PageFinished, + SdkLoaded, + WidgetRendered, + ChallengeReady, + FatalError, +} + +internal sealed interface CaptchaLoadAction { + data object KeepWatching : CaptchaLoadAction + + data object RestartWatchdog : CaptchaLoadAction + + data class SwitchTo(val captchaType: CaptchaView.CaptchaType) : CaptchaLoadAction + + data object Stop : CaptchaLoadAction +} + +internal fun decideCaptchaLoadAction( + event: CaptchaLoadEvent, + captchaType: CaptchaView.CaptchaType, + failureCount: Int, + maxFailureCount: Int, + fallbackEnabled: Boolean, +): CaptchaLoadAction = + when (event) { + CaptchaLoadEvent.PageFinished -> CaptchaLoadAction.KeepWatching + CaptchaLoadEvent.SdkLoaded, + CaptchaLoadEvent.WidgetRendered, + -> CaptchaLoadAction.RestartWatchdog + + CaptchaLoadEvent.ChallengeReady -> CaptchaLoadAction.RestartWatchdog + CaptchaLoadEvent.FatalError -> { + if (!fallbackEnabled || failureCount >= maxFailureCount) { + CaptchaLoadAction.Stop + } else { + CaptchaLoadAction.SwitchTo(captchaType.fallback()) + } + } + } diff --git a/app/src/main/java/one/mixin/android/widget/CaptchaView.kt b/app/src/main/java/one/mixin/android/widget/CaptchaView.kt index 6c215830b6..aa65cc1d1f 100644 --- a/app/src/main/java/one/mixin/android/widget/CaptchaView.kt +++ b/app/src/main/java/one/mixin/android/widget/CaptchaView.kt @@ -6,7 +6,9 @@ import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.graphics.drawable.GradientDrawable +import android.net.Uri import android.net.http.SslError +import android.os.SystemClock import android.view.Gravity import android.view.ViewGroup import android.view.Window @@ -62,6 +64,9 @@ internal fun captchaDialogBarStyle() = class CaptchaView(private val context: Context, private val callback: Callback) { companion object { private const val WEB_VIEW_TIME_OUT = 35000L + private const val MAX_CAPTCHA_CYCLES = 2 + private const val CAPTCHA_TYPES_PER_CYCLE = 3 + private const val MAX_CAPTCHA_FAILURES = MAX_CAPTCHA_CYCLES * CAPTCHA_TYPES_PER_CYCLE private const val DIALOG_HORIZONTAL_MARGIN_DP = 20 private const val CAPTCHA_CONTENT_MAX_HEIGHT_DP = 560 @@ -69,6 +74,16 @@ class CaptchaView(private val context: Context, private val callback: Callback) private const val TAG = "CaptchaView" + private const val EVENT_PROGRESS = "progress" + private const val EVENT_READY = "ready" + private const val EVENT_ERROR = "error" + private const val EVENT_CANCEL = "cancel" + private const val STAGE_PAGE_LOADING = "page_loading" + private const val STAGE_PAGE_FINISHED = "page_finished" + private const val STAGE_SDK_LOADED = "sdk_loaded" + private const val STAGE_WIDGET_RENDERED = "widget_rendered" + private const val STAGE_CHALLENGE_READY = "challenge_ready" + const val reCAPTCHA = "reCAPTCHA" const val hCAPTCHA = "hCaptcha" const val gtCAPTCHA = "GeeTest" @@ -76,7 +91,22 @@ class CaptchaView(private val context: Context, private val callback: Callback) private var captchaDialog: Dialog? = null private var released = false - private val timedOutCaptchaTypes = mutableSetOf() + private val captchaFailureHistory = mutableListOf() + private var nextCaptchaLoadId = 0L + private var activeCaptchaLoad: CaptchaLoadState? = null + + private data class CaptchaLoadState( + val id: String, + val type: CaptchaType, + val fallbackEnabled: Boolean, + val startedAt: Long, + val documentUrl: String, + val scriptOnloadCallback: String, + var stage: String, + var settled: Boolean = false, + var timeoutRunnable: Runnable? = null, + var lastWebError: String? = null, + ) private val captchaContentHeight by lazy { val barHeight = captchaDialogBarStyle().heightDp.dp @@ -178,11 +208,6 @@ class CaptchaView(private val context: Context, private val callback: Callback) val webView: WebView by webViewLazy - private val stopWebViewRunnable = Runnable { handleCaptchaTimeout() } - - private var captchaType = CaptchaType.GCaptcha - private var fallbackEnabled = true - fun loadCaptcha(captchaType: CaptchaType) = loadCaptcha(captchaType, true, true) fun loadCaptchaWithoutFallback(captchaType: CaptchaType) = loadCaptcha(captchaType, true, false) @@ -193,11 +218,10 @@ class CaptchaView(private val context: Context, private val callback: Callback) fallbackEnabled: Boolean, ) { if (released) return - this.captchaType = captchaType - this.fallbackEnabled = fallbackEnabled if (resetTimeoutFallbacks) { - timedOutCaptchaTypes.clear() + captchaFailureHistory.clear() } + val load = startCaptchaLoad(captchaType, fallbackEnabled) show() val isG = captchaType.isG() val isH = captchaType.isH() @@ -211,6 +235,7 @@ class CaptchaView(private val context: Context, private val callback: Callback) newProgress: Int, ) { super.onProgressChanged(view, newProgress) + if (!isActiveCaptchaLoad(load.id)) return updateProgress(newProgress) } } @@ -221,8 +246,9 @@ class CaptchaView(private val context: Context, private val callback: Callback) url: String?, ) { super.onPageFinished(view, url) + if (!isActiveCaptchaLoad(load.id) || url != load.documentUrl) return + handleCaptchaLoadEvent(load.id, CaptchaLoadEvent.PageFinished, STAGE_PAGE_FINISHED) if (isGT) view?.evaluateJavascript("initGTCaptcha()") {} - cancelRunOnUiThread(stopWebViewRunnable) view?.translationY(0f) updateProgress(100) } @@ -233,9 +259,17 @@ class CaptchaView(private val context: Context, private val callback: Callback) errorResponse: WebResourceResponse?, ) { super.onReceivedHttpError(view, request, errorResponse) - val message = "$TAG load $captchaType onReceivedHttpError ${errorResponse?.statusCode} ${errorResponse?.reasonPhrase}" - Timber.e(message) - reportException(CaptchaException(message)) + val state = activeCaptchaLoad(load.id) ?: return + if (isStaleCaptchaRequest(state, request)) return + val detail = + "status=${errorResponse?.statusCode}" + + " mainFrame=${request?.isForMainFrame}" + + " resource=${captchaResource(request)}" + if (isCriticalCaptchaRequest(state, request)) { + failCaptchaLoad(load.id, "http_error", detail) + } else { + logCaptchaWebError(state, "http_error", detail) + } } override fun onReceivedSslError( @@ -243,10 +277,15 @@ class CaptchaView(private val context: Context, private val callback: Callback) handler: SslErrorHandler?, error: SslError?, ) { - super.onReceivedSslError(view, handler, error) - val message = "$TAG load $captchaType onReceivedSslError ${error?.toString()}" - Timber.e(message) - reportException(CaptchaException(message)) + handler?.cancel() + val state = activeCaptchaLoad(load.id) ?: return + if (isStaleCaptchaApiUrl(state, error?.url)) return + val detail = "code=${error?.primaryError} resource=${captchaResource(error?.url)}" + if (isCriticalCaptchaUrl(state, error?.url)) { + failCaptchaLoad(load.id, "ssl_error", detail) + } else { + logCaptchaWebError(state, "ssl_error", detail) + } } override fun onReceivedError( @@ -255,9 +294,18 @@ class CaptchaView(private val context: Context, private val callback: Callback) error: WebResourceError?, ) { super.onReceivedError(view, request, error) - val message = "$TAG load $captchaType onReceivedError ${error?.errorCode} ${error?.description}" - Timber.e(message) - reportException(CaptchaException(message)) + val state = activeCaptchaLoad(load.id) ?: return + if (isStaleCaptchaRequest(state, request)) return + val detail = + "code=${error?.errorCode}" + + " mainFrame=${request?.isForMainFrame}" + + " resource=${captchaResource(request)}" + + " description=${error?.description}" + if (isCriticalCaptchaRequest(state, request)) { + failCaptchaLoad(load.id, "resource_error", detail) + } else { + logCaptchaWebError(state, "resource_error", detail) + } } override fun shouldInterceptRequest( @@ -269,15 +317,19 @@ class CaptchaView(private val context: Context, private val callback: Callback) val inputStream = context.assets.open("gt4.js") return WebResourceResponse("application/javascript", "UTF-8", inputStream) } catch (e: Exception) { - Timber.e(e, "$TAG load $captchaType intercept local gt4.js failed") - reportException(e) + Timber.e(e, "$TAG load ${load.type} intercept local gt4.js failed") + webView.post { + failCaptchaLoad(load.id, "script_load", "local_gt4") + } } } return super.shouldInterceptRequest(view, request) } } - val input = context.assets.open("captcha.html") - var html = input.source().buffer().readByteString().string(Charset.forName("utf-8")) + var html = + context.assets.open("captcha.html").use { input -> + input.source().buffer().readByteString().string(Charset.forName("utf-8")) + } val apiKey = when { isG -> BuildConfig.RECAPTCHA_KEY isH -> BuildConfig.HCAPTCHA_KEY @@ -286,17 +338,32 @@ class CaptchaView(private val context: Context, private val callback: Callback) } html = html.replace("#apiKey", apiKey) + html = html.replace("#loadId", load.id) when { - isG -> html = html.replace("#src", "https://www.recaptcha.net/recaptcha/api.js?onload=onGCaptchaLoad&render=explicit") - isH -> html = html.replace("#src", "https://js.hcaptcha.com/1/api.js?onload=onHCaptchaLoad&render=explicit") + isG -> { + html = html.replace("onGCaptchaLoad", load.scriptOnloadCallback) + html = html.replace( + "#src", + "https://www.recaptcha.net/recaptcha/api.js?onload=${load.scriptOnloadCallback}&render=explicit", + ) + } + + isH -> { + html = html.replace("onHCaptchaLoad", load.scriptOnloadCallback) + html = html.replace( + "#src", + "https://js.hcaptcha.com/1/api.js?onload=${load.scriptOnloadCallback}&render=explicit", + ) + } + else -> html = html.replace("#src", "") } - if (isGT) { - val gt4Input = context.assets.open("gt4.js") - val gt4Content = gt4Input.source().buffer().readByteString().string(Charset.forName("utf-8")) - gt4Input.close() + val gt4Content = + context.assets.open("gt4.js").use { input -> + input.source().buffer().readByteString().string(Charset.forName("utf-8")) + } html = html.replace( "#gt", """