diff --git a/app/src/main/java/com/futo/platformplayer/activities/CaptchaActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/CaptchaActivity.kt index ffac8489..e0b2581e 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/CaptchaActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/CaptchaActivity.kt @@ -10,7 +10,9 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.* import com.futo.platformplayer.api.media.platforms.js.SourceAuth +import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig +import com.futo.platformplayer.api.media.platforms.js.SourcePluginCaptchaConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.logging.Logger @@ -41,38 +43,48 @@ class CaptchaActivity : AppCompatActivity() { _webView.settings.javaScriptEnabled = true; CookieManager.getInstance().setAcceptCookie(true); - val url = if (intent.hasExtra("url")) + + val config = if(intent.hasExtra("plugin")) + Json.decodeFromString(intent.getStringExtra("plugin")!!); + else null; + + val captchaConfig = if(config != null) + config.captcha ?: throw IllegalStateException("Plugin has no captcha support"); + else if(intent.hasExtra("captcha")) + Json.decodeFromString(intent.getStringExtra("captcha")!!); + else throw IllegalStateException("No valid configuration?"); + //TODO: Backwards compat removal? + + val extraUrl = if (intent.hasExtra("url")) intent.getStringExtra("url"); else null; - if (url == null) { - throw Exception("URL is missing"); - } - - val body = if (intent.hasExtra("body")) + val extraBody = if (intent.hasExtra("body")) intent.getStringExtra("body"); else null; - if (body == null) { - throw Exception("Body is missing"); - } - + _webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"; _webView.settings.useWideViewPort = true; _webView.settings.loadWithOverviewMode = true; - val webViewClient = CaptchaWebViewClient(); - webViewClient.onCaptchaFinished.subscribe { googleAbuseCookie -> - Logger.i(TAG, "Abuse cookie found: $googleAbuseCookie"); + val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig); + webViewClient.onCaptchaFinished.subscribe { captcha -> _callback?.let { _callback = null; - it.invoke(googleAbuseCookie); + it.invoke(captcha); } finish(); }; _webView.settings.domStorageEnabled = true; _webView.webViewClient = webViewClient; - _webView.loadDataWithBaseURL(url, body, "text/html", "utf-8", null); - //_webView.loadUrl(url); + + if(captchaConfig.captchaUrl != null) + _webView.loadUrl(captchaConfig.captchaUrl); + else if(extraUrl != null && extraBody != null) + _webView.loadDataWithBaseURL(extraUrl, extraBody, "text/html", "utf-8", null); + else if(extraUrl != null) + _webView.loadUrl(extraUrl); + else throw IllegalStateException("No valid captcha info provided"); } override fun finish() { @@ -88,31 +100,21 @@ class CaptchaActivity : AppCompatActivity() { companion object { private val TAG = "CaptchaActivity"; - private var _callback: ((String?) -> Unit)? = null; + private var _callback: ((SourceCaptchaData?) -> Unit)? = null; - private fun getCaptchaIntent(context: Context, url: String, body: String): Intent { + private fun getCaptchaIntent(context: Context, config: SourcePluginConfig, url: String? = null, body: String? = null): Intent { val intent = Intent(context, CaptchaActivity::class.java); - intent.putExtra("url", url); - intent.putExtra("body", body); + if(url != null) + intent.putExtra("url", url); + if(body != null) + intent.putExtra("body", body); + intent.putExtra("plugin", Json.encodeToString(config)); return intent; } - fun showCaptcha(context: Context, url: String, body: String, callback: ((String?) -> Unit)? = null) { - val cookieManager = CookieManager.getInstance(); - val cookieString = cookieManager.getCookie("https://youtube.com") - val cookieMap = cookieString.split(";") - .map { it.trim() } - .map { it.split("=", limit = 2) } - .filter { it.size == 2 } - .associate { it[0] to it[1] }; - - if (cookieMap.containsKey("GOOGLE_ABUSE_EXEMPTION")) { - callback?.invoke("GOOGLE_ABUSE_EXEMPTION=" + cookieMap["GOOGLE_ABUSE_EXEMPTION"]); - return; - } - + fun showCaptcha(context: Context, config: SourcePluginConfig, url: String? = null, body: String? = null, callback: ((SourceCaptchaData?) -> Unit)? = null) { _callback = callback; - context.startActivity(getCaptchaIntent(context, url, body)); + context.startActivity(getCaptchaIntent(context, config, url, body)); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 60e73a37..e0ac6fc1 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -894,7 +894,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { - + //TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers. private var resultLauncherMap = mutableMapOfUnit>(); private var requestCode: Int? = -1; private val resultLauncher: ActivityResultLauncher = registerForActivityResult( diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt index f1167586..417f5b2e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt @@ -15,29 +15,36 @@ class DevJSClient : JSClient { private val _devScript: String; private var _auth: SourceAuth? = null; + private var _captcha: SourceCaptchaData? = null; val devID: String; - constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), listOf("DEV")), null, script) { + constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), captcha?.toEncrypted(), listOf("DEV")), null, script) { _devScript = script; _auth = auth; + _captcha = captcha; this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5); } - constructor(context: Context, descriptor: SourcePluginDescriptor, script: String, auth: SourceAuth? = null, savedState: String? = null, devID: String? = null): super(context, descriptor, savedState, script) { + //TODO: Misisng auth/captcha pass on purpose? + constructor(context: Context, descriptor: SourcePluginDescriptor, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, savedState: String? = null, devID: String? = null): super(context, descriptor, savedState, script) { _devScript = script; _auth = auth; + _captcha = captcha; this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5); } + fun setCaptcha(captcha: SourceCaptchaData? = null) { + _captcha = captcha; + } fun setAuth(auth: SourceAuth? = null) { _auth = auth; } fun recreate(context: Context): DevJSClient { - return DevJSClient(context, config, _devScript, _auth, devID); + return DevJSClient(context, config, _devScript, _auth, _captcha, devID); } override fun getCopy(): JSClient { - return DevJSClient(_context, descriptor, _script, _auth, saveState(), devID); + return DevJSClient(_context, descriptor, _script, _auth, _captcha, saveState(), devID); } override fun initialize() { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 33787528..3eba6173 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -25,9 +25,11 @@ import com.futo.platformplayer.api.media.platforms.js.internal.* import com.futo.platformplayer.api.media.platforms.js.models.* import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.exceptions.PluginEngineException import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException +import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptValidationException import com.futo.platformplayer.logging.Logger @@ -61,6 +63,7 @@ open class JSClient : IPlatformClient { private var _enabled: Boolean = false; private val _auth: SourceAuth?; + private val _captcha: SourceCaptchaData?; private val _injectedSaveState: String?; @@ -87,6 +90,7 @@ open class JSClient : IPlatformClient { val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true val onDisabled = Event1(); + val onCaptchaException = Event2(); constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) { this._context = context; @@ -95,10 +99,11 @@ open class JSClient : IPlatformClient { this.descriptor = descriptor; _injectedSaveState = saveState; _auth = descriptor.getAuth(); + _captcha = descriptor.getCaptchaData(); flags = descriptor.flags.toTypedArray(); - _client = JSHttpClient(this); - _clientAuth = JSHttpClient(this, _auth); + _client = JSHttpClient(this, null, _captcha); + _clientAuth = JSHttpClient(this, _auth, _captcha); _plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth); _plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/source.js"); @@ -110,6 +115,11 @@ open class JSClient : IPlatformClient { } else throw IllegalStateException("Script for plugin [${descriptor.config.name}] was not available"); + + _plugin.onScriptException.subscribe { + if(it is ScriptCaptchaRequiredException) + onCaptchaException.emit(this, it); + }; } constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) { this._context = context; @@ -118,15 +128,21 @@ open class JSClient : IPlatformClient { this.descriptor = descriptor; _injectedSaveState = saveState; _auth = descriptor.getAuth(); + _captcha = descriptor.getCaptchaData(); flags = descriptor.flags.toTypedArray(); - _client = JSHttpClient(this); - _clientAuth = JSHttpClient(this, _auth); + _client = JSHttpClient(this, null, _captcha); + _clientAuth = JSHttpClient(this, _auth, _captcha); _plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth); _plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/source.js"); _plugin.withScript(script); _script = script; + + _plugin.onScriptException.subscribe { + if(it is ScriptCaptchaRequiredException) + onCaptchaException.emit(this, it); + }; } open fun getCopy(): JSClient { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourceCaptchaData.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourceCaptchaData.kt new file mode 100644 index 00000000..7dceeef2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourceCaptchaData.kt @@ -0,0 +1,49 @@ +package com.futo.platformplayer.api.media.platforms.js + +import com.futo.platformplayer.encryption.EncryptionProvider +import com.futo.platformplayer.logging.Logger +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +data class SourceCaptchaData(val cookieMap: HashMap>? = null, val headers: Map> = mapOf()) { + override fun toString(): String { + return "(headers: '$headers', cookieString: '$cookieMap')"; + } + + fun toEncrypted(): String{ + return EncryptionProvider.instance.encrypt(serialize()); + } + + private fun serialize(): String { + return Json.encodeToString(SerializedCaptchaData(cookieMap, headers)); + } + + companion object { + val TAG = "SourceAuth"; + + fun fromEncrypted(encrypted: String?): SourceCaptchaData? { + if(encrypted == null) + return null; + + val decrypted = EncryptionProvider.instance.decrypt(encrypted); + try { + return deserialize(decrypted); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to deserialize authentication", ex); + return null; + } + } + + fun deserialize(str: String): SourceCaptchaData { + val data = Json.decodeFromString(str); + return SourceCaptchaData(data.cookieMap, data.headers); + } + } + + @Serializable + data class SerializedCaptchaData(val cookieMap: HashMap>?, + val headers: Map> = mapOf()) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginCaptchaConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginCaptchaConfig.kt new file mode 100644 index 00000000..9f63b33e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginCaptchaConfig.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.platforms.js + +import kotlinx.serialization.Serializable + +@Serializable +class SourcePluginCaptchaConfig( + val captchaUrl: String? = null, + val completionUrl: String? = null, + val cookiesToFind: List? = null, + val userAgent: String? = null, + val cookiesExclOthers: Boolean = true +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt index d1b61102..5cadbd98 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -35,6 +35,7 @@ class SourcePluginConfig( val settings: List = listOf(), + var captcha: SourcePluginCaptchaConfig? = null, val authentication: SourcePluginAuthConfig? = null, var sourceUrl: String? = null, val constants: HashMap = hashMapOf(), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt index 172ebb23..26af7a48 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -13,22 +13,28 @@ class SourcePluginDescriptor { var appSettings: AppPluginSettings = AppPluginSettings(); - var authEncrypted: String? + var authEncrypted: String? = null + private set; + var captchaEncrypted: String? = null private set; val flags: List; @kotlinx.serialization.Transient val onAuthChanged = Event0(); + @kotlinx.serialization.Transient + val onCaptchaChanged = Event0(); - constructor(config :SourcePluginConfig, authEncrypted: String? = null) { + constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null) { this.config = config; this.authEncrypted = authEncrypted; + this.captchaEncrypted = captchaEncrypted; this.flags = listOf(); } - constructor(config :SourcePluginConfig, authEncrypted: String? = null, flags: List) { + constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, flags: List) { this.config = config; this.authEncrypted = authEncrypted; + this.captchaEncrypted = captchaEncrypted; this.flags = flags; } @@ -41,6 +47,13 @@ class SourcePluginDescriptor { return map; } + fun updateCaptcha(captcha: SourceCaptchaData?) { + captchaEncrypted = captcha?.toEncrypted(); + onCaptchaChanged.emit(); + } + fun getCaptchaData(): SourceCaptchaData? { + return SourceCaptchaData.fromEncrypted(captchaEncrypted); + } fun updateAuth(str: SourceAuth?) { authEncrypted = str?.toEncrypted(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt index 7eb77988..a81301a6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt @@ -5,72 +5,73 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourceAuth +import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData import com.futo.platformplayer.matchesDomain class JSHttpClient : ManagedHttpClient { private val _jsClient: JSClient?; private val _auth: SourceAuth?; + private val _captcha: SourceCaptchaData?; var doUpdateCookies: Boolean = true; var doApplyCookies: Boolean = true; var doAllowNewCookies: Boolean = true; val isLoggedIn: Boolean get() = _auth != null; - private var _currentCookieMap: HashMap>?; + private var _currentCookieMap: HashMap>; - constructor(jsClient: JSClient?, auth: SourceAuth? = null) : super() { + constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() { _jsClient = jsClient; _auth = auth; + _captcha = captcha; + _currentCookieMap = hashMapOf(); if(!auth?.cookieMap.isNullOrEmpty()) { - _currentCookieMap = hashMapOf(); for(domainCookies in auth!!.cookieMap!!) - _currentCookieMap!!.put(domainCookies.key, HashMap(domainCookies.value)); + _currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value)); } - else _currentCookieMap = null; + if(!captcha?.cookieMap.isNullOrEmpty()) { + for(domainCookies in auth!!.cookieMap!!) + _currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value)); + } + } override fun clone(): ManagedHttpClient { val newClient = JSHttpClient(_jsClient, _auth); newClient._currentCookieMap = if(_currentCookieMap != null) - HashMap(_currentCookieMap!!.toList().associate { Pair(it.first, HashMap(it.second)) }) + HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) }) else - null; + hashMapOf(); return newClient; } override fun beforeRequest(request: Request) { + val domain = Uri.parse(request.url).host!!.lowercase(); + val auth = _auth; if (auth != null) { - val domain = Uri.parse(request.url).host!!.lowercase(); - //TODO: Possibly add doApplyHeaders for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries }) request.headers[header.key] = header.value; - - if(doApplyCookies) { - if (!_currentCookieMap.isNullOrEmpty()) { - val cookiesToApply = hashMapOf(); - synchronized(_currentCookieMap!!) { - for(cookie in _currentCookieMap!! - .filter { domain.matchesDomain(it.key) } - .flatMap { it.value.toList() }) - cookiesToApply[cookie.first] = cookie.second; - }; - - if(cookiesToApply.size > 0) { - val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); - request.headers["Cookie"] = cookieString; - } - //printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) }); - } - } } - if (exemptionId != null) { - val cookie = request.headers["Cookie"]; - request.headers["Cookie"] = (cookie ?: "") + ";$exemptionId" - Logger.i(TAG, "Exemption ID applied: ${request.headers["Cookie"]}") + if(doApplyCookies) { + if (!_currentCookieMap.isNullOrEmpty()) { + val cookiesToApply = hashMapOf(); + synchronized(_currentCookieMap!!) { + for(cookie in _currentCookieMap!! + .filter { domain.matchesDomain(it.key) } + .flatMap { it.value.toList() }) + cookiesToApply[cookie.first] = cookie.second; + }; + + if(cookiesToApply.size > 0) { + val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); + request.headers["Cookie"] = cookieString; + } + //printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) }); + } } _jsClient?.validateUrlOrThrow(request.url); @@ -86,7 +87,7 @@ class JSHttpClient : ManagedHttpClient { val defaultCookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString("."); for (header in resp.headers) { - if (_currentCookieMap != null && header.key.lowercase() == "set-cookie") { + if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.key.lowercase() == "set-cookie") { val newCookies = cookieStringToMap(header.value); for (cookie in newCookies) { val endIndex = cookie.value.indexOf(";"); @@ -162,7 +163,4 @@ class JSHttpClient : ManagedHttpClient { Logger.i("Testing", code); } - companion object { - var exemptionId: String? = null; - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt index 0aa74623..310f8ee8 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt @@ -51,6 +51,8 @@ class V8Plugin { */ val afterBusy = Event1(); + val onScriptException = Event1(); + constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) { this._client = client; this._clientAuth = clientAuth; @@ -217,7 +219,13 @@ class V8Plugin { } fun catchScriptErrors(context: String, code: String? = null, handle: ()->T): T { - return catchScriptErrors(this.config, context, code, handle); + try { + return catchScriptErrors(this.config, context, code, handle); + } + catch(ex: ScriptException) { + onScriptException.emit(ex); + throw ex; + } } companion object { @@ -242,7 +250,7 @@ class V8Plugin { if(result is V8ValueObject) { val type = result.getString("plugin_type"); if(type != null && type.endsWith("Exception")) - Companion.throwExceptionFromV8( + throwExceptionFromV8( config, result.getOrThrow(config, "plugin_type", "V8Plugin"), result.getOrThrow(config, "message", "V8Plugin"), @@ -261,26 +269,26 @@ class V8Plugin { catch(executeEx: JavetExecutionException) { if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) { val pluginType = executeEx.scriptingError.context["plugin_type"].toString(); + + //Captcha if (pluginType == "CaptchaRequiredException") { throw ScriptCaptchaRequiredException(config, - executeEx.scriptingError.context["url"].toString(), - executeEx.scriptingError.context["body"].toString(), - executeEx, executeEx.scriptingError?.stack, codeStripped) - }; + executeEx.scriptingError.context["url"]?.toString(), + executeEx.scriptingError.context["body"]?.toString(), + executeEx, executeEx.scriptingError?.stack, codeStripped); + } - val exMessage = extractJSExceptionMessage(executeEx); + //Others throwExceptionFromV8( config, pluginType, - (exMessage ?: ""), + (extractJSExceptionMessage(executeEx) ?: ""), executeEx, executeEx.scriptingError?.stack, codeStripped ); } - - val exMessage = extractJSExceptionMessage(executeEx); - throw ScriptExecutionException(config, "${exMessage}", null, executeEx.scriptingError?.stack, codeStripped); + throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped); } catch(ex: Exception) { throw ex; diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCaptchaRequiredException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCaptchaRequiredException.kt index 41090556..8aa7f2c8 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCaptchaRequiredException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCaptchaRequiredException.kt @@ -2,15 +2,17 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow -class ScriptCaptchaRequiredException(config: IV8PluginConfig, val url: String, val body: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, "Captcha required", ex, stack, code) { +class ScriptCaptchaRequiredException(config: IV8PluginConfig, val url: String?, val body: String?, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, "Captcha required", ex, stack, code) { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { + val contextName = "ScriptCaptchaRequiredException"; return ScriptCaptchaRequiredException(config, - obj.getOrThrow(config, "url", "ScriptCaptchaRequiredException"), - obj.getOrThrow(config, "body", "ScriptCaptchaRequiredException")); + obj.getOrDefault(config, "url", contextName, null), + obj.getOrDefault(config, "body", contextName, null)); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index 3bfab4c9..21e7d20c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -98,20 +98,6 @@ class HomeFragment : MainFragment() { StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) }) .success { loadedResult(it); } - .exception { - Logger.w(TAG, "Plugin captcha required.", it); - - UIDialogs.showConfirmationDialog(context, "Captcha required\nPlugin [${it.config.name}]", action = { - CaptchaActivity.showCaptcha(context, it.url, it.body) { - if (it != null) { - Logger.i(TAG, "Captcha entered $it") - JSHttpClient.exemptionId = it; - //TODO: Reload plugin when captcha completed? is it necessary - loadResults(); - } - } - }) - } .exception { Logger.w(ChannelFragment.TAG, "Plugin failure.", it); UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0, diff --git a/app/src/main/java/com/futo/platformplayer/others/CaptchaWebViewClient.kt b/app/src/main/java/com/futo/platformplayer/others/CaptchaWebViewClient.kt index 04f1df5e..ec8be8a9 100644 --- a/app/src/main/java/com/futo/platformplayer/others/CaptchaWebViewClient.kt +++ b/app/src/main/java/com/futo/platformplayer/others/CaptchaWebViewClient.kt @@ -1,15 +1,51 @@ package com.futo.platformplayer.others import android.webkit.* +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData +import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig +import com.futo.platformplayer.api.media.platforms.js.SourcePluginCaptchaConfig +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.logging.Logger +import kotlinx.serialization.encodeToString class CaptchaWebViewClient : WebViewClient { - val onCaptchaFinished = Event1(); + val onCaptchaFinished = Event1(); val onPageLoaded = Event2() - constructor() : super() {} + private val _pluginConfig: SourcePluginConfig?; + private val _captchaConfig: SourcePluginCaptchaConfig; + + private val _extractor: WebViewRequirementExtractor; + + constructor(config: SourcePluginConfig) : super() { + _pluginConfig = config; + _captchaConfig = config.captcha!!; + _extractor = WebViewRequirementExtractor( + config.allowUrls, + null, + null, + config.captcha!!.cookiesToFind, + config.captcha!!.completionUrl, + config.captcha!!.cookiesExclOthers + ); + Logger.i(TAG, "Captcha [${config.name}]" + + "\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",); + } + constructor(captcha: SourcePluginCaptchaConfig) : super() { + _pluginConfig = null; + _captchaConfig = captcha; + _extractor = WebViewRequirementExtractor( + null, + null, + null, + captcha.cookiesToFind, + captcha.completionUrl, + captcha.cookiesExclOthers + ); + } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url); @@ -21,12 +57,12 @@ class CaptchaWebViewClient : WebViewClient { if(request == null) return super.shouldInterceptRequest(view, request as WebResourceRequest?); - Logger.i(TAG, "shouldInterceptRequest url = ${request.url}") - if (request.url.isHierarchical) { - val googleAbuse = request.url.getQueryParameter("google_abuse"); - if (googleAbuse != null) { - onCaptchaFinished.emit(googleAbuse); - } + val extracted = _extractor.handleRequest(view, request); + if(extracted != null) { + onCaptchaFinished.emit(SourceCaptchaData( + extracted.cookies, + extracted.headers + )); } return super.shouldInterceptRequest(view, request); diff --git a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt index eed9b04a..371af503 100644 --- a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt +++ b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt @@ -46,6 +46,7 @@ class LoginWebViewClient : WebViewClient { onPageLoaded.emit(view, url); } + //TODO: Use new WebViewRequirementExtractor when time to test extensively override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { if(request == null) return super.shouldInterceptRequest(view, request as WebResourceRequest?); diff --git a/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt b/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt new file mode 100644 index 00000000..b62138a8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt @@ -0,0 +1,125 @@ +package com.futo.platformplayer.others + +import android.net.Uri +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import com.futo.platformplayer.api.media.platforms.js.SourceAuth +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.matchesDomain + +class WebViewRequirementExtractor { + private val allowedUrls: List; + private val headersToFind: List?; + private val domainHeadersToFind: Map>?; + private val cookiesToFind: List?; + private val completionUrl: String?; + + private val exclOtherCookies: Boolean; + + + private val headersFoundMap: HashMap> = hashMapOf(); + private val cookiesFoundMap = hashMapOf>(); + private var urlFound = false; + + + constructor(allowedUrls: List?, headers: List?, domainHeaders: Map>?, cookies: List?, url: String?, exclOtherCookies: Boolean = false) { + this.allowedUrls = allowedUrls ?: listOf("everywhere"); + this.exclOtherCookies = exclOtherCookies; + headersToFind = headers; + domainHeadersToFind = domainHeaders; + cookiesToFind = cookies; + completionUrl = url; + } + + + fun handleRequest(view: WebView?, request: WebResourceRequest, logVerbose: Boolean = false): ExtractedData? { + + val domain = request.url.host; + val domainLower = request.url.host?.lowercase(); + if(completionUrl == null) + urlFound = true; + else urlFound = urlFound || request.url == Uri.parse(completionUrl); + + //HEADERS + if(domainLower != null) { + val headersToFind = ((headersToFind?.map { Pair(it.lowercase(), domainLower) } ?: listOf()) + + (domainHeadersToFind?.filter { domainLower.matchesDomain(it.key.lowercase())} + ?.flatMap { it.value.map { header -> Pair(header.lowercase(), it.key.lowercase()) } } ?: listOf())); + + val foundHeaders = request.requestHeaders.filter { requestHeader -> headersToFind.any { it.first.equals(requestHeader.key, true)} && + (!requestHeader.key.equals("Authorization", ignoreCase = true) || requestHeader.value != "undefined") } //TODO: More universal fix (optional regex?) + for(header in foundHeaders) { + for(headerDomain in headersToFind.filter { it.first.equals(header.key, true) }) { + if (!headersFoundMap.containsKey(headerDomain.second)) + headersFoundMap[headerDomain.second] = hashMapOf(); + headersFoundMap[headerDomain.second]!![header.key.lowercase()] = header.value; + } + } + } + + + //COOKIES + //TODO: This is not an ideal solution, we want to intercept the response, but interception need to be rewritten to support that. Correct implementation commented underneath + //TODO: For now we assume cookies are legit for all subdomains of a top-level domain, this is the most common scenario anyway + val cookieString = CookieManager.getInstance().getCookie(request.url.toString()); + if(cookieString != null) { + val domainParts = domain!!.split("."); + val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString("."); + if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) + cookiesToFind?.let { cookiesToFind -> + val cookies = cookieString.split(";"); + for(cookieStr in cookies) { + val cookieSplitIndex = cookieStr.indexOf("="); + if(cookieSplitIndex <= 0) continue; + val cookieKey = cookieStr.substring(0, cookieSplitIndex).trim(); + val cookieVal = cookieStr.substring(cookieSplitIndex + 1).trim(); + + if (exclOtherCookies && !cookiesToFind.contains(cookieKey)) + continue; + + if (cookiesFoundMap.containsKey(cookieDomain)) + cookiesFoundMap[cookieDomain]!![cookieKey] = cookieVal; + else + cookiesFoundMap[cookieDomain] = hashMapOf(Pair(cookieKey, cookieVal)); + } + }; + } + + val headersFound = headersToFind?.map { it.lowercase() }?.all { reqHeader -> headersFoundMap.any { it.value.containsKey(reqHeader) } } ?: true + val domainHeadersFound = domainHeadersToFind?.all { + if(it.value.isEmpty()) + return@all true; + if(!headersFoundMap.containsKey(it.key.lowercase())) + return@all false; + val foundDomainHeaders = headersFoundMap[it.key.lowercase()] ?: mapOf(); + return@all it.value.all { reqHeader -> foundDomainHeaders.containsKey(reqHeader.lowercase()) }; + } ?: true; + val cookiesFound = cookiesToFind?.all { toFind -> cookiesFoundMap.any { it.value.containsKey(toFind) } } ?: true; + + if(logVerbose) { + val builder = StringBuilder(); + builder.appendLine("Request (method: ${request.method}, host: ${request.url.host}, url: ${request.url}, path: ${request.url.path}):"); + for (pair in request.requestHeaders) { + builder.appendLine(" ${pair.key}: ${pair.value}"); + } + builder.appendLine(" Cookies: ${cookiesFoundMap.values.sumOf { it.values.size }}"); + Logger.i(TAG, builder.toString()); + Logger.i(TAG, "Result (urlFound: $urlFound, headersFound: $headersFound, cookiesFound: $cookiesFound)"); + } + + if (urlFound && headersFound && domainHeadersFound && cookiesFound) + return ExtractedData(cookiesFoundMap, headersFoundMap); + return null; + } + + + + data class ExtractedData( + val cookies: HashMap>, + val headers: HashMap> + ); + companion object { + val TAG = "WebViewRequirementExtractor"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 27d5e63b..1711edbc 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -25,11 +25,17 @@ import androidx.lifecycle.lifecycleScope import androidx.work.* import com.futo.platformplayer.* import com.futo.platformplayer.R +import com.futo.platformplayer.activities.CaptchaActivity import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException +import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment +import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.logging.AndroidLogConsumer import com.futo.platformplayer.logging.FileLogConsumer import com.futo.platformplayer.logging.LogLevel @@ -637,6 +643,26 @@ class StateApp { } } + fun handleCaptchaException(client: JSClient, exception: ScriptCaptchaRequiredException) { + Logger.w(HomeFragment.TAG, "[${client.name}] Plugin captcha required.", exception); + + scopeOrNull?.launch(Dispatchers.Main) { + UIDialogs.showConfirmationDialog(context, "Captcha required\nPlugin [${client.config.name}]", { + CaptchaActivity.showCaptcha(context, client.config, exception.url, exception.body) { + StatePlugins.instance.setPluginCaptcha(client.config.id, it); + scopeOrNull?.launch(Dispatchers.IO) { + try { + StatePlatform.instance.reloadClient(context, client.config.id); + } catch (e: Throwable) { + Logger.e(SourceDetailFragment.TAG, "Failed to reload client.", e) + return@launch; + } + } + } + }) + } + } + companion object { private val TAG = "StateApp"; @SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index f416fc25..549f3ef3 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -172,7 +172,11 @@ class StatePlatform { _icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: ImageVariable(plugin.config.absoluteIconUrl, null); - _availableClients.add(JSClient(context, plugin)); + val client = JSClient(context, plugin); + client.onCaptchaException.subscribe { client, ex -> + StateApp.instance.handleCaptchaException(client, ex); + } + _availableClients.add(client); } if(_availableClients.distinctBy { it.id }.count() < _availableClients.size) @@ -287,6 +291,9 @@ class StatePlatform { StatePlugins.instance.getPlugin(id) ?: throw IllegalStateException("Client existed, but plugin config didn't") ); + newClient.onCaptchaException.subscribe { client, ex -> + StateApp.instance.handleCaptchaException(client, ex); + } synchronized(_clientsLock) { if (_enabledClients.contains(client)) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index 1fbf537e..84662d2c 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -6,6 +6,7 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourceAuth +import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor import com.futo.platformplayer.logging.Logger @@ -372,7 +373,7 @@ class StatePlugins { if(icon != null) iconsDir.saveIconBinary(config.id, icon); - _plugins.save(SourcePluginDescriptor(config, null, flags)); + _plugins.save(SourcePluginDescriptor(config, null, null, flags)); return null; } catch(ex: Throwable) { @@ -407,6 +408,18 @@ class StatePlugins { } } + fun setPluginCaptcha(id: String, captcha: SourceCaptchaData?) { + if(id == StateDeveloper.DEV_ID) { + StatePlatform.instance.getDevClient()?.let { + it.setCaptcha(captcha); + }; + return; + } + + val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist"); + descriptor.updateCaptcha(captcha); + _plugins.save(descriptor); + } fun setPluginAuth(id: String, auth: SourceAuth?) { if(id == StateDeveloper.DEV_ID) { StatePlatform.instance.getDevClient()?.let { diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 40c9307c..1c34bb01 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 40c9307c06e0005a972fe4c94c3c89a421379e0d +Subproject commit 1c34bb016378805ca24883ed55573dcd862309cf