package exh.ui.captcha import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle import android.support.annotation.RequiresApi import android.support.v7.app.AppCompatActivity import android.webkit.* import com.github.salomonbrys.kotson.get import com.github.salomonbrys.kotson.string import com.google.gson.JsonParser import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import kotlinx.android.synthetic.main.eh_activity_captcha.* import okhttp3.* import rx.Single import rx.schedulers.Schedulers import timber.log.Timber import uy.kohesive.injekt.injectLazy import java.net.URL import java.util.* import android.view.MotionEvent import android.os.SystemClock import com.afollestad.materialdialogs.MaterialDialog import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import exh.util.melt import rx.Observable class SolveCaptchaActivity : AppCompatActivity() { private val sourceManager: SourceManager by injectLazy() private val preferencesHelper: PreferencesHelper by injectLazy() val httpClient = OkHttpClient() private val jsonParser = JsonParser() 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 source = if(sourceId != -1L) sourceManager.get(sourceId) as? CaptchaCompletionVerifier else null val cookies: HashMap? = intent.getSerializableExtra(COOKIES_EXTRA) as? HashMap val script: String? = intent.getStringExtra(SCRIPT_EXTRA) val url: String? = intent.getStringExtra(URL_EXTRA) if(source == null || cookies == null || url == null) { finish() return } toolbar.title = source.name + ": Solve captcha" val parsedUrl = URL(url) val cm = CookieManager.getInstance() fun continueLoading() { cookies.forEach { (t, u) -> val cookieString = t + "=" + u + "; domain=" + parsedUrl.host cm.setCookie(url, cookieString) } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) CookieSyncManager.createInstance(this).sync() webview.settings.javaScriptEnabled = true webview.settings.domStorageEnabled = true 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() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 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 (preferencesHelper.eh_autoSolveCaptchas().getOrDefault() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // 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.parse(it.body()!!.string()) it.close() json["token"].string }.melt() webview.addJavascriptInterface(this@SolveCaptchaActivity, "exh") AutoSolvingWebViewClient(this, source, script) } else { BasicWebViewClient(this, source, script) } webview.loadUrl(url) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { cm.removeAllCookies { continueLoading() } } else { cm.removeAllCookie() continueLoading() } setSupportActionBar(toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) } override fun onSupportNavigateUp(): Boolean { finish() return true } @RequiresApi(Build.VERSION_CODES.KITKAT) fun captchaSolveFail() { currentLoopId = null validateCurrentLoopId = null Timber.e(IllegalStateException("Captcha solve failure!")) runOnUiThread { webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null) MaterialDialog.Builder(this) .title("Captcha solve failure") .content("Failed to auto-solve the captcha!") .cancelable(true) .canceledOnTouchOutside(true) .positiveText("Ok") .show() } } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) @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(HttpUrl.parse("https://stream.watsonplatform.net/speech-to-text/api/v1/recognize")!! .newBuilder() .addQueryParameter("watson-token", token) .build()) .post(MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("jsonDescription", RECOGNIZE_JSON) .addFormDataPart("audio.mp3", "audio.mp3", RequestBody.create(MediaType.parse("audio/mp3"), audioFile)) .build()) .build()).asObservableSuccess() }.map { response -> jsonParser.parse(response.body()!!.string())["results"][0]["alternatives"][0]["transcript"].string.trim() }.toSingle() } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 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) } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 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) } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 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) } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 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) } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) fun beginSolveLoop() { val loopId = UUID.randomUUID().toString() currentLoopId = loopId doStageCheckbox(loopId) } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) @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) } } } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 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) } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 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 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 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(" +") fun launch(context: Context, source: CaptchaCompletionVerifier, cookies: Map, script: String, url: String, autoSolveSubmitBtnSelector: String? = null) { val intent = Intent(context, SolveCaptchaActivity::class.java).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) } } } interface CaptchaCompletionVerifier : Source { fun verifyNoCaptcha(url: String): Boolean }