package exh.ui.captcha import android.content.Context import android.content.Intent import android.os.Bundle import android.os.SystemClock import android.view.MotionEvent import android.webkit.CookieManager import android.webkit.JavascriptInterface import android.webkit.JsResult import android.webkit.WebChromeClient import android.webkit.WebView import androidx.appcompat.app.AppCompatActivity import com.afollestad.materialdialogs.MaterialDialog import com.github.salomonbrys.kotson.get import com.github.salomonbrys.kotson.string import com.google.gson.JsonParser import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.online.HttpSource import exh.source.DelegatedHttpSource import exh.util.melt import java.io.Serializable import java.net.URL import java.util.UUID import kotlinx.android.synthetic.main.eh_activity_captcha.toolbar import kotlinx.android.synthetic.main.eh_activity_captcha.webview import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.Request import okhttp3.RequestBody import rx.Observable import rx.Single import rx.schedulers.Schedulers import timber.log.Timber import uy.kohesive.injekt.injectLazy class BrowserActionActivity : AppCompatActivity() { private val sourceManager: SourceManager by injectLazy() private val preferencesHelper: PreferencesHelper by injectLazy() private val networkHelper: NetworkHelper by injectLazy() val httpClient = networkHelper.client private var currentLoopId: String? = null private var validateCurrentLoopId: String? = null private var strictValidationStartTime: Long? = null lateinit var credentialsObservable: Observable override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(eu.kanade.tachiyomi.R.layout.eh_activity_captcha) val sourceId = intent.getLongExtra(SOURCE_ID_EXTRA, -1) val originalSource = if (sourceId != -1L) sourceManager.get(sourceId) else null val source = if (originalSource != null) { originalSource as? ActionCompletionVerifier ?: run { (originalSource as? HttpSource)?.let { NoopActionCompletionVerifier(it) } } } else null val headers = ((source as? HttpSource)?.headers?.toMultimap()?.mapValues { it.value.joinToString(",") } ?: emptyMap()) + (intent.getSerializableExtra(HEADERS_EXTRA) as? HashMap ?: emptyMap()) val cookies: HashMap? = intent.getSerializableExtra(COOKIES_EXTRA) as? HashMap val script: String? = intent.getStringExtra(SCRIPT_EXTRA) val url: String? = intent.getStringExtra(URL_EXTRA) val actionName = intent.getStringExtra(ACTION_NAME_EXTRA) @Suppress("NOT_NULL_ASSERTION_ON_CALLABLE_REFERENCE") val verifyComplete = if (source != null) { source::verifyComplete!! } else intent.getSerializableExtra(VERIFY_LAMBDA_EXTRA) as? (String) -> Boolean if (verifyComplete == null || url == null) { finish() return } val actionStr = actionName ?: "Solve captcha" toolbar.title = if (source != null) { "${source.name}: $actionStr" } else actionStr val parsedUrl = URL(url) val cm = CookieManager.getInstance() cookies?.forEach { (t, u) -> val cookieString = t + "=" + u + "; domain=" + parsedUrl.host cm.setCookie(url, cookieString) } webview.settings.javaScriptEnabled = true webview.settings.domStorageEnabled = true headers.entries.find { it.key.equals("user-agent", true) }?.let { webview.settings.userAgentString = it.value } var loadedInners = 0 webview.webChromeClient = object : WebChromeClient() { override fun onJsAlert(view: WebView?, url: String?, message: String, result: JsResult): Boolean { if (message.startsWith("exh-")) { loadedInners++ // Wait for both inner scripts to be loaded if (loadedInners >= 2) { // Attempt to autosolve captcha if (preferencesHelper.eh_autoSolveCaptchas().getOrDefault()) { webview.post { // 10 seconds to auto-solve captcha strictValidationStartTime = System.currentTimeMillis() + 1000 * 10 beginSolveLoop() beginValidateCaptchaLoop() webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE) { webview.evaluateJavascript(SOLVE_UI_SCRIPT_SHOW, null) } } } } result.confirm() return true } return false } } webview.webViewClient = if (actionName == null && preferencesHelper.eh_autoSolveCaptchas().getOrDefault()) { // Fetch auto-solve credentials early for speed credentialsObservable = httpClient.newCall(Request.Builder() // Rob demo credentials .url("https://speech-to-text-demo.ng.bluemix.net/api/v1/credentials") .build()) .asObservableSuccess() .subscribeOn(Schedulers.io()) .map { val json = JsonParser.parseString(it.body!!.string()) it.close() json["token"].string }.melt() webview.addJavascriptInterface(this@BrowserActionActivity, "exh") AutoSolvingWebViewClient(this, verifyComplete, script, headers) } else { HeadersInjectingWebViewClient(this, verifyComplete, script, headers) } webview.loadUrl(url, headers) setSupportActionBar(toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) } override fun onSupportNavigateUp(): Boolean { finish() return true } fun captchaSolveFail() { currentLoopId = null validateCurrentLoopId = null Timber.e(IllegalStateException("Captcha solve failure!")) runOnUiThread { webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null) MaterialDialog(this) .title(text = "Captcha solve failure") .message(text = "Failed to auto-solve the captcha!") .cancelable(true) .cancelOnTouchOutside(true) .positiveButton(android.R.string.ok) .show() } } @JavascriptInterface fun callback(result: String?, loopId: String, stage: Int) { if (loopId != currentLoopId) return when (stage) { STAGE_CHECKBOX -> { if (result!!.toBoolean()) { webview.postDelayed({ getAudioButtonLocation(loopId) }, 250) } else { webview.postDelayed({ doStageCheckbox(loopId) }, 250) } } STAGE_GET_AUDIO_BTN_LOCATION -> { if (result != null) { val splitResult = result.split(" ").map { it.toFloat() } val origX = splitResult[0] val origY = splitResult[1] val iw = splitResult[2] val ih = splitResult[3] val x = webview.x + origX / iw * webview.width val y = webview.y + origY / ih * webview.height Timber.d("Found audio button coords: %f %f", x, y) simulateClick(x + 50, y + 50) webview.post { doStageDownloadAudio(loopId) } } else { webview.postDelayed({ getAudioButtonLocation(loopId) }, 250) } } STAGE_DOWNLOAD_AUDIO -> { if (result != null) { Timber.d("Got audio URL: $result") performRecognize(result) .observeOn(Schedulers.io()) .subscribe({ Timber.d("Got audio transcript: $it") webview.post { typeResult(loopId, it!! .replace(TRANSCRIPT_CLEANER_REGEX, "") .replace(SPACE_DEDUPE_REGEX, " ") .trim()) } }, { captchaSolveFail() }) } else { webview.postDelayed({ doStageDownloadAudio(loopId) }, 250) } } STAGE_TYPE_RESULT -> { if (result!!.toBoolean()) { // Fail if captcha still not solved after 1.5s strictValidationStartTime = System.currentTimeMillis() + 1500 } else { captchaSolveFail() } } } } fun performRecognize(url: String): Single { return credentialsObservable.flatMap { token -> httpClient.newCall(Request.Builder() .url(url) .build()).asObservableSuccess().map { token to it } }.flatMap { (token, response) -> val audioFile = response.body!!.bytes() httpClient.newCall(Request.Builder() .url("https://stream.watsonplatform.net/speech-to-text/api/v1/recognize".toHttpUrlOrNull()!! .newBuilder() .addQueryParameter("watson-token", token) .build()) .post(MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("jsonDescription", RECOGNIZE_JSON) .addFormDataPart("audio.mp3", "audio.mp3", RequestBody.create("audio/mp3".toMediaTypeOrNull(), audioFile)) .build()) .build()).asObservableSuccess() }.map { response -> JsonParser.parseString(response.body!!.string())["results"][0]["alternatives"][0]["transcript"].string.trim() }.toSingle() } fun doStageCheckbox(loopId: String) { if (loopId != currentLoopId) return webview.evaluateJavascript(""" (function() { $CROSS_WINDOW_SCRIPT_OUTER let exh_cframe = document.querySelector('iframe[role=presentation][name|=a]'); if(exh_cframe != null) { cwmExec(exh_cframe, ` let exh_cb = document.getElementsByClassName('recaptcha-checkbox-checkmark')[0]; if(exh_cb != null) { exh_cb.click(); return "true"; } else { return "false"; } `, function(result) { exh.callback(result, '$loopId', $STAGE_CHECKBOX); }); } else { exh.callback("false", '$loopId', $STAGE_CHECKBOX); } })(); """.trimIndent().replace("\n", ""), null) } fun getAudioButtonLocation(loopId: String) { webview.evaluateJavascript(""" (function() { $CROSS_WINDOW_SCRIPT_OUTER let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]"); if(exh_bframe != null) { let bfb = exh_bframe.getBoundingClientRect(); let iw = window.innerWidth; let ih = window.innerHeight; if(bfb.left < 0 || bfb.top < 0) { exh.callback(null, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION); } else { cwmExec(exh_bframe, ` let exh_ab = document.getElementById("recaptcha-audio-button"); if(exh_ab != null) { let bounds = exh_ab.getBoundingClientRect(); return (${'$'}{bfb.left} + bounds.left) + " " + (${'$'}{bfb.top} + bounds.top) + " " + ${'$'}{iw} + " " + ${'$'}{ih}; } else { return null; } `, function(result) { exh.callback(result, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION); }); } } else { exh.callback(null, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION); } })(); """.trimIndent().replace("\n", ""), null) } fun doStageDownloadAudio(loopId: String) { webview.evaluateJavascript(""" (function() { $CROSS_WINDOW_SCRIPT_OUTER let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]"); if(exh_bframe != null) { cwmExec(exh_bframe, ` let exh_as = document.getElementById("audio-source"); if(exh_as != null) { return exh_as.src; } else { return null; } `, function(result) { exh.callback(result, '$loopId', $STAGE_DOWNLOAD_AUDIO); }); } else { exh.callback(null, '$loopId', $STAGE_DOWNLOAD_AUDIO); } })(); """.trimIndent().replace("\n", ""), null) } fun typeResult(loopId: String, result: String) { webview.evaluateJavascript(""" (function() { $CROSS_WINDOW_SCRIPT_OUTER let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]"); if(exh_bframe != null) { cwmExec(exh_bframe, ` let exh_as = document.getElementById("audio-response"); let exh_vb = document.getElementById("recaptcha-verify-button"); if(exh_as != null && exh_vb != null) { exh_as.value = "$result"; exh_vb.click(); return "true"; } else { return "false"; } `, function(result) { exh.callback(result, '$loopId', $STAGE_TYPE_RESULT); }); } else { exh.callback("false", '$loopId', $STAGE_TYPE_RESULT); } })(); """.trimIndent().replace("\n", ""), null) } fun beginSolveLoop() { val loopId = UUID.randomUUID().toString() currentLoopId = loopId doStageCheckbox(loopId) } @JavascriptInterface fun validateCaptchaCallback(result: Boolean, loopId: String) { if (loopId != validateCurrentLoopId) return if (result) { Timber.d("Captcha solved!") webview.post { webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null) } val asbtn = intent.getStringExtra(ASBTN_EXTRA) if (asbtn != null) { webview.post { webview.evaluateJavascript("(function() {document.querySelector('$asbtn').click();})();", null) } } } else { val savedStrictValidationStartTime = strictValidationStartTime if (savedStrictValidationStartTime != null && System.currentTimeMillis() > savedStrictValidationStartTime) { captchaSolveFail() } else { webview.postDelayed({ runValidateCaptcha(loopId) }, 250) } } } fun runValidateCaptcha(loopId: String) { if (loopId != validateCurrentLoopId) return webview.evaluateJavascript(""" (function() { $CROSS_WINDOW_SCRIPT_OUTER let exh_cframe = document.querySelector('iframe[role=presentation][name|=a]'); if(exh_cframe != null) { cwmExec(exh_cframe, ` let exh_cb = document.querySelector(".recaptcha-checkbox[aria-checked=true]"); if(exh_cb != null) { return true; } else { return false; } `, function(result) { exh.validateCaptchaCallback(result, '$loopId'); }); } else { exh.validateCaptchaCallback(false, '$loopId'); } })(); """.trimIndent().replace("\n", ""), null) } fun beginValidateCaptchaLoop() { val loopId = UUID.randomUUID().toString() validateCurrentLoopId = loopId runValidateCaptcha(loopId) } private fun simulateClick(x: Float, y: Float) { val downTime = SystemClock.uptimeMillis() val eventTime = SystemClock.uptimeMillis() val properties = arrayOfNulls(1) val pp1 = MotionEvent.PointerProperties().apply { id = 0 toolType = MotionEvent.TOOL_TYPE_FINGER } properties[0] = pp1 val pointerCoords = arrayOfNulls(1) val pc1 = MotionEvent.PointerCoords().apply { this.x = x this.y = y pressure = 1f size = 1f } pointerCoords[0] = pc1 var motionEvent = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, 1, properties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0) dispatchTouchEvent(motionEvent) motionEvent.recycle() motionEvent = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, 1, properties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0) dispatchTouchEvent(motionEvent) motionEvent.recycle() } companion object { const val VERIFY_LAMBDA_EXTRA = "verify_lambda_extra" const val SOURCE_ID_EXTRA = "source_id_extra" const val COOKIES_EXTRA = "cookies_extra" const val SCRIPT_EXTRA = "script_extra" const val URL_EXTRA = "url_extra" const val ASBTN_EXTRA = "asbtn_extra" const val ACTION_NAME_EXTRA = "action_name_extra" const val HEADERS_EXTRA = "headers_extra" const val STAGE_CHECKBOX = 0 const val STAGE_GET_AUDIO_BTN_LOCATION = 1 const val STAGE_DOWNLOAD_AUDIO = 2 const val STAGE_TYPE_RESULT = 3 val CROSS_WINDOW_SCRIPT_OUTER = """ function cwmExec(element, code, cb) { console.log(">>> [CWM-Outer] Running: " + code); let runId = Math.random(); if(cb != null) { let listener; listener = function(event) { if(typeof event.data === "string" && event.data.startsWith("exh-")) { let response = JSON.parse(event.data.substring(4)); if(response.id === runId) { cb(response.result); window.removeEventListener('message', listener); console.log(">>> [CWM-Outer] Finished: " + response.id + " ==> " + response.result); } } }; window.addEventListener('message', listener, false); } let runRequest = { id: runId, code: code }; element.contentWindow.postMessage("exh-" + JSON.stringify(runRequest), "*"); } """.trimIndent().replace("\n", "") val CROSS_WINDOW_SCRIPT_INNER = """ window.addEventListener('message', function(event) { if(typeof event.data === "string" && event.data.startsWith("exh-")) { let request = JSON.parse(event.data.substring(4)); console.log(">>> [CWM-Inner] Incoming: " + request.id); let result = eval("(function() {" + request.code + "})();"); let response = { id: request.id, result: result }; console.log(">>> [CWM-Inner] Outgoing: " + response.id + " ==> " + response.result); event.source.postMessage("exh-" + JSON.stringify(response), event.origin); } }, false); console.log(">>> [CWM-Inner] Loaded!"); alert("exh-"); """.trimIndent() val SOLVE_UI_SCRIPT_SHOW = """ (function() { let exh_overlay = document.createElement("div"); exh_overlay.id = "exh_overlay"; exh_overlay.style.zIndex = 2000000001; exh_overlay.style.backgroundColor = "rgba(0, 0, 0, 0.8)"; exh_overlay.style.position = "fixed"; exh_overlay.style.top = 0; exh_overlay.style.left = 0; exh_overlay.style.width = "100%"; exh_overlay.style.height = "100%"; exh_overlay.style.pointerEvents = "none"; document.body.appendChild(exh_overlay); let exh_otext = document.createElement("div"); exh_otext.id = "exh_otext"; exh_otext.style.zIndex = 2000000002; exh_otext.style.position = "fixed"; exh_otext.style.top = "50%"; exh_otext.style.left = 0; exh_otext.style.transform = "translateY(-50%)"; exh_otext.style.color = "white"; exh_otext.style.fontSize = "25pt"; exh_otext.style.pointerEvents = "none"; exh_otext.style.width = "100%"; exh_otext.style.textAlign = "center"; exh_otext.textContent = "Solving captcha..." document.body.appendChild(exh_otext); })(); """.trimIndent() val SOLVE_UI_SCRIPT_HIDE = """ (function() { let exh_overlay = document.getElementById("exh_overlay"); let exh_otext = document.getElementById("exh_otext"); if(exh_overlay != null) exh_overlay.remove(); if(exh_otext != null) exh_otext.remove(); })(); """.trimIndent() val RECOGNIZE_JSON = """ { "part_content_type": "audio/mp3", "keywords": [], "profanity_filter": false, "max_alternatives": 1, "speaker_labels": false, "firstReadyInSession": false, "preserveAdaptation": false, "timestamps": false, "inactivity_timeout": 30, "word_confidence": false, "audioMetrics": false, "latticeGeneration": true, "customGrammarWords": [], "action": "recognize" } """.trimIndent() val TRANSCRIPT_CLEANER_REGEX = Regex("[^0-9a-zA-Z_ -]") val SPACE_DEDUPE_REGEX = Regex(" +") private fun baseIntent(context: Context) = Intent(context, BrowserActionActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } fun launchCaptcha( context: Context, source: ActionCompletionVerifier, cookies: Map, script: String?, url: String, autoSolveSubmitBtnSelector: String? = null ) { val intent = baseIntent(context).apply { putExtra(SOURCE_ID_EXTRA, source.id) putExtra(COOKIES_EXTRA, HashMap(cookies)) putExtra(SCRIPT_EXTRA, script) putExtra(URL_EXTRA, url) putExtra(ASBTN_EXTRA, autoSolveSubmitBtnSelector) } context.startActivity(intent) } fun launchUniversal( context: Context, source: HttpSource, url: String ) { val intent = baseIntent(context).apply { putExtra(SOURCE_ID_EXTRA, source.id) putExtra(URL_EXTRA, url) } context.startActivity(intent) } fun launchUniversal( context: Context, sourceId: Long, url: String ) { val intent = baseIntent(context).apply { putExtra(SOURCE_ID_EXTRA, sourceId) putExtra(URL_EXTRA, url) } context.startActivity(intent) } fun launchAction( context: Context, completionVerifier: ActionCompletionVerifier, script: String?, url: String, actionName: String ) { val intent = baseIntent(context).apply { putExtra(SOURCE_ID_EXTRA, completionVerifier.id) putExtra(SCRIPT_EXTRA, script) putExtra(URL_EXTRA, url) putExtra(ACTION_NAME_EXTRA, actionName) } context.startActivity(intent) } fun launchAction( context: Context, completionVerifier: (String) -> Boolean, script: String?, url: String, actionName: String, headers: Map? = emptyMap() ) { val intent = baseIntent(context).apply { putExtra(HEADERS_EXTRA, HashMap(headers!!)) putExtra(VERIFY_LAMBDA_EXTRA, completionVerifier as Serializable) putExtra(SCRIPT_EXTRA, script) putExtra(URL_EXTRA, url) putExtra(ACTION_NAME_EXTRA, actionName) } context.startActivity(intent) } } } class NoopActionCompletionVerifier(private val source: HttpSource) : DelegatedHttpSource(source), ActionCompletionVerifier { override val versionId get() = source.versionId override val lang: String get() = source.lang override fun verifyComplete(url: String) = false } interface ActionCompletionVerifier : Source { fun verifyComplete(url: String): Boolean }