feat(StreamsExtractor): generate PoToken

Implements support for locally generating PoTokens using the device
webview. This is a direct port of
https://github.com/TeamNewPipe/NewPipe/pull/11955 to native Kotlin.

Closes: https://github.com/libre-tube/LibreTube/issues/7065
This commit is contained in:
FineFindus 2025-02-03 20:56:21 +01:00
parent 7b0d4caf31
commit d927dffce4
No known key found for this signature in database
GPG Key ID: 64873EE210FF8E6B
8 changed files with 688 additions and 1 deletions

View File

@ -134,6 +134,7 @@ dependencies {
/* NewPipe Extractor */ /* NewPipe Extractor */
implementation(libs.newpipeextractor) implementation(libs.newpipeextractor)
/* Coil */ /* Coil */
coreLibraryDesugaring(libs.desugaring) coreLibraryDesugaring(libs.desugaring)
implementation(libs.coil) implementation(libs.coil)

127
app/src/main/assets/po_token.html vendored Normal file
View File

@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en"><head><title></title><script>
/**
* Factory method to create and load a BotGuardClient instance.
* @param options - Configuration options for the BotGuardClient.
* @returns A promise that resolves to a loaded BotGuardClient instance.
*/
function loadBotGuard(challengeData) {
this.vm = this[challengeData.globalName];
this.program = challengeData.program;
this.vmFunctions = {};
this.syncSnapshotFunction = null;
if (!this.vm)
throw new Error('[BotGuardClient]: VM not found in the global object');
if (!this.vm.a)
throw new Error('[BotGuardClient]: Could not load program');
const vmFunctionsCallback = function (
asyncSnapshotFunction,
shutdownFunction,
passEventFunction,
checkCameraFunction
) {
this.vmFunctions = {
asyncSnapshotFunction: asyncSnapshotFunction,
shutdownFunction: shutdownFunction,
passEventFunction: passEventFunction,
checkCameraFunction: checkCameraFunction
};
};
this.syncSnapshotFunction = this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, function () {/** no-op */ }, [ [], [] ])[0]
// an asynchronous function runs in the background and it will eventually call
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
// control to the things running in the background by interrupting this async
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
// needed but is there just because.
return new Promise(function (resolve, reject) {
i = 0
refreshIntervalId = setInterval(function () {
if (!!this.vmFunctions.asyncSnapshotFunction) {
resolve(this)
clearInterval(refreshIntervalId);
}
if (i >= 10000) {
reject("asyncSnapshotFunction is null even after 10 seconds")
clearInterval(refreshIntervalId);
}
i += 1;
}, 1);
})
}
/**
* Takes a snapshot asynchronously.
* @returns The snapshot result.
* @example
* ```ts
* const result = await botguard.snapshot({
* contentBinding: {
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
* encryptedVideoId: "P-vC09ZJcnM"
* }
* });
*
* console.log(result);
* ```
*/
function snapshot(args) {
return new Promise(function (resolve, reject) {
if (!this.vmFunctions.asyncSnapshotFunction)
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));
this.vmFunctions.asyncSnapshotFunction(function (response) { resolve(response) }, [
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
args.skipPrivacyBuffer
]);
});
}
function runBotGuard(challengeData) {
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (interpreterJavascript) {
new Function(interpreterJavascript)();
} else throw new Error('Could not load VM');
const webPoSignalOutput = [];
return loadBotGuard({
globalName: challengeData.globalName,
globalObj: this,
program: challengeData.program
}).then(function (botguard) {
return botguard.snapshot({ webPoSignalOutput: webPoSignalOutput })
}).then(function (botguardResponse) {
return { webPoSignalOutput: webPoSignalOutput, botguardResponse: botguardResponse }
})
}
function obtainPoToken(webPoSignalOutput, integrityToken, identifier) {
const getMinter = webPoSignalOutput[0];
if (!getMinter)
throw new Error('PMD:Undefined');
const mintCallback = getMinter(integrityToken);
if (!(mintCallback instanceof Function))
throw new Error('APF:Failed');
const result = mintCallback(identifier);
if (!result)
throw new Error('YNJ:Undefined');
if (!(result instanceof Uint8Array))
throw new Error('ODM:Invalid');
return result;
}
</script></head><body></body></html>

