Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ dependencies {
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
// WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
// the corresponding commit hash, since JitPack is sometimes buggy
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.4'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:9f83b385a'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'

/** Checkstyle **/
Expand Down Expand Up @@ -241,6 +241,7 @@ dependencies {
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
implementation 'com.google.android.material:material:1.11.0'
implementation "androidx.webkit:webkit:1.9.0"

/** Third-party libraries **/
// Instance state boilerplate elimination
Expand Down
127 changes: 127 additions & 0 deletions app/src/main/assets/po_token.html
Original file line number Diff line number Diff line change
@@ -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>
4 changes: 4 additions & 0 deletions app/src/main/java/org/schabi/newpipe/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.BridgeStateSaverInitializer;
Expand All @@ -26,6 +27,7 @@
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl;

import java.io.IOException;
import java.io.InterruptedIOException;
Expand Down Expand Up @@ -118,6 +120,8 @@ public void onCreate() {
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));

configureRxJavaErrorHandler();

YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl.INSTANCE);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
import static com.google.android.exoplayer2.util.Util.castNonNull;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTvHtml5UserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5StreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl;
import static java.lang.Math.min;

import android.net.Uri;
Expand Down Expand Up @@ -270,6 +272,7 @@ public YoutubeHttpDataSource createDataSource() {

private static final String RN_PARAMETER = "&rn=";
private static final String YOUTUBE_BASE_URL = "https://www.youtube.com";
private static final byte[] POST_BODY = new byte[] {0x78, 0};

private final boolean allowCrossProtocolRedirects;
private final boolean rangeParameterEnabled;
Expand Down Expand Up @@ -658,8 +661,11 @@ private HttpURLConnection makeConnection(
}
}

final boolean isTvHtml5StreamingUrl = isTvHtml5StreamingUrl(requestUrl);

if (isWebStreamingUrl(requestUrl)
|| isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) {
|| isTvHtml5StreamingUrl
|| isWebEmbeddedPlayerStreamingUrl(requestUrl)) {
httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty");
Expand All @@ -679,6 +685,9 @@ private HttpURLConnection makeConnection(
} else if (isIosStreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getIosUserAgent(null));
} else if (isTvHtml5StreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getTvHtml5UserAgent());
} else {
// non-mobile user agent
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT);
Expand All @@ -687,22 +696,16 @@ private HttpURLConnection makeConnection(
httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING,
allowGzip ? "gzip" : "identity");
httpURLConnection.setInstanceFollowRedirects(followRedirects);
httpURLConnection.setDoOutput(httpBody != null);

// Mobile clients uses POST requests to fetch contents
httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl
? "POST"
: DataSpec.getStringForHttpMethod(httpMethod));

if (httpBody != null) {
httpURLConnection.setFixedLengthStreamingMode(httpBody.length);
httpURLConnection.connect();
final OutputStream os = httpURLConnection.getOutputStream();
os.write(httpBody);
os.close();
} else {
httpURLConnection.connect();
}
// Most clients use POST requests to fetch contents
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setDoOutput(true);
httpURLConnection.setFixedLengthStreamingMode(POST_BODY.length);
httpURLConnection.connect();

final OutputStream os = httpURLConnection.getOutputStream();
os.write(POST_BODY);
os.close();

return httpURLConnection;
}

Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import android.view.KeyEvent;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.webkit.CookieManager;

import androidx.annotation.Dimension;
import androidx.annotation.NonNull;
Expand Down Expand Up @@ -335,4 +336,17 @@ public static boolean shouldSupportMediaTunneling() {
&& !TX_50JXW834
&& !HMB9213NW;
}

/**
* @return whether the device has support for WebView, see
* <a href="https://stackoverflow.com/a/69626735">https://stackoverflow.com/a/69626735</a>
*/
public static boolean supportsWebView() {
try {
CookieManager.getInstance();
return true;
} catch (final Throwable ignored) {
return false;
}
}
}
113 changes: 113 additions & 0 deletions app/src/main/java/org/schabi/newpipe/util/potoken/JavaScriptUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package org.schabi.newpipe.util.potoken

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

/**
* 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 = 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(1)
Comment thread
ShareASmile marked this conversation as resolved.
}

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()
}
Loading