diff --git a/extensions/proguard-rules.pro b/extensions/proguard-rules.pro index 8f804140d6..53b4fe8a28 100644 --- a/extensions/proguard-rules.pro +++ b/extensions/proguard-rules.pro @@ -7,3 +7,8 @@ -keep class com.google.** { *; } +-keep class org.mozilla.javascript.** { *; } +-dontwarn org.mozilla.javascript.tools.** +-dontwarn java.beans.** +-dontwarn jdk.dynalink.** +-dontwarn javax.script.** \ No newline at end of file diff --git a/extensions/youtube/build.gradle.kts b/extensions/youtube/build.gradle.kts index f84a54a0d3..f74154baa1 100644 --- a/extensions/youtube/build.gradle.kts +++ b/extensions/youtube/build.gradle.kts @@ -2,6 +2,19 @@ dependencies { compileOnly(project(":extensions:shared:library")) compileOnly(project(":extensions:youtube:stub")) compileOnly(libs.annotation) + implementation("com.github.teamnewpipe:NewPipeExtractor:0.24.8") + implementation("io.reactivex.rxjava3:rxjava:3.1.8") + implementation("com.squareup.okio:okio:3.7.0") // Newer okio use Kotlin 2.0 which Patches does not yet use. + implementation("com.github.TeamNewPipe:nanojson:e9d656ddb49a412a5a0a5d5ef20ca7ef09549996") + implementation("io.reactivex.rxjava3:rxandroid:3.0.2") +} + +repositories { + mavenCentral() + google() + maven { + url = uri("https://jitpack.io") + } } android { diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/spoof/JavaScriptUtil.kt b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/spoof/JavaScriptUtil.kt new file mode 100644 index 0000000000..5df530a177 --- /dev/null +++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/spoof/JavaScriptUtil.kt @@ -0,0 +1,695 @@ +package app.revanced.extension.youtube.patches.spoof + + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebView +import app.revanced.extension.shared.Utils +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleEmitter +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.services.youtube.InnertubeClientRequestInfo +import org.schabi.newpipe.extractor.services.youtube.PoTokenProvider +import org.schabi.newpipe.extractor.services.youtube.PoTokenResult +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper +import java.io.Closeable +import java.time.Instant + +import com.grack.nanojson.JsonObject +import com.grack.nanojson.JsonParser +import com.grack.nanojson.JsonWriter +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString +import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor +import org.schabi.newpipe.extractor.stream.StreamInfo +import java.net.HttpURLConnection +import java.net.URL + +// TODO: Remove +fun test(){ + YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl); + with(StreamInfo.getInfo("https://youtube.com/watch?v=dQw4w9WgXcQ")) { + this.videoStreams.first().content + this.videoOnlyStreams.first().content + this.audioStreams.first().content + this.subtitles.first().isAutoGenerated + } +} + +object BuildConfig { + const val DEBUG = true +} + +fun parseChallengeData(rawChallengeData: String): String { + val scrambled = JsonParser.array().from(rawChallengeData) + + val challengeData = if (scrambled.size > 1 && scrambled.isString(1)) { + val descrambled = descramble(scrambled.getString(1)) + JsonParser.array().from(descrambled) + } else { + scrambled.getArray(0) + } + + val messageId = challengeData.getString(0) + val interpreterHash = challengeData.getString(3) + val program = challengeData.getString(4) + val globalName = challengeData.getString(5) + val clientExperimentsStateBlob = challengeData.getString(7) + + val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData.getArray(1, null)?.find { it is String } + val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData.getArray(2, null)?.find { it is String } + + return JsonWriter.string( + JsonObject.builder() + .value("messageId", messageId) + .`object`("interpreterJavascript") + .value("privateDoNotAccessOrElseSafeScriptWrappedValue", privateDoNotAccessOrElseSafeScriptWrappedValue) + .value("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue", privateDoNotAccessOrElseTrustedResourceUrlWrappedValue) + .end() + .value("interpreterHash", interpreterHash) + .value("program", program) + .value("globalName", globalName) + .value("clientExperimentsStateBlob", clientExperimentsStateBlob) + .done() + ) +} + +/** + * Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript + * `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the + * duration of this token in seconds. + */ +fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair { + val integrityTokenData = JsonParser.array().from(rawIntegrityTokenData) + return base64ToU8(integrityTokenData.getString(0)) to integrityTokenData.getLong(1) +} + +/** + * Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript + * `Uint8Array` that can be embedded directly in JavaScript code. + */ +fun stringToU8(identifier: String): String { + return newUint8Array(identifier.toByteArray()) +} + +/** + * Takes a poToken encoded as a sequence of bytes represented as integers separated by commas + * (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript, + * and converts it to the specific base64 representation for poTokens. + */ +fun u8ToBase64(poToken: String): String { + return poToken.split(",") + .map { it.toUByte().toByte() } + .toByteArray() + .toByteString() + .base64() + .replace("+", "-") + .replace("/", "_") +} + +/** + * Takes the scrambled challenge, decodes it from base64, adds 97 to each byte. + */ +private fun descramble(scrambledChallenge: String): String { + return base64ToByteString(scrambledChallenge) + .map { (it + 97).toByte() } + .toByteArray() + .decodeToString() +} + +/** + * Decodes a base64 string encoded in the specific base64 representation used by YouTube, and + * returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code. + */ +private fun base64ToU8(base64: String): String { + return newUint8Array(base64ToByteString(base64)) +} + +private fun newUint8Array(contents: ByteArray): String { + return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])" +} + +/** + * Decodes a base64 string encoded in the specific base64 representation used by YouTube. + */ +private fun base64ToByteString(base64: String): ByteArray { + val base64Mod = base64 + .replace('-', '+') + .replace('_', '/') + .replace('.', '=') + + return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode")) + .toByteArray() +} + + +class PoTokenException(message: String) : Exception(message) + +// to be thrown if the WebView provided by the system is broken +class BadWebViewException(message: String) : Exception(message) + +fun buildExceptionForJsError(error: String): Exception { + return if (error.contains("SyntaxError")) + BadWebViewException(error) + else + PoTokenException(error) +} + + +/** + * This interface was created to allow for multiple methods to generate poTokens in the future (e.g. + * via WebView and via a local DOM implementation) + */ +interface PoTokenGenerator : Closeable { + /** + * Generates a poToken for the provided identifier, using the `integrityToken` and + * `webPoSignalOutput` previously obtained in the initialization of [PoTokenWebView]. Can be + * called multiple times. + */ + fun generatePoToken(identifier: String): Single + + /** + * @return whether the `integrityToken` is expired, in which case all tokens generated by + * [generatePoToken] will be invalid + */ + fun isExpired(): Boolean + + interface Factory { + /** + * Initializes a [PoTokenGenerator] by loading the BotGuard VM, running it, and obtaining + * an `integrityToken`. Can then be used multiple times to generate multiple poTokens with + * [generatePoToken]. + * + * @param context used e.g. to load the HTML asset or to instantiate a WebView + */ + fun newPoTokenGenerator(context: Context): Single + } +} + + +object PoTokenProviderImpl : PoTokenProvider { + val TAG = PoTokenProviderImpl::class.simpleName + private var webViewBadImpl = false // whether the system has a bad WebView implementation + + private object WebPoTokenGenLock + private var webPoTokenVisitorData: String? = null + private var webPoTokenStreamingPot: String? = null + private var webPoTokenGenerator: PoTokenGenerator? = null + + override fun getWebClientPoToken(videoId: String): PoTokenResult? { + if (!webViewBadImpl) { + return null + } + + try { + return getWebClientPoToken(videoId = videoId, forceRecreate = false) + } catch (e: RuntimeException) { + // RxJava's Single wraps exceptions into RuntimeErrors, so we need to unwrap them here + when (val cause = e.cause) { + is BadWebViewException -> { + Log.e(TAG, "Could not obtain poToken because WebView is broken", e) + webViewBadImpl = true + return null + } + null -> throw e + else -> throw cause // includes PoTokenException + } + } + } + + /** + * @param forceRecreate whether to force the recreation of [webPoTokenGenerator], to be used in + * case the current [webPoTokenGenerator] threw an error last time + * [PoTokenGenerator.generatePoToken] was called + */ + private fun getWebClientPoToken(videoId: String, forceRecreate: Boolean): PoTokenResult { + // just a helper class since Kotlin does not have builtin support for 4-tuples + data class Quadruple(val t1: T1, val t2: T2, val t3: T3, val t4: T4) + + val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) = + synchronized(WebPoTokenGenLock) { + val shouldRecreate = webPoTokenGenerator == null || forceRecreate || + webPoTokenGenerator!!.isExpired() + + if (shouldRecreate) { + + val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient() + innertubeClientRequestInfo.clientInfo.clientVersion = + YoutubeParsingHelper.getClientVersion() + + webPoTokenVisitorData = YoutubeParsingHelper.getVisitorDataFromInnertube( + innertubeClientRequestInfo, + NewPipe.getPreferredLocalization(), + NewPipe.getPreferredContentCountry(), + YoutubeParsingHelper.getYouTubeHeaders(), + YoutubeParsingHelper.YOUTUBEI_V1_URL, + null, + false + ) + // close the current webPoTokenGenerator on the main thread + webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } } + + // create a new webPoTokenGenerator + webPoTokenGenerator = PoTokenWebView + .newPoTokenGenerator(Utils.getContext()).blockingGet() + + // The streaming poToken needs to be generated exactly once before generating + // any other (player) tokens. + webPoTokenStreamingPot = webPoTokenGenerator!! + .generatePoToken(webPoTokenVisitorData!!).blockingGet() + } + + return@synchronized Quadruple( + webPoTokenGenerator!!, + webPoTokenVisitorData!!, + webPoTokenStreamingPot!!, + shouldRecreate + ) + } + + val playerPot = try { + // Not using synchronized here, since poTokenGenerator would be able to generate + // multiple poTokens in parallel if needed. The only important thing is for exactly one + // visitorData/streaming poToken to be generated before anything else. + poTokenGenerator.generatePoToken(videoId).blockingGet() + } catch (throwable: Throwable) { + if (hasBeenRecreated) { + // the poTokenGenerator has just been recreated (and possibly this is already the + // second time we try), so there is likely nothing we can do + throw throwable + } else { + // retry, this time recreating the [webPoTokenGenerator] from scratch; + // this might happen for example if NewPipe goes in the background and the WebView + // content is lost + Log.e(TAG, "Failed to obtain poToken, retrying", throwable) + return getWebClientPoToken(videoId = videoId, forceRecreate = true) + } + } + + if (BuildConfig.DEBUG) { + Log.d( + TAG, + "poToken for $videoId: playerPot=$playerPot, " + + "streamingPot=$streamingPot, visitor_data=$visitorData" + ) + } + + return PoTokenResult(visitorData, playerPot, streamingPot) + } + + override fun getWebEmbedClientPoToken(videoId: String): PoTokenResult? = null + + override fun getAndroidClientPoToken(videoId: String): PoTokenResult? = null + + override fun getIosClientPoToken(videoId: String): PoTokenResult? = null +} + + +class PoTokenWebView private constructor( + context: Context, + // to be used exactly once only during initialization! + private val generatorEmitter: SingleEmitter, +) : PoTokenGenerator { + private val webView = WebView(context) + private val disposables = CompositeDisposable() // used only during initialization + private val poTokenEmitters = mutableListOf>>() + private lateinit var expirationInstant: Instant + + //region Initialization + init { + val webViewSettings = webView.settings + //noinspection SetJavaScriptEnabled we want to use JavaScript! + webViewSettings.javaScriptEnabled = true + webViewSettings.safeBrowsingEnabled = false + webViewSettings.userAgentString = USER_AGENT + webViewSettings.blockNetworkLoads = true // the WebView does not need internet access + + // so that we can run async functions and get back the result + webView.addJavascriptInterface(this, JS_INTERFACE) + + webView.webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(m: ConsoleMessage): Boolean { + if (m.message().contains("Uncaught")) { + // There should not be any uncaught errors while executing the code, because + // everything that can fail is guarded by try-catch. Therefore, this likely + // indicates that there was a syntax error in the code, i.e. the WebView only + // supports a really old version of JS. + + val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})" + val exception = BadWebViewException(fmt) + Log.e(TAG, "This WebView implementation is broken: $fmt") + + onInitializationErrorCloseAndCancel(exception) + popAllPoTokenEmitters().forEach { (_, emitter) -> emitter.onError(exception) } + } + return super.onConsoleMessage(m) + } + } + } + + /** + * Must be called right after instantiating [PoTokenWebView] to perform the actual + * initialization. This will asynchronously go through all the steps needed to load BotGuard, + * run it, and obtain an `integrityToken`. + */ + private fun loadHtmlAndObtainBotguard(context: Context) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "loadHtmlAndObtainBotguard() called") + } + + disposables.add( + Single.fromCallable { + val html = context.assets.open("po_token.html").bufferedReader() + .use { it.readText() } + return@fromCallable html + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { html -> + webView.loadDataWithBaseURL( + "https://www.youtube.com", + html.replaceFirst( + "", + // calls downloadAndRunBotguard() when the page has finished loading + "\n$JS_INTERFACE.downloadAndRunBotguard()" + ), + "text/html", + "utf-8", + null, + ) + }, + this::onInitializationErrorCloseAndCancel + ) + ) + } + + /** + * Called during initialization by the JavaScript snippet appended to the HTML page content in + * [loadHtmlAndObtainBotguard] after the WebView content has been loaded. + */ + @JavascriptInterface + fun downloadAndRunBotguard() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "downloadAndRunBotguard() called") + } + + makeBotguardServiceRequest( + "https://www.youtube.com/api/jnn/v1/Create", + "[ \"$REQUEST_KEY\" ]", + ) { responseBody -> + val parsedChallengeData = parseChallengeData(responseBody) + webView.evaluateJavascript( + """try { + data = $parsedChallengeData + runBotGuard(data).then(function (result) { + this.webPoSignalOutput = result.webPoSignalOutput + $JS_INTERFACE.onRunBotguardResult(result.botguardResponse) + }, function (error) { + $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) + }) + } catch (error) { + $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) + }""", + null + ) + } + } + + /** + * Called during initialization by the JavaScript snippets from either + * [downloadAndRunBotguard] or [onRunBotguardResult]. + */ + @JavascriptInterface + fun onJsInitializationError(error: String) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "Initialization error from JavaScript: $error") + } + onInitializationErrorCloseAndCancel(buildExceptionForJsError(error)) + } + + /** + * Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after + * obtaining the BotGuard execution output [botguardResponse]. + */ + @JavascriptInterface + fun onRunBotguardResult(botguardResponse: String) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "botguardResponse: $botguardResponse") + } + makeBotguardServiceRequest( + "https://www.youtube.com/api/jnn/v1/GenerateIT", + "[ \"$REQUEST_KEY\", \"$botguardResponse\" ]", + ) { responseBody -> + if (BuildConfig.DEBUG) { + Log.d(TAG, "GenerateIT response: $responseBody") + } + val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody) + + // leave 10 minutes of margin just to be sure + expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds - 600) + + webView.evaluateJavascript( + "this.integrityToken = $integrityToken" + ) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s") + } + generatorEmitter.onSuccess(this) + } + } + } + //endregion + + //region Obtaining poTokens + override fun generatePoToken(identifier: String): Single = + Single.create { emitter -> + if (BuildConfig.DEBUG) { + Log.d(TAG, "generatePoToken() called with identifier $identifier") + } + runOnMainThread(emitter) { + addPoTokenEmitter(identifier, emitter) + val u8Identifier = stringToU8(identifier) + webView.evaluateJavascript( + """try { + identifier = "$identifier" + u8Identifier = $u8Identifier + poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier) + poTokenU8String = "" + for (i = 0; i < poTokenU8.length; i++) { + if (i != 0) poTokenU8String += "," + poTokenU8String += poTokenU8[i] + } + $JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String) + } catch (error) { + $JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack) + }""", + ) {} + } + } + + /** + * Called by the JavaScript snippet from [generatePoToken] when an error occurs in calling the + * JavaScript `obtainPoToken()` function. + */ + @JavascriptInterface + fun onObtainPoTokenError(identifier: String, error: String) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "obtainPoToken error from JavaScript: $error") + } + popPoTokenEmitter(identifier)?.onError(buildExceptionForJsError(error)) + } + + /** + * Called by the JavaScript snippet from [generatePoToken] with the original identifier and the + * result of the JavaScript `obtainPoToken()` function. + */ + @JavascriptInterface + fun onObtainPoTokenResult(identifier: String, poTokenU8: String) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8") + } + val poToken = try { + u8ToBase64(poTokenU8) + } catch (t: Throwable) { + popPoTokenEmitter(identifier)?.onError(t) + return + } + + if (BuildConfig.DEBUG) { + Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken") + } + popPoTokenEmitter(identifier)?.onSuccess(poToken) + } + + override fun isExpired(): Boolean { + return Instant.now().isAfter(expirationInstant) + } + //endregion + + //region Handling multiple emitters + /** + * Adds the ([identifier], [emitter]) pair to the [poTokenEmitters] list. This makes it so that + * multiple poToken requests can be generated invparallel, and the results will be notified to + * the right emitters. + */ + private fun addPoTokenEmitter(identifier: String, emitter: SingleEmitter) { + synchronized(poTokenEmitters) { + poTokenEmitters.add(Pair(identifier, emitter)) + } + } + + /** + * Extracts and removes from the [poTokenEmitters] list a [SingleEmitter] based on its + * [identifier]. The emitter is supposed to be used immediately after to either signal a success + * or an error. + */ + private fun popPoTokenEmitter(identifier: String): SingleEmitter? { + return synchronized(poTokenEmitters) { + poTokenEmitters.indexOfFirst { it.first == identifier }.takeIf { it >= 0 }?.let { + poTokenEmitters.removeAt(it).second + } + } + } + + /** + * Clears [poTokenEmitters] and returns its previous contents. The emitters are supposed to be + * used immediately after to either signal a success or an error. + */ + private fun popAllPoTokenEmitters(): List>> { + return synchronized(poTokenEmitters) { + val result = poTokenEmitters.toList() + poTokenEmitters.clear() + result + } + } + //endregion + + //region Utils + /** + * Makes a POST request to [url] with the given [data] by setting the correct headers. Calls + * [onInitializationErrorCloseAndCancel] in case of any network errors and also if the response + * does not have HTTP code 200, therefore this is supposed to be used only during + * initialization. Calls [handleResponseBody] with the response body if the response is + * successful. The request is performed in the background and a disposable is added to + * [disposables]. + */ + private fun makeBotguardServiceRequest( + url: String, + data: String, + handleResponseBody: (String) -> Unit, + ) { + disposables.add( + Single.fromCallable { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.doOutput = true + + // headers + connection.setRequestProperty("User-Agent", USER_AGENT) + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty("Content-Type", "application/json+protobuf") + connection.setRequestProperty("x-goog-api-key", GOOGLE_API_KEY) + connection.setRequestProperty("x-user-agent", "grpc-web-javascript/0.1") + + // body + connection.outputStream.use { os -> + os.writer().write(data); + } + + // response + + return@fromCallable connection + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { connection -> + val httpCode = connection.responseCode + if (httpCode != 200) { + onInitializationErrorCloseAndCancel( + PoTokenException("Invalid response code: $httpCode") + ) + return@subscribe + } + + val responseBody = connection.inputStream.bufferedReader().use { it.readText() } + connection.disconnect() + + handleResponseBody(responseBody) + }, + this::onInitializationErrorCloseAndCancel + ) + ) + } + + /** + * Handles any error happening during initialization, releasing resources and sending the error + * to [generatorEmitter]. + */ + private fun onInitializationErrorCloseAndCancel(error: Throwable) { + runOnMainThread(generatorEmitter) { + close() + generatorEmitter.onError(error) + } + } + + /** + * Releases all [webView] and [disposables] resources. + */ + override fun close() { + disposables.dispose() + + webView.clearHistory() + // clears RAM cache and disk cache (globally for all WebViews) + webView.clearCache(true) + + // ensures that the WebView isn't doing anything when destroying it + webView.loadUrl("about:blank") + + webView.onPause() + webView.removeAllViews() + webView.destroy() + } + //endregion + + companion object : PoTokenGenerator.Factory { + private val TAG = PoTokenWebView::class.simpleName + // Public API key used by BotGuard, which has been got by looking at BotGuard requests + private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw" // NOSONAR + private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo" + private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3" + private const val JS_INTERFACE = "PoTokenWebView" + + override fun newPoTokenGenerator(context: Context): Single = + Single.create { emitter -> + runOnMainThread(emitter) { + val potWv = PoTokenWebView(context, emitter) + potWv.loadHtmlAndObtainBotguard(context) + emitter.setDisposable(potWv.disposables) + } + } + + /** + * Runs [runnable] on the main thread using `Handler(Looper.getMainLooper()).post()`, and + * if the `post` fails emits an error on [emitterIfPostFails]. + */ + private fun runOnMainThread( + emitterIfPostFails: SingleEmitter, + runnable: Runnable, + ) { + if (!Handler(Looper.getMainLooper()).post(runnable)) { + emitterIfPostFails.onError(PoTokenException("Could not run on main thread")) + } + } + } +} \ No newline at end of file