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", """