Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(YouTube - Client spoof): Spoof client to fix playback #15

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import android.content.Context;
import android.content.ContextWrapper;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.net.ConnectivityManager;
Expand Down Expand Up @@ -55,6 +57,7 @@ public class Utils {
public static Context context;

private static Resources resources;
private static String versionName;

protected Utils() {
} // utility class
Expand Down Expand Up @@ -319,6 +322,37 @@ public static void setEditTextDialogTheme(final AlertDialog.Builder builder) {
setEditTextDialogTheme(builder, false);
}

/**
* @return The version name of the app, such as "YouTube".
*/
public static String getAppVersionName() {
if (versionName == null) {
try {
final var packageName = Objects.requireNonNull(getContext()).getPackageName();

PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageInfo = packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(0)
);
} else {
packageInfo = packageManager.getPackageInfo(
packageName,
0
);
}
versionName = packageInfo.versionName;
} catch (Exception ex) {
Logger.printException(() -> "Failed to get package info", ex);
versionName = "Unknown";
}
}

return versionName;
}

/**
* If {@link Fragment} uses [Android library] rather than [AndroidX library],
* the Dialog theme corresponding to [Android library] should be used.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ private static String getStoryboardRendererSpec(String originalStoryboardRendere
if (spoofParameter && !useOriginalStoryboardRenderer) {
final StoryboardRenderer renderer = getRenderer(false);
if (renderer != null) {
if (returnNullIfLiveStream && renderer.isLiveStream()) {
if (returnNullIfLiveStream && renderer.isLiveStream) {
return null;
}
String spec = renderer.getSpec();
String spec = renderer.spec;
if (spec != null) {
return spec;
}
Expand Down Expand Up @@ -192,7 +192,7 @@ public static int getRecommendedLevel(int originalLevel) {
if (spoofParameter && !useOriginalStoryboardRenderer) {
final StoryboardRenderer renderer = getRenderer(false);
if (renderer != null) {
Integer recommendedLevel = renderer.getRecommendedLevel();
Integer recommendedLevel = renderer.recommendedLevel;
if (recommendedLevel != null) return recommendedLevel;
}
}
Expand All @@ -215,6 +215,6 @@ public static boolean getSeekbarThumbnailOverrideValue() {
// Show empty thumbnails so the seek time and chapters still show up.
return true;
}
return renderer.getSpec() != null;
return renderer.spec != null;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
package app.revanced.integrations.youtube.patches.misc;

import android.os.Build;
import app.revanced.integrations.shared.utils.Logger;
import app.revanced.integrations.shared.utils.Utils;
import app.revanced.integrations.youtube.settings.Settings;

import android.net.Uri;

import androidx.annotation.Nullable;
import app.revanced.integrations.youtube.shared.VideoInformation;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import static app.revanced.integrations.youtube.patches.misc.requests.StoryboardRendererRequester.getStoryboardRenderer;


/**
* @noinspection ALL
* Spoof the client name as 'ANDROID_TESTSUITE'.
Expand All @@ -10,14 +29,257 @@
*/
public class SpoofTestClientPatch {
private static final String ANDROID_TESTSUITE_VERSION_NAME = "1.9";
private static final boolean spoofTestClientEnabled =
Settings.SPOOF_TEST_CLIENT.get();
private static final boolean SPOOF_TEST_CLIENT = Settings.SPOOF_TEST_CLIENT.get();
private static final boolean SPOOF_CLIENT_USE_TEST_SUITE = Settings.SPOOF_CLIENT_USE_TEST_SUITE.get();
private static final boolean SPOOF_CLIENT_STORYBOARD = SPOOF_TEST_CLIENT && !SPOOF_CLIENT_USE_TEST_SUITE;

public static boolean spoofTestClient() {
return spoofTestClientEnabled;
public static String spoofTestClient(final String original) {
return SPOOF_TEST_CLIENT ? ANDROID_TESTSUITE_VERSION_NAME : original;
}

public static String spoofTestClient(final String original) {
return spoofTestClientEnabled ? ANDROID_TESTSUITE_VERSION_NAME : original;
/**
* Any unreachable ip address. Used to intentionally fail requests.
*/
private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0";
private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING);

@Nullable
private static volatile Future<StoryboardRenderer> lastStoryboardFetched;

private static final Map<String, Future<StoryboardRenderer>> storyboardCache =
Collections.synchronizedMap(new LinkedHashMap<>(100) {
private static final int CACHE_LIMIT = 100;

@Override
protected boolean removeEldestEntry(Entry eldest) {
return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
}
});

/**
* Injection point.
* Blocks /get_watch requests by returning a localhost URI.
*
* @param playerRequestUri The URI of the player request.
* @return Localhost URI if the request is a /get_watch request, otherwise the original URI.
*/
public static Uri blockGetWatchRequest(Uri playerRequestUri) {
if (SPOOF_TEST_CLIENT) {
try {
String path = playerRequestUri.getPath();

if (path != null && path.contains("get_watch")) {
Logger.printDebug(() -> "Blocking: " + playerRequestUri + " by returning: " + UNREACHABLE_HOST_URI_STRING);

return UNREACHABLE_HOST_URI;
}
} catch (Exception ex) {
Logger.printException(() -> "blockGetWatchRequest failure", ex);
}
}

return playerRequestUri;
}

/**
* Injection point.
* Blocks /initplayback requests.
* For iOS, an unreachable host URL can be used, but for Android Testsuite, this is not possible.
*/
public static String blockInitPlaybackRequest(String originalUrlString) {
if (SPOOF_TEST_CLIENT) {
try {
var originalUri = Uri.parse(originalUrlString);
String path = originalUri.getPath();

if (path != null && path.contains("initplayback")) {
String replacementUriString = (getSpoofClientType() == ClientType.IOS)
? UNREACHABLE_HOST_URI_STRING
// TODO: Ideally, a local proxy could be setup and block
// the request the same way as Burp Suite is capable of
// because that way the request is never sent to YouTube unnecessarily.
// Just using localhost unfortunately does not work.
: originalUri.buildUpon().clearQuery().build().toString();

Logger.printDebug(() -> "Blocking: " + originalUrlString + " by returning: " + replacementUriString);

return replacementUriString;
}
} catch (Exception ex) {
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
}
}

return originalUrlString;
}

private static ClientType getSpoofClientType() {
if (!SPOOF_CLIENT_USE_TEST_SUITE) {
return ClientType.IOS;
}

StoryboardRenderer renderer = getRenderer(false);
if (renderer == null) {
// Video is private or otherwise not available.
// Test client still works for video playback, but seekbar thumbnails are not available.
// Use iOS client instead.
Logger.printDebug(() -> "Using iOS client for paid or otherwise restricted video");
return ClientType.IOS;
}

if (renderer.isLiveStream) {
// Test client does not support live streams.
// Use the storyboard renderer information to fallback to iOS if a live stream is opened.
Logger.printDebug(() -> "Using iOS client for livestream: " + renderer.videoId);
return ClientType.IOS;
}

return ClientType.ANDROID_TESTSUITE;
}

/**
* Injection point.
*/
public static String getClientModel(String originalClientModel) {
if (SPOOF_TEST_CLIENT) {
return getSpoofClientType().model;
}

return originalClientModel;
}

/**
* Injection point.
*/
public static int getClientTypeId(int originalClientTypeId) {
if (SPOOF_TEST_CLIENT) {
return getSpoofClientType().id;
}

return originalClientTypeId;
}

/**
* Injection point.
*/
public static String getClientVersion(String originalClientVersion) {
if (SPOOF_TEST_CLIENT) {
return getSpoofClientType().version;
}

return originalClientVersion;
}

//
// Storyboard.
//

/**
* Injection point.
*/
public static String setPlayerResponseVideoId(String parameters, String videoId, boolean isShortAndOpeningOrPlaying) {
if (SPOOF_CLIENT_STORYBOARD) {
try {
// VideoInformation is not a dependent patch, and only this single helper method is used.
// Hook can be called when scrolling thru the feed and a Shorts shelf is present.
// Ignore these videos.
if (!isShortAndOpeningOrPlaying && VideoInformation.playerParametersAreShort(parameters)) {
Logger.printDebug(() -> "Ignoring Short: " + videoId);
return parameters;
}

Future<StoryboardRenderer> storyboard = storyboardCache.get(videoId);
if (storyboard == null) {
storyboard = Utils.submitOnBackgroundThread(() -> getStoryboardRenderer(videoId));
storyboardCache.put(videoId, storyboard);
lastStoryboardFetched = storyboard;

// Block until the renderer fetch completes.
// This is desired because if this returns without finishing the fetch
// then video will start playback but the storyboard is not ready yet.
getRenderer(true);
} else {
lastStoryboardFetched = storyboard;
// No need to block on the fetch since it previously loaded.
}

} catch (Exception ex) {
Logger.printException(() -> "setPlayerResponseVideoId failure", ex);
}
}

return parameters; // Return the original value since we are observing and not modifying.
}

@Nullable
private static StoryboardRenderer getRenderer(boolean waitForCompletion) {
var future = lastStoryboardFetched;
if (future != null) {
try {
if (waitForCompletion || future.isDone()) {
return future.get(20000, TimeUnit.MILLISECONDS); // Any arbitrarily large timeout.
} // else, return null.
} catch (TimeoutException ex) {
Logger.printDebug(() -> "Could not get renderer (get timed out)");
} catch (ExecutionException | InterruptedException ex) {
// Should never happen.
Logger.printException(() -> "Could not get renderer", ex);
}
}
return null;
}

/**
* Injection point.
* Called from background threads and from the main thread.
*/
@Nullable
public static String getStoryboardRendererSpec(String originalStoryboardRendererSpec) {
if (SPOOF_CLIENT_STORYBOARD) {
StoryboardRenderer renderer = getRenderer(false);

if (renderer != null) {
if (!renderer.isLiveStream && renderer.spec != null) {
return renderer.spec;
}
}
}

return originalStoryboardRendererSpec;
}

/**
* Injection point.
*/
public static int getRecommendedLevel(int originalLevel) {
if (SPOOF_CLIENT_STORYBOARD) {
StoryboardRenderer renderer = getRenderer(false);

if (renderer != null) {
if (!renderer.isLiveStream && renderer.recommendedLevel != null) {
return renderer.recommendedLevel;
}
}
}

return originalLevel;
}

private enum ClientType {
ANDROID_TESTSUITE(30, Build.MODEL, "1.9"),
// 16,2 = iPhone 15 Pro Max.
// Version number should be a valid iOS release.
// https://www.ipa4fun.com/history/185230
IOS(5, "iPhone16,2", "19.10.7");

final int id;
final String model;
final String version;

ClientType(int id, String model, String version) {
this.id = id;
this.model = model;
this.version = version;
}
}
}
Loading
Loading