From b1f53f941f7864f2cc98b39cb71a94794e43a5ba Mon Sep 17 00:00:00 2001 From: inotia00 <108592928+inotia00@users.noreply.github.com> Date: Sun, 1 Sep 2024 02:11:15 +0900 Subject: [PATCH] feat(YouTube): Add `Spoof streaming data` patch --- .../patches/misc/SpoofStreamingDataPatch.java | 209 +++++++++++++ .../patches/misc/client/AppClient.java | 279 ++++++++++++++++++ .../misc/client/DeviceHardwareSupport.java | 63 ++++ .../patches/misc/requests/PlayerRoutes.java | 83 ++++++ .../misc/requests/StreamingDataRequester.java | 150 ++++++++++ .../youtube/settings/Settings.java | 7 + ...oofStreamingDataSideEffectsPreference.java | 86 ++++++ .../java/org/chromium/net/UrlRequest.java | 4 + 8 files changed, 881 insertions(+) create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/misc/SpoofStreamingDataPatch.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/misc/client/AppClient.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/misc/client/DeviceHardwareSupport.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/misc/requests/PlayerRoutes.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/misc/requests/StreamingDataRequester.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/misc/SpoofStreamingDataPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/SpoofStreamingDataPatch.java new file mode 100644 index 0000000000..3108f76ee1 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/SpoofStreamingDataPatch.java @@ -0,0 +1,209 @@ +package app.revanced.integrations.youtube.patches.misc; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import org.chromium.net.UrlRequest; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import app.revanced.integrations.shared.settings.Setting; +import app.revanced.integrations.shared.utils.Logger; +import app.revanced.integrations.shared.utils.Utils; +import app.revanced.integrations.youtube.patches.misc.client.AppClient.ClientType; +import app.revanced.integrations.youtube.patches.misc.requests.StreamingDataRequester; +import app.revanced.integrations.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class SpoofStreamingDataPatch { + private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_STREAMING_DATA.get(); + + /** + * 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); + + private static volatile Future currentVideoStream; + + private static String url; + private static Map playerHeaders; + + /** + * Injection point. + * Blocks /get_watch requests by returning an unreachable URI. + * + * @param playerRequestUri The URI of the player request. + * @return An unreachable URI if the request is a /get_watch request, otherwise the original URI. + */ + public static Uri blockGetWatchRequest(Uri playerRequestUri) { + if (SPOOF_STREAMING_DATA) { + try { + String path = playerRequestUri.getPath(); + + if (path != null && path.contains("get_watch")) { + Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri"); + + return UNREACHABLE_HOST_URI; + } + } catch (Exception ex) { + Logger.printException(() -> "blockGetWatchRequest failure", ex); + } + } + + return playerRequestUri; + } + + /** + * Injection point. + *

+ * Blocks /initplayback requests. + */ + public static String blockInitPlaybackRequest(String originalUrlString) { + if (SPOOF_STREAMING_DATA) { + try { + var originalUri = Uri.parse(originalUrlString); + String path = originalUri.getPath(); + + if (path != null && path.contains("initplayback")) { + Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url"); + + return UNREACHABLE_HOST_URI_STRING; + } + } catch (Exception ex) { + Logger.printException(() -> "blockInitPlaybackRequest failure", ex); + } + } + + return originalUrlString; + } + + /** + * Injection point. + */ + public static boolean isSpoofingEnabled() { + return SPOOF_STREAMING_DATA; + } + + /** + * Injection point. + */ + public static void setHeader(String url, Map playerHeaders) { + if (SPOOF_STREAMING_DATA) { + SpoofStreamingDataPatch.url = url; + SpoofStreamingDataPatch.playerHeaders = playerHeaders; + } + } + + /** + * Injection point. + */ + public static UrlRequest buildRequest(UrlRequest.Builder builder) { + if (SPOOF_STREAMING_DATA) { + try { + Uri uri = Uri.parse(url); + String path = uri.getPath(); + if (path != null && path.contains("player") && !path.contains("heartbeat")) { + String videoId = Objects.requireNonNull(uri.getQueryParameter("id")); + currentVideoStream = StreamingDataRequester.fetch(videoId, playerHeaders); + } + } catch (Exception ex) { + Logger.printException(() -> "buildRequest failure", ex); + } + } + + return builder.build(); + } + + /** + * Injection point. + * Fix playback by replace the streaming data. + * Called after {@link #buildRequest(UrlRequest.Builder)}. + */ + @Nullable + public static ByteBuffer getStreamingData(String videoId) { + if (SPOOF_STREAMING_DATA) { + try { + Utils.verifyOffMainThread(); + + var future = currentVideoStream; + if (future != null) { + final long maxSecondsToWait = 20; + var stream = future.get(maxSecondsToWait, TimeUnit.SECONDS); + if (stream != null) { + Logger.printDebug(() -> "Overriding video stream"); + return stream; + } + + Logger.printDebug(() -> "Not overriding streaming data (video stream is null)"); + } + } catch (TimeoutException ex) { + Logger.printInfo(() -> "getStreamingData timed out", ex); + } catch (InterruptedException ex) { + Logger.printException(() -> "getStreamingData interrupted", ex); + Thread.currentThread().interrupt(); // Restore interrupt status flag. + } catch (ExecutionException ex) { + Logger.printException(() -> "getStreamingData failure", ex); + } + } + + return null; + } + + /** + * Injection point. + * Called after {@link #getStreamingData(String)}. + */ + @Nullable + public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) { + if (SPOOF_STREAMING_DATA) { + try { + final int methodPost = 2; + if (method == methodPost) { + String path = uri.getPath(); + String clientName = "c"; + final boolean iosClient = ClientType.IOS.name().equals(uri.getQueryParameter(clientName)); + if (iosClient && path != null && path.contains("videoplayback")) { + return null; + } + } + } catch (Exception ex) { + Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex); + } + } + + return postData; + } + + /** + * Injection point. + */ + public static String appendSpoofedClient(String videoFormat) { + try { + if (SPOOF_STREAMING_DATA && Settings.SPOOF_STREAMING_DATA_STATS_FOR_NERDS.get() + && !TextUtils.isEmpty(videoFormat)) { + // Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages + return "\u202D" + videoFormat + String.format("\u2009(%s)", StreamingDataRequester.getLastSpoofedClientName()); // u202D = left to right override + } + } catch (Exception ex) { + Logger.printException(() -> "appendSpoofedClient failure", ex); + } + + return videoFormat; + } + + public static final class ForceiOSAVCAvailability implements Setting.Availability { + @Override + public boolean isAvailable() { + return Settings.SPOOF_STREAMING_DATA.get() && Settings.SPOOF_STREAMING_DATA_TYPE.get() == ClientType.IOS; + } + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/misc/client/AppClient.java b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/client/AppClient.java new file mode 100644 index 0000000000..f19fd8ef9b --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/client/AppClient.java @@ -0,0 +1,279 @@ +package app.revanced.integrations.youtube.patches.misc.client; + +import static app.revanced.integrations.shared.utils.StringRef.str; + +import android.os.Build; + +import androidx.annotation.Nullable; + +import app.revanced.integrations.shared.utils.PackageUtils; + +public class AppClient { + + // WEB + private static final String CLIENT_VERSION_WEB = "2.20240726.00.00"; + private static final String DEVICE_MODEL_WEB = "Surface Book 3"; + private static final String OS_VERSION_WEB = "10"; + private static final String USER_AGENT_WEB = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0)" + + " Gecko/20100101" + + " Firefox/129.0"; + + // ANDROID + private static final String CLIENT_VERSION_ANDROID = PackageUtils.getVersionName(); + private static final String DEVICE_MODEL_ANDROID = Build.MODEL; + private static final String OS_NAME_ANDROID = "Android"; + private static final String OS_VERSION_ANDROID = Build.VERSION.RELEASE; + private static final int ANDROID_SDK_VERSION_ANDROID = Build.VERSION.SDK_INT; + private static final String USER_AGENT_ANDROID = "com.google.android.youtube/" + + CLIENT_VERSION_ANDROID + + " (Linux; U; Android " + + OS_VERSION_ANDROID + + "; GB) gzip"; + + // IOS + /** + * The hardcoded client version of the iOS app used for InnerTube requests with this client. + * + *

+ * It can be extracted by getting the latest release version of the app on + * the App + * Store page of the YouTube app, in the {@code What’s New} section. + *

+ */ + private static final String CLIENT_VERSION_IOS = "19.16.3"; + private static final String DEVICE_MAKE_IOS = "Apple"; + /** + * The device machine id for the iPhone XS Max (iPhone11,4), used to get 60fps. + * The device machine id for the iPhone 15 Pro Max (iPhone16,2), used to get HDR with AV1 hardware decoding. + * + *

+ * See this GitHub Gist for more + * information. + *

+ */ + private static final String DEVICE_MODEL_IOS = DeviceHardwareSupport.allowAV1() + ? "iPhone16,2" + : "iPhone11,4"; + private static final String OS_NAME_IOS = "iOS"; + /** + * The minimum supported OS version for the iOS YouTube client is iOS 14.0. + * Using an invalid OS version will use the AVC codec. + */ + private static final String OS_VERSION_IOS = DeviceHardwareSupport.allowVP9() + ? "17.6.1.21G101" + : "13.7.17H35"; + private static final String USER_AGENT_VERSION_IOS = DeviceHardwareSupport.allowVP9() + ? "17_6_1" + : "13_7"; + private static final String USER_AGENT_IOS = "com.google.ios.youtube/" + + CLIENT_VERSION_IOS + + "(" + + DEVICE_MODEL_IOS + + "; U; CPU iOS " + + USER_AGENT_VERSION_IOS + + " like Mac OS X)"; + + // ANDROID VR + /** + * The hardcoded client version of the Android VR app used for InnerTube requests with this client. + * + *

+ * It can be extracted by getting the latest release version of the app on + * the App + * Store page of the YouTube app, in the {@code Additional details} section. + *

+ */ + private static final String CLIENT_VERSION_ANDROID_VR = "1.56.21"; + /** + * The device machine id for the Meta Quest 3, used to get opus codec with the Android VR client. + * + *

+ * See this GitLab for more + * information. + *

+ */ + private static final String DEVICE_MODEL_ANDROID_VR = "Quest 3"; + private static final String OS_VERSION_ANDROID_VR = "12"; + /** + * The SDK version for Android 12 is 31, + * but for some reason the build.props for the {@code Quest 3} state that the SDK version is 32. + */ + private static final int ANDROID_SDK_VERSION_ANDROID_VR = 32; + /** + * Package name for YouTube VR (Google DayDream): com.google.android.apps.youtube.vr (Deprecated) + * Package name for YouTube VR (Meta Quests): com.google.android.apps.youtube.vr.oculus + * Package name for YouTube VR (ByteDance Pico 4): com.google.android.apps.youtube.vr.pico + */ + private static final String USER_AGENT_ANDROID_VR = "com.google.android.youtube/" + + CLIENT_VERSION_ANDROID_VR + + " (Linux; U; Android " + + OS_VERSION_ANDROID_VR + + "; GB) gzip"; + + // ANDROID UNPLUGGED + private static final String CLIENT_VERSION_ANDROID_UNPLUGGED = "8.16.0"; + /** + * The device machine id for the Chromecast with Google TV 4K. + * + *

+ * See this GitLab for more + * information. + *

+ */ + private static final String DEVICE_MODEL_ANDROID_UNPLUGGED = "Chromecast"; + private static final String OS_VERSION_ANDROID_UNPLUGGED = "12"; + private static final int ANDROID_SDK_VERSION_ANDROID_UNPLUGGED = 31; + private static final String USER_AGENT_ANDROID_UNPLUGGED = "com.google.android.apps.youtube.unplugged/" + + CLIENT_VERSION_ANDROID_UNPLUGGED + + " (Linux; U; Android " + + OS_VERSION_ANDROID_UNPLUGGED + + "; GB) gzip"; + + // ANDROID TESTSUITE + private static final String CLIENT_VERSION_ANDROID_TESTSUITE = "1.9"; + private static final String USER_AGENT_ANDROID_TESTSUITE = "com.google.android.youtube/" + + CLIENT_VERSION_ANDROID_TESTSUITE + + " (Linux; U; Android " + + OS_VERSION_ANDROID + + "; GB) gzip"; + + // TVHTML5 SIMPLY EMBEDDED PLAYER + private static final String CLIENT_VERSION_TVHTML5_SIMPLY_EMBEDDED_PLAYER = "2.0"; + + private AppClient() { + } + + public enum ClientType { + WEB(1, + null, + DEVICE_MODEL_WEB, + CLIENT_VERSION_WEB, + null, + OS_VERSION_WEB, + null, + USER_AGENT_WEB + ), + ANDROID(3, + null, + DEVICE_MODEL_ANDROID, + CLIENT_VERSION_ANDROID, + OS_NAME_ANDROID, + OS_VERSION_ANDROID, + ANDROID_SDK_VERSION_ANDROID, + USER_AGENT_ANDROID + ), + IOS(5, + DEVICE_MAKE_IOS, + DEVICE_MODEL_IOS, + CLIENT_VERSION_IOS, + OS_NAME_IOS, + OS_VERSION_IOS, + null, + USER_AGENT_IOS + ), + ANDROID_VR(28, + null, + DEVICE_MODEL_ANDROID_VR, + CLIENT_VERSION_ANDROID_VR, + OS_NAME_ANDROID, + OS_VERSION_ANDROID_VR, + ANDROID_SDK_VERSION_ANDROID_VR, + USER_AGENT_ANDROID_VR + ), + ANDROID_UNPLUGGED(29, + null, + DEVICE_MODEL_ANDROID_UNPLUGGED, + CLIENT_VERSION_ANDROID_UNPLUGGED, + OS_NAME_ANDROID, + OS_VERSION_ANDROID_UNPLUGGED, + ANDROID_SDK_VERSION_ANDROID_UNPLUGGED, + USER_AGENT_ANDROID_UNPLUGGED + ), + ANDROID_TESTSUITE(30, + null, + DEVICE_MODEL_ANDROID, + CLIENT_VERSION_ANDROID_TESTSUITE, + OS_NAME_ANDROID, + OS_VERSION_ANDROID, + ANDROID_SDK_VERSION_ANDROID, + USER_AGENT_ANDROID_TESTSUITE + ), + ANDROID_EMBEDDED_PLAYER(55, + null, + DEVICE_MODEL_ANDROID, + CLIENT_VERSION_ANDROID, + OS_NAME_ANDROID, + OS_VERSION_ANDROID, + ANDROID_SDK_VERSION_ANDROID, + USER_AGENT_ANDROID + ), + TVHTML5_SIMPLY_EMBEDDED_PLAYER(85, + null, + DEVICE_MODEL_WEB, + CLIENT_VERSION_TVHTML5_SIMPLY_EMBEDDED_PLAYER, + null, + OS_VERSION_WEB, + null, + USER_AGENT_WEB + ); + + public final String friendlyName; + + /** + * YouTube + * client type + */ + public final int id; + + /** + * Device manufacturer. + */ + @Nullable + public final String make; + + /** + * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model) + */ + public final String model; + + /** + * Device OS name. + */ + @Nullable + public final String osName; + + /** + * Device OS version. + */ + public final String osVersion; + + /** + * Player user-agent. + */ + public final String userAgent; + + /** + * Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk) + * Field is null if not applicable. + */ + public final Integer androidSdkVersion; + + /** + * App version. + */ + public final String appVersion; + + ClientType(int id, @Nullable String make, String model, String appVersion, @Nullable String osName, + String osVersion, Integer androidSdkVersion, String userAgent) { + this.friendlyName = str("revanced_spoof_streaming_data_type_entry_" + name().toLowerCase()); + this.id = id; + this.make = make; + this.model = model; + this.appVersion = appVersion; + this.osName = osName; + this.osVersion = osVersion; + this.androidSdkVersion = androidSdkVersion; + this.userAgent = userAgent; + } + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/misc/client/DeviceHardwareSupport.java b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/client/DeviceHardwareSupport.java new file mode 100644 index 0000000000..d17f9525e4 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/client/DeviceHardwareSupport.java @@ -0,0 +1,63 @@ +package app.revanced.integrations.youtube.patches.misc.client; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.os.Build; + +import app.revanced.integrations.shared.utils.Logger; +import app.revanced.integrations.youtube.settings.Settings; + +public class DeviceHardwareSupport { + private static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9 = deviceHasVP9HardwareDecoding(); + private static final boolean DEVICE_HAS_HARDWARE_DECODING_AV1 = deviceHasAV1HardwareDecoding(); + + private static boolean deviceHasVP9HardwareDecoding() { + MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS); + + for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) { + final boolean isHardwareAccelerated = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ? codecInfo.isHardwareAccelerated() + : !codecInfo.getName().startsWith("OMX.google"); // Software decoder. + if (isHardwareAccelerated && !codecInfo.isEncoder()) { + for (String type : codecInfo.getSupportedTypes()) { + if (type.equalsIgnoreCase("video/x-vnd.on2.vp9")) { + Logger.printDebug(() -> "Device supports VP9 hardware decoding."); + return true; + } + } + } + } + + Logger.printDebug(() -> "Device does not support VP9 hardware decoding."); + return false; + } + + private static boolean deviceHasAV1HardwareDecoding() { + // It appears all devices with hardware AV1 are also Android 10 or newer. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS); + + for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) { + if (codecInfo.isHardwareAccelerated() && !codecInfo.isEncoder()) { + for (String type : codecInfo.getSupportedTypes()) { + if (type.equalsIgnoreCase("video/av01")) { + Logger.printDebug(() -> "Device supports AV1 hardware decoding."); + return true; + } + } + } + } + } + + Logger.printDebug(() -> "Device does not support AV1 hardware decoding."); + return false; + } + + public static boolean allowVP9() { + return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_STREAMING_DATA_IOS_FORCE_AVC.get(); + } + + public static boolean allowAV1() { + return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1; + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/misc/requests/PlayerRoutes.java b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/requests/PlayerRoutes.java new file mode 100644 index 0000000000..a1a9181b3e --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/requests/PlayerRoutes.java @@ -0,0 +1,83 @@ +package app.revanced.integrations.youtube.patches.misc.requests; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import app.revanced.integrations.shared.requests.Requester; +import app.revanced.integrations.shared.requests.Route; +import app.revanced.integrations.shared.utils.Logger; +import app.revanced.integrations.youtube.patches.misc.client.AppClient.ClientType; + +public final class PlayerRoutes { + /** + * The base URL of requests of non-web clients to the InnerTube internal API. + */ + private static final String YOUTUBEI_V1_GAPIS_URL = "https://youtubei.googleapis.com/youtubei/v1/"; + + static final Route.CompiledRoute GET_STREAMING_DATA = new Route( + Route.Method.POST, + "player" + + "?fields=streamingData" + + "&alt=proto" + ).compile(); + + /** + * TCP connection and HTTP read timeout + */ + private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds. + + private PlayerRoutes() { + } + + static String createInnertubeBody(ClientType clientType) { + JSONObject innerTubeBody = new JSONObject(); + + try { + JSONObject context = new JSONObject(); + + JSONObject client = new JSONObject(); + client.put("clientName", clientType.name()); + client.put("clientVersion", clientType.appVersion); + client.put("deviceModel", clientType.model); + client.put("osVersion", clientType.osVersion); + if (clientType.make != null) { + client.put("deviceMake", clientType.make); + } + if (clientType.osName != null) { + client.put("osName", clientType.osName); + } + if (clientType.androidSdkVersion != null) { + client.put("androidSdkVersion", clientType.androidSdkVersion.toString()); + } + + context.put("client", client); + + innerTubeBody.put("context", context); + innerTubeBody.put("contentCheckOk", true); + innerTubeBody.put("racyCheckOk", true); + innerTubeBody.put("videoId", "%s"); + } catch (JSONException e) { + Logger.printException(() -> "Failed to create innerTubeBody", e); + } + + return innerTubeBody.toString(); + } + + /** @noinspection SameParameterValue*/ + static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException { + var connection = Requester.getConnectionFromCompiledRoute(YOUTUBEI_V1_GAPIS_URL, route); + + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("User-Agent", clientType.userAgent); + + connection.setUseCaches(false); + connection.setDoOutput(true); + + connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS); + return connection; + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/misc/requests/StreamingDataRequester.java b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/requests/StreamingDataRequester.java new file mode 100644 index 0000000000..f9af4cde1c --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/misc/requests/StreamingDataRequester.java @@ -0,0 +1,150 @@ +package app.revanced.integrations.youtube.patches.misc.requests; + +import static app.revanced.integrations.youtube.patches.misc.requests.PlayerRoutes.GET_STREAMING_DATA; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Future; + +import app.revanced.integrations.shared.settings.BaseSettings; +import app.revanced.integrations.shared.utils.Logger; +import app.revanced.integrations.shared.utils.Utils; +import app.revanced.integrations.youtube.patches.misc.client.AppClient.ClientType; +import app.revanced.integrations.youtube.settings.Settings; + +public class StreamingDataRequester { + private static final ClientType[] allClientTypes = { + ClientType.IOS, + ClientType.ANDROID_VR, + ClientType.ANDROID_UNPLUGGED, + ClientType.ANDROID_TESTSUITE, + ClientType.ANDROID_EMBEDDED_PLAYER, + ClientType.WEB, + ClientType.TVHTML5_SIMPLY_EMBEDDED_PLAYER + }; + + private static ClientType[] clientTypesToUse; + + static { + final ClientType clientType = Settings.SPOOF_STREAMING_DATA_TYPE.get(); + clientTypesToUse = new ClientType[allClientTypes.length + 1]; + clientTypesToUse[0] = clientType; + int i = 1; + for (ClientType c : allClientTypes) { + clientTypesToUse[i] = c; + i++; + } + clientTypesToUse = Arrays.stream(clientTypesToUse) + .distinct() + .toArray(ClientType[]::new); + } + + private static String lastSpoofedClientName = "Unknown"; + + public static String getLastSpoofedClientName() { + return lastSpoofedClientName; + } + + private StreamingDataRequester() { + } + + private static void handleConnectionError(String toastMessage, @Nullable Exception ex, boolean showToast) { + if (showToast) Utils.showToastShort(toastMessage); + Logger.printInfo(() -> toastMessage, ex); + } + + @Nullable + private static HttpURLConnection send(ClientType clientType, String videoId, + Map playerHeaders, + boolean showErrorToasts) { + final long startTime = System.currentTimeMillis(); + String clientTypeName = clientType.name(); + Logger.printDebug(() -> "Fetching video streams using client: " + clientType.name()); + + try { + HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType); + + String authHeader = playerHeaders.get("Authorization"); + String visitorId = playerHeaders.get("X-Goog-Visitor-Id"); + connection.setRequestProperty("Authorization", authHeader); + connection.setRequestProperty("X-Goog-Visitor-Id", visitorId); + + String innerTubeBody = String.format(PlayerRoutes.createInnertubeBody(clientType), videoId); + byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(requestBody.length); + connection.getOutputStream().write(requestBody); + + final int responseCode = connection.getResponseCode(); + if (responseCode == 200) return connection; + + handleConnectionError(clientTypeName + " not available with response code: " + + responseCode + " message: " + connection.getResponseMessage(), + null, showErrorToasts); + } catch (SocketTimeoutException ex) { + handleConnectionError("Connection timeout", ex, showErrorToasts); + } catch (IOException ex) { + handleConnectionError("Network error", ex, showErrorToasts); + } catch (Exception ex) { + Logger.printException(() -> "send failed", ex); + } finally { + Logger.printDebug(() -> clientTypeName + " took: " + (System.currentTimeMillis() - startTime) + "ms"); + } + + return null; + } + + public static Future fetch(@NonNull String videoId, Map playerHeaders) { + Objects.requireNonNull(videoId); + + return Utils.submitOnBackgroundThread(() -> { + final boolean debugEnabled = BaseSettings.ENABLE_DEBUG_LOGGING.get(); + + // Retry with different client if empty response body is received. + int i = 0; + for (ClientType clientType : clientTypesToUse) { + // Show an error if the last client type fails, or if the debug is enabled then show for all attempts. + final boolean showErrorToast = (++i == clientTypesToUse.length) || debugEnabled; + + HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast); + if (connection != null) { + try { + // gzip encoding doesn't response with content length (-1), + // but empty response body does. + if (connection.getContentLength() != 0) { + try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) >= 0) { + baos.write(buffer, 0, bytesRead); + } + + lastSpoofedClientName = clientType.friendlyName; + + return ByteBuffer.wrap(baos.toByteArray()); + } + } + } + } catch (IOException ex) { + Logger.printException(() -> "Fetch failed while processing response data", ex); + } + } + } + + handleConnectionError("Could not fetch any client streams", null, debugEnabled); + return null; + }); + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java index 4c1bc0f898..855b131aec 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java @@ -31,7 +31,9 @@ import app.revanced.integrations.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.StillImagesAvailability; import app.revanced.integrations.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.ThumbnailOption; import app.revanced.integrations.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.ThumbnailStillTime; +import app.revanced.integrations.youtube.patches.misc.SpoofStreamingDataPatch; import app.revanced.integrations.youtube.patches.misc.WatchHistoryPatch.WatchHistoryType; +import app.revanced.integrations.youtube.patches.misc.client.AppClient.ClientType; import app.revanced.integrations.youtube.patches.shorts.AnimationFeedbackPatch.AnimationType; import app.revanced.integrations.youtube.sponsorblock.SponsorBlockSettings; @@ -454,6 +456,11 @@ public class Settings extends BaseSettings { public static final BooleanSetting ENABLE_EXTERNAL_BROWSER = new BooleanSetting("revanced_enable_external_browser", TRUE, true); public static final BooleanSetting ENABLE_OPEN_LINKS_DIRECTLY = new BooleanSetting("revanced_enable_open_links_directly", TRUE); public static final BooleanSetting DISABLE_QUIC_PROTOCOL = new BooleanSetting("revanced_disable_quic_protocol", FALSE, true); + public static final BooleanSetting SPOOF_STREAMING_DATA = new BooleanSetting("revanced_spoof_streaming_data", TRUE, true,"revanced_spoof_streaming_data_user_dialog_message"); + public static final BooleanSetting SPOOF_STREAMING_DATA_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_streaming_data_ios_force_avc", FALSE, true, + "revanced_spoof_streaming_data_ios_force_avc_user_dialog_message", new SpoofStreamingDataPatch.ForceiOSAVCAvailability()); + public static final EnumSetting SPOOF_STREAMING_DATA_TYPE = new EnumSetting<>("revanced_spoof_streaming_data_type", ClientType.ANDROID_VR, true, parent(SPOOF_STREAMING_DATA)); + public static final BooleanSetting SPOOF_STREAMING_DATA_STATS_FOR_NERDS = new BooleanSetting("revanced_spoof_streaming_data_stats_for_nerds", TRUE, parent(SPOOF_STREAMING_DATA)); public static final EnumSetting WATCH_HISTORY_TYPE = new EnumSetting<>("revanced_watch_history_type", WatchHistoryType.REPLACE); diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java new file mode 100644 index 0000000000..bb839b8428 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java @@ -0,0 +1,86 @@ +package app.revanced.integrations.youtube.settings.preference; + +import static app.revanced.integrations.shared.utils.StringRef.str; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.Preference; +import android.preference.PreferenceManager; +import android.util.AttributeSet; + +import java.util.Arrays; +import java.util.List; + +import app.revanced.integrations.shared.settings.Setting; +import app.revanced.integrations.shared.utils.Utils; +import app.revanced.integrations.youtube.patches.misc.client.AppClient.ClientType; +import app.revanced.integrations.youtube.settings.Settings; + +@SuppressWarnings({"deprecation", "unused"}) +public class SpoofStreamingDataSideEffectsPreference extends Preference { + + private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { + // Because this listener may run before the ReVanced settings fragment updates Settings, + // this could show the prior config and not the current. + // + // Push this call to the end of the main run queue, + // so all other listeners are done and Settings is up to date. + Utils.runOnMainThread(this::updateUI); + }; + + public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SpoofStreamingDataSideEffectsPreference(Context context) { + super(context); + } + + private void addChangeListener() { + Setting.preferences.preferences.registerOnSharedPreferenceChangeListener(listener); + } + + private void removeChangeListener() { + Setting.preferences.preferences.unregisterOnSharedPreferenceChangeListener(listener); + } + + @Override + protected void onAttachedToHierarchy(PreferenceManager preferenceManager) { + super.onAttachedToHierarchy(preferenceManager); + updateUI(); + addChangeListener(); + } + + @Override + protected void onPrepareForRemoval() { + super.onPrepareForRemoval(); + removeChangeListener(); + } + + private static final List selectableClientTypes = Arrays.asList( + ClientType.IOS, + ClientType.ANDROID_VR, + ClientType.ANDROID_UNPLUGGED + ); + + private void updateUI() { + final ClientType clientType = Settings.SPOOF_STREAMING_DATA_TYPE.get(); + + final String summaryTextKey; + if (selectableClientTypes.contains(clientType)) { + summaryTextKey = "revanced_spoof_streaming_data_side_effects_" + clientType.name().toLowerCase(); + } else { + summaryTextKey = "revanced_spoof_streaming_data_side_effects_unknown"; + } + + setSummary(str(summaryTextKey)); + } +} \ No newline at end of file diff --git a/stub/src/main/java/org/chromium/net/UrlRequest.java b/stub/src/main/java/org/chromium/net/UrlRequest.java index 565fc22274..4c02f1a400 100644 --- a/stub/src/main/java/org/chromium/net/UrlRequest.java +++ b/stub/src/main/java/org/chromium/net/UrlRequest.java @@ -1,4 +1,8 @@ package org.chromium.net; public abstract class UrlRequest { + public abstract class Builder { + public abstract Builder addHeader(String name, String value); + public abstract UrlRequest build(); + } }