|
|
|
|
@@ -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<String, Long> {
|
|
|
|
|
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<String>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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<PoTokenGenerator>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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<T1, T2, T3, T4>(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>,
|
|
|
|
|
) : PoTokenGenerator {
|
|
|
|
|
private val webView = WebView(context)
|
|
|
|
|
private val disposables = CompositeDisposable() // used only during initialization
|
|
|
|
|
private val poTokenEmitters = mutableListOf<Pair<String, SingleEmitter<String>>>()
|
|
|
|
|
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(
|
|
|
|
|
"</script>",
|
|
|
|
|
// calls downloadAndRunBotguard() when the page has finished loading
|
|
|
|
|
"\n$JS_INTERFACE.downloadAndRunBotguard()</script>"
|
|
|
|
|
),
|
|
|
|
|
"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<String> =
|
|
|
|
|
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<String>) {
|
|
|
|
|
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<String>? {
|
|
|
|
|
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<Pair<String, SingleEmitter<String>>> {
|
|
|
|
|
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<PoTokenGenerator> =
|
|
|
|
|
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<out Any>,
|
|
|
|
|
runnable: Runnable,
|
|
|
|
|
) {
|
|
|
|
|
if (!Handler(Looper.getMainLooper()).post(runnable)) {
|
|
|
|
|
emitterIfPostFails.onError(PoTokenException("Could not run on main thread"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|