View File

@ -8,8 +8,10 @@ import com.github.libretube.api.obj.SegmentData
import com.github.libretube.api.obj.SubmitSegmentResponse import com.github.libretube.api.obj.SubmitSegmentResponse
import com.github.libretube.api.obj.VoteInfo import com.github.libretube.api.obj.VoteInfo
import com.github.libretube.obj.update.UpdateInfo import com.github.libretube.obj.update.UpdateInfo
import kotlinx.serialization.json.JsonElement
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@ -18,6 +20,8 @@ import retrofit2.http.Url
private const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/releases/latest" private const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/releases/latest"
private const val SB_API_URL = "https://sponsor.ajay.app" private const val SB_API_URL = "https://sponsor.ajay.app"
private const val RYD_API_URL = "https://returnyoutubedislikeapi.com" private const val RYD_API_URL = "https://returnyoutubedislikeapi.com"
private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"
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"
interface ExternalApi { interface ExternalApi {
// only for fetching servers list // only for fetching servers list
@ -68,4 +72,17 @@ interface ExternalApi {
@GET("$SB_API_URL/api/branding/{videoId}") @GET("$SB_API_URL/api/branding/{videoId}")
suspend fun getDeArrowContent(@Path("videoId") videoId: String): Map<String, DeArrowContent> suspend fun getDeArrowContent(@Path("videoId") videoId: String): Map<String, DeArrowContent>
@Headers(
"User-Agent: $USER_AGENT",
"Accept: application/json",
"Content-Type: application/json+protobuf",
"x-goog-api-key: $GOOGLE_API_KEY",
"x-user-agent: grpc-web-javascript/0.1",
)
@POST
suspend fun botguardRequest(
@Url url: String,
@Body jsonPayload: List<String>
): JsonElement
} }

View File

@ -1,6 +1,7 @@
package com.github.libretube.api package com.github.libretube.api
import android.util.Base64 import android.util.Base64
import com.github.libretube.api.poToken.PoTokenGenerator
import com.github.libretube.api.obj.Channel import com.github.libretube.api.obj.Channel
import com.github.libretube.api.obj.ChannelTab import com.github.libretube.api.obj.ChannelTab
import com.github.libretube.api.obj.ChannelTabResponse import com.github.libretube.api.obj.ChannelTabResponse
@ -47,6 +48,7 @@ import org.schabi.newpipe.extractor.localization.ContentCountry
import org.schabi.newpipe.extractor.playlist.PlaylistInfo import org.schabi.newpipe.extractor.playlist.PlaylistInfo
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.extractor.search.SearchInfo import org.schabi.newpipe.extractor.search.SearchInfo
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor
import org.schabi.newpipe.extractor.stream.AudioStream import org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
@ -237,6 +239,11 @@ fun String.toListLinkHandler() = with(JsonHelper.json.decodeFromString<TabData>(
} }
class NewPipeMediaServiceRepository : MediaServiceRepository { class NewPipeMediaServiceRepository : MediaServiceRepository {
init {
YoutubeStreamExtractor.setPoTokenProvider(PoTokenGenerator());
}
override suspend fun getTrending(region: String): List<StreamItem> { override suspend fun getTrending(region: String): List<StreamItem> {
val kioskList = NewPipeExtractorInstance.extractor.kioskList val kioskList = NewPipeExtractorInstance.extractor.kioskList
kioskList.forceContentCountry(ContentCountry(region)) kioskList.forceContentCountry(ContentCountry(region))

View File

@ -0,0 +1,133 @@
package com.github.libretube.api.poToken
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
/**
* Parses the raw challenge data obtained from the Create endpoint and returns an object that can be
* embedded in a JavaScript snippet.
*/
fun parseChallengeData(rawChallengeData: String): String {
val scrambled = Json.parseToJsonElement(rawChallengeData).jsonArray
val challengeData = if (scrambled.size > 1 && scrambled[1].jsonPrimitive.isString) {
val descrambled = descramble(scrambled[1].jsonPrimitive.content)
Json.parseToJsonElement(descrambled).jsonArray
} else {
scrambled[0].jsonArray
}
val messageId = challengeData[0].jsonPrimitive.content
val interpreterHash = challengeData[3].jsonPrimitive.content
val program = challengeData[4].jsonPrimitive.content
val globalName = challengeData[5].jsonPrimitive.content
val clientExperimentsStateBlob = challengeData[7].jsonPrimitive.content
val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData[1]
.takeIf { it !is JsonNull }
?.jsonArray
?.find { it.jsonPrimitive.isString }
val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData[2]
.takeIf { it !is JsonNull }
?.jsonArray
?.find { it.jsonPrimitive.isString }
return Json.encodeToString(
JsonObject.serializer(), JsonObject(
mapOf(
"messageId" to JsonPrimitive(messageId),
"interpreterJavascript" to JsonObject(
mapOf(
"privateDoNotAccessOrElseSafeScriptWrappedValue" to (privateDoNotAccessOrElseSafeScriptWrappedValue
?: JsonNull),
"privateDoNotAccessOrElseTrustedResourceUrlWrappedValue" to (privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
?: JsonNull)
)
),
"interpreterHash" to JsonPrimitive(interpreterHash),
"program" to JsonPrimitive(program),
"globalName" to JsonPrimitive(globalName),
"clientExperimentsStateBlob" to JsonPrimitive(clientExperimentsStateBlob)
)
)
)
}
/**
* 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 = Json.parseToJsonElement(rawIntegrityTokenData).jsonArray
return base64ToU8(integrityTokenData[0].jsonPrimitive.content) to integrityTokenData[1].jsonPrimitive.long
}
/**
* 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()
}

View File

@ -0,0 +1,123 @@
package com.github.libretube.api.poToken
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.CookieManager
import com.github.libretube.BuildConfig
import com.github.libretube.LibreTubeApp
import kotlinx.coroutines.runBlocking
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
class PoTokenGenerator : PoTokenProvider {
private val TAG = PoTokenGenerator::class.simpleName
private val supportsWebView by lazy { runCatching { CookieManager.getInstance() }.isSuccess }
private object WebPoTokenGenLock
private var webPoTokenVisitorData: String? = null
private var webPoTokenStreamingPot: String? = null
private var webPoTokenGenerator: PoTokenWebView? = null
override fun getWebClientPoToken(videoId: String): PoTokenResult? {
if (!supportsWebView) {
return null
}
return getWebClientPoToken(videoId, false)
}
/**
* @param forceRecreate whether to force the recreation of [webPoTokenGenerator], to be used in
* case the current [webPoTokenGenerator] threw an error last time
* [PoTokenGenerator.getWebClientPoToken] 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
)
runBlocking {
// close the current webPoTokenGenerator on the main thread
webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } }
// create a new webPoTokenGenerator
webPoTokenGenerator = PoTokenWebView
.newPoTokenGenerator(LibreTubeApp.instance)
// The streaming poToken needs to be generated exactly once before generating
// any other (player) tokens.
webPoTokenStreamingPot = webPoTokenGenerator!!.generatePoToken(webPoTokenVisitorData!!)
}
}
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.
runBlocking {
poTokenGenerator.generatePoToken(videoId)
}
} 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
}

View File

@ -0,0 +1,279 @@
package com.github.libretube.api.poToken
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.annotation.MainThread
import com.github.libretube.BuildConfig
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.USER_AGENT
import kotlinx.coroutines.*
import java.time.Instant
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class PoTokenWebView private constructor(
context: Context,
private val generatorContinuation: Continuation<PoTokenWebView>
) {
private val webView = WebView(context)
private val poTokenContinuations = mutableMapOf<String, Continuation<String>>()
private val exceptionHandler = CoroutineExceptionHandler { context, exception ->
onInitializationError(exception)
}
private lateinit var expirationInstant: Instant
//region Initialization
init {
webView.settings.apply {
//noinspection SetJavaScriptEnabled we want to use JavaScript!
javaScriptEnabled = true
safeBrowsingEnabled = false
userAgentString = USER_AGENT
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)
}
/**
* 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")
}
CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
try {
val html = context.assets.open("po_token.html").bufferedReader().use { it.readText() }
withContext(Dispatchers.Main) {
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,
)
}
} catch (e: Exception) {
onInitializationError(e)
}
}
}
/**
* 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")
}
CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
val responseBody = makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/Create",
listOf(REQUEST_KEY)
)
val parsedChallengeData = parseChallengeData(responseBody)
withContext(Dispatchers.Main) {
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")
}
onInitializationError(PoTokenException(error))
}
/**
* Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after
* obtaining the BotGuard execution output [botguardResponse].
*/
@JavascriptInterface
fun onRunBotguardResult(botguardResponse: String) {
CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
val response = makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/GenerateIT",
listOf(REQUEST_KEY, botguardResponse)
)
val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(response)
// leave 10 minutes of margin just to be sure
expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds - 600)
withContext(Dispatchers.Main) {
webView.evaluateJavascript(
"this.integrityToken = $integrityToken"
) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s")
}
generatorContinuation.resume(this@PoTokenWebView)
}
}
}
}
//endregion
//region Obtaining poTokens
suspend fun generatePoToken(identifier: String): String {
if (BuildConfig.DEBUG) {
Log.d(TAG, "generatePoToken() called with identifier $identifier")
}
return suspendCancellableCoroutine { continuation ->
poTokenContinuations[identifier] = continuation
val u8Identifier = stringToU8(identifier)
Handler(Looper.getMainLooper()).post {
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")
}
poTokenContinuations.remove(identifier)?.resumeWithException(PoTokenException(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) {
poTokenContinuations.remove(identifier)?.resumeWithException(t)
return
}
if (BuildConfig.DEBUG) {
Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken")
}
poTokenContinuations.remove(identifier)?.resume(poToken)
}
fun isExpired(): Boolean {
return Instant.now().isAfter(expirationInstant)
}
//endregion
//region Utils
/**
* Makes a POST request to [url] with the given [data] by setting the correct headers.
* This is supposed to be used only during initialization. Returns the response body
* as a String if the response is successful.
*/
private suspend fun makeBotguardServiceRequest(url: String, data: List<String>): String = withContext(Dispatchers.IO) {
val response = RetrofitInstance.externalApi.botguardRequest(url, data)
response.toString()
}
/**
* Handles any error happening during initialization, releasing resources and sending the error
* to [generatorContinuation].
*/
private fun onInitializationError(error: Throwable) {
CoroutineScope(Dispatchers.Main).launch {
close()
generatorContinuation.resumeWithException(error)
}
}
/**
* Releases all [webView] resources.
*/
@MainThread
fun close() = with(webView) {
clearHistory()
// clears RAM cache and disk cache (globally for all WebViews)
clearCache(true)
// ensures that the WebView isn't doing anything when destroying it
loadUrl("about:blank")
onPause()
removeAllViews()
destroy()
}
//endregion
companion object {
private val TAG = PoTokenWebView::class.simpleName
private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo"
private val JS_INTERFACE = PoTokenWebView::class.simpleName!!
suspend fun newPoTokenGenerator(context: Context): PoTokenWebView {
return suspendCancellableCoroutine { continuation ->
Handler(Looper.getMainLooper()).post {
val poTokenWebView = PoTokenWebView(context, continuation)
poTokenWebView.loadHtmlAndObtainBotguard(context)
}
}
}
}
}
class PoTokenException(message: String) : Exception(message)

View File

@ -60,7 +60,7 @@ androidx-media3-exoplayer-hls = { group = "androidx.media3", name="media3-exopla
androidx-media3-exoplayer-dash = { group = "androidx.media3", name="media3-exoplayer-dash", version.ref="media3" } androidx-media3-exoplayer-dash = { group = "androidx.media3", name="media3-exoplayer-dash", version.ref="media3" }
androidx-media3-session = { group="androidx.media3", name="media3-session", version.ref="media3" } androidx-media3-session = { group="androidx.media3", name="media3-session", version.ref="media3" }
androidx-media3-ui = { group="androidx.media3", name="media3-ui", version.ref="media3" } androidx-media3-ui = { group="androidx.media3", name="media3-ui", version.ref="media3" }
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } newpipeextractor = { module = "com.github.TeamNewPipe:NewPipeExtractor", version.ref = "newpipeextractor" }
square-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } square-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
desugaring = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugaring" } desugaring = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugaring" }