diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java
new file mode 100644
index 0000000000..f5300cb87c
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java
@@ -0,0 +1,79 @@
+package app.revanced.integrations.youtube.patches.spoof;
+
+import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.allowAV1;
+import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.allowVP9;
+
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+
+public enum ClientType {
+ // https://dumps.tadiphone.dev/dumps/oculus/eureka
+ IOS(5,
+ // iPhone 15 supports AV1 hardware decoding.
+ // Only use if this Android device also has hardware decoding.
+ allowAV1()
+ ? "iPhone16,2" // 15 Pro Max
+ : "iPhone11,4", // XS Max
+ // iOS 14+ forces VP9.
+ allowVP9()
+ ? "17.5.1.21F90"
+ : "13.7.17H35",
+ allowVP9()
+ ? "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)"
+ : "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 13_7 like Mac OS X)",
+ null,
+ // Version number should be a valid iOS release.
+ // https://www.ipa4fun.com/history/185230
+ "19.10.7"
+ ),
+ ANDROID_VR(28,
+ "Quest 3",
+ "12",
+ "com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
+ "32", // Android 12.1
+ "1.56.21"
+ );
+
+ /**
+ * YouTube
+ * client type
+ */
+ public final int id;
+
+ /**
+ * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
+ */
+ public final String model;
+
+ /**
+ * 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.
+ */
+ @Nullable
+ public final String androidSdkVersion;
+
+ /**
+ * App version.
+ */
+ public final String appVersion;
+
+ ClientType(int id, String model, String osVersion, String userAgent, @Nullable String androidSdkVersion, String appVersion) {
+ this.id = id;
+ this.model = model;
+ this.osVersion = osVersion;
+ this.userAgent = userAgent;
+ this.androidSdkVersion = androidSdkVersion;
+ this.appVersion = appVersion;
+ }
+}
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java
new file mode 100644
index 0000000000..6b147cd6fb
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java
@@ -0,0 +1,53 @@
+package app.revanced.integrations.youtube.patches.spoof;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.os.Build;
+
+import app.revanced.integrations.shared.Logger;
+import app.revanced.integrations.youtube.settings.Settings;
+
+public class DeviceHardwareSupport {
+ public static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9;
+ public static final boolean DEVICE_HAS_HARDWARE_DECODING_AV1;
+
+ static {
+ boolean vp9found = false;
+ boolean av1found = false;
+ MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+ final boolean deviceIsAndroidTenOrLater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
+
+ for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) {
+ final boolean isHardwareAccelerated = deviceIsAndroidTenOrLater
+ ? 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")) {
+ vp9found = true;
+ } else if (type.equalsIgnoreCase("video/av01")) {
+ av1found = true;
+ }
+ }
+ }
+ }
+
+ DEVICE_HAS_HARDWARE_DECODING_VP9 = vp9found;
+ DEVICE_HAS_HARDWARE_DECODING_AV1 = av1found;
+
+ Logger.printDebug(() -> DEVICE_HAS_HARDWARE_DECODING_AV1
+ ? "Device supports AV1 hardware decoding\n"
+ : "Device does not support AV1 hardware decoding\n"
+ + (DEVICE_HAS_HARDWARE_DECODING_VP9
+ ? "Device supports VP9 hardware decoding"
+ : "Device does not support VP9 hardware decoding"));
+ }
+
+ public static boolean allowVP9() {
+ return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_VIDEO_STREAMS_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/spoof/SpoofClientPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java
deleted file mode 100644
index 14e5e2f1b4..0000000000
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java
+++ /dev/null
@@ -1,279 +0,0 @@
-package app.revanced.integrations.youtube.patches.spoof;
-
-import static app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch.DeviceHardwareSupport.allowAV1;
-import static app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch.DeviceHardwareSupport.allowVP9;
-
-import android.media.MediaCodecInfo;
-import android.media.MediaCodecList;
-import android.net.Uri;
-import android.os.Build;
-
-import org.chromium.net.ExperimentalUrlRequest;
-
-import app.revanced.integrations.shared.Logger;
-import app.revanced.integrations.shared.settings.Setting;
-import app.revanced.integrations.youtube.patches.BackgroundPlaybackPatch;
-import app.revanced.integrations.youtube.settings.Settings;
-
-@SuppressWarnings("unused")
-public class SpoofClientPatch {
- private static final boolean SPOOF_CLIENT_ENABLED = Settings.SPOOF_CLIENT.get();
- private static final ClientType SPOOF_CLIENT_TYPE = Settings.SPOOF_CLIENT_TYPE.get();
- private static final boolean SPOOF_IOS = SPOOF_CLIENT_ENABLED && SPOOF_CLIENT_TYPE == ClientType.IOS;
-
- /**
- * 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);
-
- /**
- * 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_CLIENT_ENABLED) {
- 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_CLIENT_ENABLED) {
- 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 int getClientTypeId(int originalClientTypeId) {
- return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.id : originalClientTypeId;
- }
-
- /**
- * Injection point.
- */
- public static String getClientVersion(String originalClientVersion) {
- return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.appVersion : originalClientVersion;
- }
-
- /**
- * Injection point.
- */
- public static String getClientModel(String originalClientModel) {
- return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.model : originalClientModel;
- }
-
- /**
- * Injection point.
- * Fix video qualities missing, if spoofing to iOS by using the correct client OS version.
- */
- public static String getOsVersion(String originalOsVersion) {
- return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.osVersion : originalOsVersion;
- }
-
- /**
- * Injection point.
- */
- public static boolean enablePlayerGesture(boolean original) {
- return SPOOF_CLIENT_ENABLED || original;
- }
-
- /**
- * Injection point.
- */
- public static boolean isClientSpoofingEnabled() {
- return SPOOF_CLIENT_ENABLED;
- }
-
- /**
- * Injection point.
- * When spoofing the client to iOS, the playback speed menu is missing from the player response.
- * Return true to force create the playback speed menu.
- */
- public static boolean forceCreatePlaybackSpeedMenu(boolean original) {
- return SPOOF_IOS || original;
- }
-
- /**
- * Injection point.
- * When spoofing the client to iOS, background audio only playback of livestreams fails.
- * Return true to force enable audio background play.
- */
- public static boolean overrideBackgroundAudioPlayback() {
- return SPOOF_IOS && BackgroundPlaybackPatch.playbackIsNotShort();
- }
-
- /**
- * Injection point.
- * Fix video qualities missing, if spoofing to iOS by using the correct iOS user-agent.
- */
- public static ExperimentalUrlRequest overrideUserAgent(ExperimentalUrlRequest.Builder builder, String url) {
- if (SPOOF_CLIENT_ENABLED) {
- String path = Uri.parse(url).getPath();
- if (path != null && path.contains("player")) {
- return builder.addHeader("User-Agent", SPOOF_CLIENT_TYPE.userAgent).build();
- }
- }
-
- return builder.build();
- }
-
- // Must check for device features in a separate class and cannot place this code inside
- // the Patch or ClientType enum due to cyclic Setting references.
- static 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;
- }
-
- static boolean allowVP9() {
- return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_CLIENT_IOS_FORCE_AVC.get();
- }
-
- static boolean allowAV1() {
- return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1;
- }
- }
-
- public enum ClientType {
- // https://dumps.tadiphone.dev/dumps/oculus/eureka
- IOS(5,
- // iPhone 15 supports AV1 hardware decoding.
- // Only use if this Android device also has hardware decoding.
- allowAV1()
- ? "iPhone16,2" // 15 Pro Max
- : "iPhone11,4", // XS Max
- // iOS 14+ forces VP9.
- allowVP9()
- ? "17.5.1.21F90"
- : "13.7.17H35",
- allowVP9()
- ? "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)"
- : "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 13_7 like Mac OS X)",
- // Version number should be a valid iOS release.
- // https://www.ipa4fun.com/history/185230
- "19.10.7"
- ),
- ANDROID_VR(28,
- "Quest 3",
- "12",
- "com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
- "1.56.21"
- );
-
- /**
- * YouTube
- * client type
- */
- final int id;
-
- /**
- * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
- */
- final String model;
-
- /**
- * Device OS version.
- */
- final String osVersion;
-
- /**
- * Player user-agent.
- */
- final String userAgent;
-
- /**
- * App version.
- */
- final String appVersion;
-
- ClientType(int id, String model, String osVersion, String userAgent, String appVersion) {
- this.id = id;
- this.model = model;
- this.osVersion = osVersion;
- this.userAgent = userAgent;
- this.appVersion = appVersion;
- }
- }
-
- public static final class ForceiOSAVCAvailability implements Setting.Availability {
- @Override
- public boolean isAvailable() {
- return Settings.SPOOF_CLIENT.get() && Settings.SPOOF_CLIENT_TYPE.get() == ClientType.IOS;
- }
- }
-}
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java
deleted file mode 100644
index 41f03ed781..0000000000
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java
+++ /dev/null
@@ -1,242 +0,0 @@
-package app.revanced.integrations.youtube.patches.spoof;
-
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageView;
-import androidx.annotation.Nullable;
-import app.revanced.integrations.shared.Logger;
-import app.revanced.integrations.shared.Utils;
-import app.revanced.integrations.youtube.patches.VideoInformation;
-import app.revanced.integrations.youtube.settings.Settings;
-import app.revanced.integrations.youtube.shared.PlayerType;
-
-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.shared.Utils.containsAny;
-import static app.revanced.integrations.youtube.patches.spoof.requests.StoryboardRendererRequester.getStoryboardRenderer;
-
-/** @noinspection unused*/
-@Deprecated
-public class SpoofSignaturePatch {
- /**
- * Parameter (also used by
- * yt-dlp)
- * to fix playback issues.
- */
- private static final String INCOGNITO_PARAMETERS = "CgIQBg==";
-
- /**
- * Parameters used when playing clips.
- */
- private static final String CLIPS_PARAMETERS = "kAIB";
-
- /**
- * Parameters causing playback issues.
- */
- private static final String[] AUTOPLAY_PARAMETERS = {
- "YAHI", // Autoplay in feed.
- "SAFg" // Autoplay in scrim.
- };
-
- /**
- * Parameter used for autoplay in scrim.
- * Prepend this parameter to mute video playback (for autoplay in feed).
- */
- private static final String SCRIM_PARAMETER = "SAFgAXgB";
-
- /**
- * Last video id loaded. Used to prevent reloading the same spec multiple times.
- */
- @Nullable
- private static volatile String lastPlayerResponseVideoId;
-
- @Nullable
- private static volatile Future rendererFuture;
-
- private static volatile boolean useOriginalStoryboardRenderer;
-
- private static volatile boolean isPlayingShorts;
-
- @Nullable
- private static StoryboardRenderer getRenderer(boolean waitForCompletion) {
- Future future = rendererFuture;
- 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 off the main thread, and called multiple times for each video.
- *
- * @param parameters Original protobuf parameter value.
- */
- public static String spoofParameter(String parameters, String videoId, boolean isShortAndOpeningOrPlaying) {
- try {
- Logger.printDebug(() -> "Original protobuf parameter value: " + parameters);
-
- if (parameters == null || !Settings.SPOOF_SIGNATURE.get()) {
- return parameters;
- }
-
- // Clip's player parameters contain a lot of information (e.g. video start and end time or whether it loops)
- // For this reason, the player parameters of a clip are usually very long (150~300 characters).
- // Clips are 60 seconds or less in length, so no spoofing.
- //noinspection AssignmentUsedAsCondition
- if (useOriginalStoryboardRenderer = parameters.length() > 150 || parameters.startsWith(CLIPS_PARAMETERS)) {
- return parameters;
- }
-
- // Shorts do not need to be spoofed.
- //noinspection AssignmentUsedAsCondition
- if (useOriginalStoryboardRenderer = VideoInformation.playerParametersAreShort(parameters)) {
- isPlayingShorts = true;
- return parameters;
- }
- isPlayingShorts = false;
-
- boolean isPlayingFeed = PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL
- && containsAny(parameters, AUTOPLAY_PARAMETERS);
- if (isPlayingFeed) {
- //noinspection AssignmentUsedAsCondition
- if (useOriginalStoryboardRenderer = !Settings.SPOOF_SIGNATURE_IN_FEED.get()) {
- // Don't spoof the feed video playback. This will cause video playback issues,
- // but only if user continues watching for more than 1 minute.
- return parameters;
- }
- // Spoof the feed video. Video will show up in watch history and video subtitles are missing.
- fetchStoryboardRenderer();
- return SCRIM_PARAMETER + INCOGNITO_PARAMETERS;
- }
-
- fetchStoryboardRenderer();
- } catch (Exception ex) {
- Logger.printException(() -> "spoofParameter failure", ex);
- }
- return INCOGNITO_PARAMETERS;
- }
-
- private static void fetchStoryboardRenderer() {
- if (!Settings.SPOOF_STORYBOARD_RENDERER.get()) {
- lastPlayerResponseVideoId = null;
- rendererFuture = null;
- return;
- }
- String videoId = VideoInformation.getPlayerResponseVideoId();
- if (!videoId.equals(lastPlayerResponseVideoId)) {
- rendererFuture = Utils.submitOnBackgroundThread(() -> getStoryboardRenderer(videoId));
- lastPlayerResponseVideoId = videoId;
- }
- // 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);
- }
-
- private static String getStoryboardRendererSpec(String originalStoryboardRendererSpec,
- boolean returnNullIfLiveStream) {
- if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) {
- StoryboardRenderer renderer = getRenderer(false);
- if (renderer != null) {
- if (returnNullIfLiveStream && renderer.isLiveStream) {
- return null;
- }
-
- if (renderer.spec != null) {
- return renderer.spec;
- }
- }
- }
-
- return originalStoryboardRendererSpec;
- }
-
- /**
- * Injection point.
- * Called from background threads and from the main thread.
- */
- @Nullable
- public static String getStoryboardRendererSpec(String originalStoryboardRendererSpec) {
- return getStoryboardRendererSpec(originalStoryboardRendererSpec, false);
- }
-
- /**
- * Injection point.
- * Uses additional check to handle live streams.
- * Called from background threads and from the main thread.
- */
- @Nullable
- public static String getStoryboardDecoderRendererSpec(String originalStoryboardRendererSpec) {
- return getStoryboardRendererSpec(originalStoryboardRendererSpec, true);
- }
-
- /**
- * Injection point.
- */
- public static int getRecommendedLevel(int originalLevel) {
- if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) {
- StoryboardRenderer renderer = getRenderer(false);
- if (renderer != null) {
- if (renderer.recommendedLevel != null) {
- return renderer.recommendedLevel;
- }
- }
- }
-
- return originalLevel;
- }
-
- /**
- * Injection point. Forces seekbar to be shown for paid videos or
- * if {@link Settings#SPOOF_STORYBOARD_RENDERER} is not enabled.
- */
- public static boolean getSeekbarThumbnailOverrideValue() {
- if (!Settings.SPOOF_SIGNATURE.get()) {
- return false;
- }
- StoryboardRenderer renderer = getRenderer(false);
- if (renderer == null) {
- // Spoof storyboard renderer is turned off,
- // video is paid, or the storyboard fetch timed out.
- // Show empty thumbnails so the seek time and chapters still show up.
- return true;
- }
- return renderer.spec != null;
- }
-
- /**
- * Injection point.
- *
- * @param view seekbar thumbnail view. Includes both shorts and regular videos.
- */
- public static void seekbarImageViewCreated(ImageView view) {
- try {
- if (!Settings.SPOOF_SIGNATURE.get()
- || Settings.SPOOF_STORYBOARD_RENDERER.get()) {
- return;
- }
- if (isPlayingShorts) return;
-
- view.setVisibility(View.GONE);
- // Also hide the border around the thumbnail (otherwise a 1 pixel wide bordered frame is visible).
- ViewGroup parentLayout = (ViewGroup) view.getParent();
- parentLayout.setPadding(0, 0, 0, 0);
- } catch (Exception ex) {
- Logger.printException(() -> "seekbarImageViewCreated failure", ex);
- }
- }
-}
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java
new file mode 100644
index 0000000000..d3c964078f
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java
@@ -0,0 +1,170 @@
+package app.revanced.integrations.youtube.patches.spoof;
+
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.integrations.shared.Logger;
+import app.revanced.integrations.shared.Utils;
+import app.revanced.integrations.shared.settings.BaseSettings;
+import app.revanced.integrations.shared.settings.Setting;
+import app.revanced.integrations.youtube.patches.spoof.requests.StreamingDataRequest;
+import app.revanced.integrations.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofVideoStreamsPatch {
+ public static final class ForceiOSAVCAvailability implements Setting.Availability {
+ @Override
+ public boolean isAvailable() {
+ return Settings.SPOOF_VIDEO_STREAMS.get() && Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS;
+ }
+ }
+
+ private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_VIDEO_STREAMS.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);
+
+ /**
+ * 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 fetchStreams(String url, Map requestHeaders) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ Uri uri = Uri.parse(url);
+ String path = uri.getPath();
+ // 'heartbeat' has no video id and appears to be only after playback has started.
+ if (path != null && path.contains("player") && !path.contains("heartbeat")) {
+ String videoId = Objects.requireNonNull(uri.getQueryParameter("id"));
+ StreamingDataRequest.fetchRequest(videoId, requestHeaders);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "buildRequest failure", ex);
+ }
+ }
+ }
+
+ /**
+ * Injection point.
+ * Fix playback by replace the streaming data.
+ * Called after {@link #fetchStreams(String, Map)}.
+ */
+ @Nullable
+ public static ByteBuffer getStreamingData(String videoId) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
+ if (request != null) {
+ // This hook is always called off the main thread,
+ // but this can later be called for the same video id from the main thread.
+ // This is not a concern, since the fetch will always be finished
+ // and never block the main thread.
+ // But if debugging, then still verify this is the situation.
+ if (BaseSettings.DEBUG.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
+ Logger.printException(() -> "Error: Blocking main thread");
+ }
+
+ var stream = request.getStream();
+ if (stream != null) {
+ Logger.printDebug(() -> "Overriding video stream: " + videoId);
+ return stream;
+ }
+ }
+
+ Logger.printDebug(() -> "Not overriding streaming data (video stream is null): " + videoId);
+ } catch (Exception 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 clientNameQueryKey = "c";
+ final boolean iosClient = "IOS".equals(uri.getQueryParameter(clientNameQueryKey));
+ if (iosClient && path != null && path.contains("videoplayback")) {
+ return null;
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex);
+ }
+ }
+
+ return postData;
+ }
+}
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java
deleted file mode 100644
index 5014a5fcdc..0000000000
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package app.revanced.integrations.youtube.patches.spoof;
-
-import androidx.annotation.Nullable;
-
-import org.jetbrains.annotations.NotNull;
-
-@Deprecated
-public final class StoryboardRenderer {
- public final String videoId;
- @Nullable
- public final String spec;
- public final boolean isLiveStream;
- /**
- * Recommended image quality level, or NULL if no recommendation exists.
- */
- @Nullable
- public final Integer recommendedLevel;
-
- public StoryboardRenderer(String videoId, @Nullable String spec, boolean isLiveStream, @Nullable Integer recommendedLevel) {
- this.videoId = videoId;
- this.spec = spec;
- this.isLiveStream = isLiveStream;
- this.recommendedLevel = recommendedLevel;
- }
-
- @NotNull
- @Override
- public String toString() {
- return "StoryboardRenderer{" +
- "videoId=" + videoId +
- ", isLiveStream=" + isLiveStream +
- ", spec='" + spec + '\'' +
- ", recommendedLevel=" + recommendedLevel +
- '}';
- }
-}
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java
index 1927b1d68a..299110f461 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java
@@ -1,94 +1,68 @@
package app.revanced.integrations.youtube.patches.spoof.requests;
-import app.revanced.integrations.youtube.requests.Requester;
-import app.revanced.integrations.youtube.requests.Route;
-import app.revanced.integrations.shared.Logger;
-import app.revanced.integrations.shared.Utils;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
+import app.revanced.integrations.shared.Logger;
+import app.revanced.integrations.youtube.patches.spoof.ClientType;
+import app.revanced.integrations.youtube.requests.Requester;
+import app.revanced.integrations.youtube.requests.Route;
+
final class PlayerRoutes {
- private static final String YT_API_URL = "https://www.youtube.com/youtubei/v1/";
- static final Route.CompiledRoute GET_STORYBOARD_SPEC_RENDERER = new Route(
+ private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";
+
+ static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
Route.Method.POST,
"player" +
- "?fields=storyboards.playerStoryboardSpecRenderer," +
- "storyboards.playerLiveStoryboardSpecRenderer," +
- "playabilityStatus.status"
+ "?fields=streamingData" +
+ "&alt=proto"
).compile();
- static final String ANDROID_INNER_TUBE_BODY;
- static final String TV_EMBED_INNER_TUBE_BODY;
-
/**
* TCP connection and HTTP read timeout
*/
private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.
- static {
- JSONObject innerTubeBody = new JSONObject();
+ private PlayerRoutes() {
+ }
+
+ static String createInnertubeBody(ClientType clientType) {
+ JSONObject innerTubeBody = new JSONObject();
try {
JSONObject context = new JSONObject();
JSONObject client = new JSONObject();
- client.put("clientName", "ANDROID");
- client.put("clientVersion", Utils.getAppVersionName());
- client.put("androidSdkVersion", 34);
+ client.put("clientName", clientType.name());
+ client.put("clientVersion", clientType.appVersion);
+ client.put("deviceModel", clientType.model);
+ client.put("osVersion", clientType.osVersion);
+ if (clientType.androidSdkVersion != null) {
+ client.put("androidSdkVersion", clientType.androidSdkVersion);
+ }
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);
}
- ANDROID_INNER_TUBE_BODY = innerTubeBody.toString();
-
- JSONObject tvEmbedInnerTubeBody = new JSONObject();
-
- try {
- JSONObject context = new JSONObject();
-
- JSONObject client = new JSONObject();
- client.put("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER");
- client.put("clientVersion", "2.0");
- client.put("platform", "TV");
- client.put("clientScreen", "EMBED");
-
- JSONObject thirdParty = new JSONObject();
- thirdParty.put("embedUrl", "https://www.youtube.com/watch?v=%s");
-
- context.put("thirdParty", thirdParty);
- context.put("client", client);
-
- tvEmbedInnerTubeBody.put("context", context);
- tvEmbedInnerTubeBody.put("videoId", "%s");
- } catch (JSONException e) {
- Logger.printException(() -> "Failed to create tvEmbedInnerTubeBody", e);
- }
-
- TV_EMBED_INNER_TUBE_BODY = tvEmbedInnerTubeBody.toString();
- }
-
- private PlayerRoutes() {
+ return innerTubeBody.toString();
}
/** @noinspection SameParameterValue*/
- static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route) throws IOException {
+ static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route);
- connection.setRequestProperty(
- "User-Agent", "com.google.android.youtube/" +
- Utils.getAppVersionName() +
- " (Linux; U; Android 12; GB) gzip"
- );
- connection.setRequestProperty("X-Goog-Api-Format-Version", "2");
connection.setRequestProperty("Content-Type", "application/json");
+ connection.setRequestProperty("User-Agent", clientType.userAgent);
connection.setUseCaches(false);
connection.setDoOutput(true);
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java
deleted file mode 100644
index 0cbec19400..0000000000
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java
+++ /dev/null
@@ -1,153 +0,0 @@
-package app.revanced.integrations.youtube.patches.spoof.requests;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import app.revanced.integrations.shared.settings.BaseSettings;
-import app.revanced.integrations.youtube.patches.spoof.StoryboardRenderer;
-import app.revanced.integrations.youtube.requests.Requester;
-import app.revanced.integrations.shared.Logger;
-import app.revanced.integrations.shared.Utils;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import java.io.IOException;
-import java.net.HttpURLConnection;
-import java.net.SocketTimeoutException;
-import java.nio.charset.StandardCharsets;
-import java.util.Objects;
-
-import static app.revanced.integrations.shared.StringRef.str;
-import static app.revanced.integrations.youtube.patches.spoof.requests.PlayerRoutes.*;
-
-public class StoryboardRendererRequester {
-
- private StoryboardRendererRequester() {
- }
-
- private static void randomlyWaitIfLocallyDebugging() {
- final boolean randomlyWait = false; // Enable to simulate slow connection responses.
- if (randomlyWait) {
- final long maximumTimeToRandomlyWait = 10000;
- Utils.doNothingForDuration(maximumTimeToRandomlyWait);
- }
- }
-
- private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex,
- boolean showToastOnIOException) {
- if (showToastOnIOException) Utils.showToastShort(toastMessage);
- Logger.printInfo(() -> toastMessage, ex);
- }
-
- @Nullable
- private static JSONObject fetchPlayerResponse(@NonNull String requestBody, boolean showToastOnIOException) {
- final long startTime = System.currentTimeMillis();
- try {
- Utils.verifyOffMainThread();
- Objects.requireNonNull(requestBody);
-
- final byte[] innerTubeBody = requestBody.getBytes(StandardCharsets.UTF_8);
-
- HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STORYBOARD_SPEC_RENDERER);
- connection.getOutputStream().write(innerTubeBody, 0, innerTubeBody.length);
-
- final int responseCode = connection.getResponseCode();
- randomlyWaitIfLocallyDebugging();
- if (responseCode == 200) return Requester.parseJSONObject(connection);
-
- // Always show a toast for this, as a non 200 response means something is broken.
- // Not a normal code path and should not be reached, so no translations are needed.
- handleConnectionError("Spoof storyboard not available: " + responseCode,
- null, showToastOnIOException || BaseSettings.DEBUG_TOAST_ON_ERROR.get());
- connection.disconnect();
- } catch (SocketTimeoutException ex) {
- handleConnectionError(str("revanced_spoof_client_storyboard_timeout"), ex, showToastOnIOException);
- } catch (IOException ex) {
- handleConnectionError(str("revanced_spoof_client_storyboard_io_exception", ex.getMessage()),
- ex, showToastOnIOException);
- } catch (Exception ex) {
- Logger.printException(() -> "Spoof storyboard fetch failed", ex); // Should never happen.
- } finally {
- Logger.printDebug(() -> "Request took: " + (System.currentTimeMillis() - startTime) + "ms");
- }
-
- return null;
- }
-
- private static boolean isPlayabilityStatusOk(@NonNull JSONObject playerResponse) {
- try {
- return playerResponse.getJSONObject("playabilityStatus").getString("status").equals("OK");
- } catch (JSONException e) {
- Logger.printDebug(() -> "Failed to get playabilityStatus for response: " + playerResponse);
- }
-
- return false;
- }
-
- /**
- * Fetches the storyboardRenderer from the innerTubeBody.
- * @param innerTubeBody The innerTubeBody to use to fetch the storyboardRenderer.
- * @return StoryboardRenderer or null if playabilityStatus is not OK.
- */
- @Nullable
- private static StoryboardRenderer getStoryboardRendererUsingBody(String videoId,
- @NonNull String innerTubeBody,
- boolean showToastOnIOException) {
- final JSONObject playerResponse = fetchPlayerResponse(innerTubeBody, showToastOnIOException);
- if (playerResponse != null && isPlayabilityStatusOk(playerResponse))
- return getStoryboardRendererUsingResponse(videoId, playerResponse);
-
- return null;
- }
-
- @Nullable
- private static StoryboardRenderer getStoryboardRendererUsingResponse(@NonNull String videoId, @NonNull JSONObject playerResponse) {
- try {
- Logger.printDebug(() -> "Parsing response: " + playerResponse);
- if (!playerResponse.has("storyboards")) {
- Logger.printDebug(() -> "Using empty storyboard");
- return new StoryboardRenderer(videoId, null, false, null);
- }
- final JSONObject storyboards = playerResponse.getJSONObject("storyboards");
- final boolean isLiveStream = storyboards.has("playerLiveStoryboardSpecRenderer");
- final String storyboardsRendererTag = isLiveStream
- ? "playerLiveStoryboardSpecRenderer"
- : "playerStoryboardSpecRenderer";
-
- final var rendererElement = storyboards.getJSONObject(storyboardsRendererTag);
- StoryboardRenderer renderer = new StoryboardRenderer(
- videoId,
- rendererElement.getString("spec"),
- isLiveStream,
- rendererElement.has("recommendedLevel")
- ? rendererElement.getInt("recommendedLevel")
- : null
- );
-
- Logger.printDebug(() -> "Fetched: " + renderer);
-
- return renderer;
- } catch (JSONException e) {
- Logger.printException(() -> "Failed to get storyboardRenderer", e);
- }
-
- return null;
- }
-
- @Nullable
- public static StoryboardRenderer getStoryboardRenderer(@NonNull String videoId) {
- Objects.requireNonNull(videoId);
-
- var renderer = getStoryboardRendererUsingBody(videoId,
- String.format(ANDROID_INNER_TUBE_BODY, videoId), false);
- if (renderer == null) {
- Logger.printDebug(() -> videoId + " not available using Android client");
- renderer = getStoryboardRendererUsingBody(videoId,
- String.format(TV_EMBED_INNER_TUBE_BODY, videoId, videoId), true);
- if (renderer == null) {
- Logger.printDebug(() -> videoId + " not available using TV embedded client");
- }
- }
-
- return renderer;
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java
new file mode 100644
index 0000000000..c86b352f08
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java
@@ -0,0 +1,215 @@
+package app.revanced.integrations.youtube.patches.spoof.requests;
+
+import static app.revanced.integrations.youtube.patches.spoof.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.*;
+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.Logger;
+import app.revanced.integrations.shared.Utils;
+import app.revanced.integrations.shared.settings.BaseSettings;
+import app.revanced.integrations.youtube.patches.spoof.ClientType;
+import app.revanced.integrations.youtube.settings.Settings;
+
+/**
+ * Video streaming data. Fetching is tied to the behavior YT uses,
+ * where this class fetches the streams only when YT fetches.
+ *
+ * Effectively the cache expiration of these fetches is the same as the stock app,
+ * since the stock app would not use expired streams and therefor
+ * the integrations replace stream hook is called only if YT
+ * would have used it's own client streams.
+ */
+public class StreamingDataRequest {
+
+ private static final ClientType[] CLIENT_ORDER_TO_USE;
+
+ static {
+ ClientType[] allClientTypes = ClientType.values();
+ ClientType preferredClient = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
+
+ CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
+ CLIENT_ORDER_TO_USE[0] = preferredClient;
+
+ int i = 1;
+ for (ClientType c : allClientTypes) {
+ if (c != preferredClient) {
+ CLIENT_ORDER_TO_USE[i++] = c;
+ }
+ }
+ }
+
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000;
+
+ /**
+ * Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
+ */
+ private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000;
+
+ private static final Map cache = Collections.synchronizedMap(
+ new LinkedHashMap<>(100) {
+ /**
+ * Cache limit must be greater than the maximum number of videos open at once,
+ * which theoretically is more than 4 (3 Shorts + one regular minimized video).
+ * But instead use a much larger value, to handle if a video viewed a while ago
+ * is somehow still referenced. Each stream is a small array of Strings
+ * so memory usage is not a concern.
+ */
+ private static final int CACHE_LIMIT = 50;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
+ }
+ });
+
+ public static void fetchRequest(String videoId, Map fetchHeaders) {
+ // Always fetch, even if there is a existing request for the same video.
+ cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders));
+ }
+
+ @Nullable
+ public static StreamingDataRequest getRequestForVideoId(String videoId) {
+ return cache.get(videoId);
+ }
+
+ 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) {
+ Objects.requireNonNull(clientType);
+ Objects.requireNonNull(videoId);
+ Objects.requireNonNull(playerHeaders);
+
+ final long startTime = System.currentTimeMillis();
+ String clientTypeName = clientType.name();
+ Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType.name());
+
+ try {
+ HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType);
+ connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
+
+ 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(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
+ }
+
+ return null;
+ }
+
+ private static ByteBuffer fetch(String videoId, Map playerHeaders) {
+ final boolean debugEnabled = BaseSettings.DEBUG.get();
+
+ // Retry with different client if empty response body is received.
+ int i = 0;
+ for (ClientType clientType : CLIENT_ORDER_TO_USE) {
+ // Show an error if the last client type fails, or if the debug is enabled then show for all attempts.
+ final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.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[2048];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(buffer)) >= 0) {
+ baos.write(buffer, 0, bytesRead);
+ }
+
+ 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;
+ }
+
+ private final String videoId;
+ private final Future future;
+
+ private StreamingDataRequest(String videoId, Map playerHeaders) {
+ Objects.requireNonNull(playerHeaders);
+ this.videoId = videoId;
+ this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
+ }
+
+ public boolean fetchCompleted() {
+ return future.isDone();
+ }
+
+ @Nullable
+ public ByteBuffer getStream() {
+ try {
+ return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printInfo(() -> "getStream timed out", ex);
+ } catch (InterruptedException ex) {
+ Logger.printException(() -> "getStream interrupted", ex);
+ Thread.currentThread().interrupt(); // Restore interrupt status flag.
+ } catch (ExecutionException ex) {
+ Logger.printException(() -> "getStream failure", ex);
+ }
+
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}';
+ }
+}
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 3cbd7b58eb..85bc6c8e7c 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
@@ -7,8 +7,9 @@
import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.StillImagesAvailability;
import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.ThumbnailOption;
import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.ThumbnailStillTime;
+import app.revanced.integrations.youtube.patches.spoof.ClientType;
import app.revanced.integrations.youtube.patches.spoof.SpoofAppVersionPatch;
-import app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch;
+import app.revanced.integrations.youtube.patches.spoof.SpoofVideoStreamsPatch;
import app.revanced.integrations.youtube.sponsorblock.SponsorBlockSettings;
import java.util.Arrays;
@@ -19,7 +20,6 @@
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType;
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1;
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3;
-import static app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch.ClientType;
import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
@@ -256,10 +256,10 @@ public class Settings extends BaseSettings {
"revanced_spoof_device_dimensions_user_dialog_message");
public static final BooleanSetting BYPASS_URL_REDIRECTS = new BooleanSetting("revanced_bypass_url_redirects", TRUE);
public static final BooleanSetting ANNOUNCEMENTS = new BooleanSetting("revanced_announcements", TRUE);
- public static final BooleanSetting SPOOF_CLIENT = new BooleanSetting("revanced_spoof_client", TRUE, true,"revanced_spoof_client_user_dialog_message");
- public static final BooleanSetting SPOOF_CLIENT_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_client_ios_force_avc", FALSE, true,
- "revanced_spoof_client_ios_force_avc_user_dialog_message", new SpoofClientPatch.ForceiOSAVCAvailability());
- public static final EnumSetting SPOOF_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_client_type", ClientType.IOS, true, parent(SPOOF_CLIENT));
+ public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true,"revanced_spoof_video_streams_user_dialog_message");
+ public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true,
+ "revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofVideoStreamsPatch.ForceiOSAVCAvailability());
+ public static final EnumSetting SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type", ClientType.IOS, true, parent(SPOOF_VIDEO_STREAMS));
@Deprecated
public static final StringSetting DEPRECATED_ANNOUNCEMENT_LAST_HASH = new StringSetting("revanced_announcement_last_hash", "");
public static final IntegerSetting ANNOUNCEMENT_LAST_ID = new IntegerSetting("revanced_announcement_last_id", -1);
diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java
new file mode 100644
index 0000000000..8d37017e61
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java
@@ -0,0 +1,61 @@
+package app.revanced.integrations.youtube.settings.preference;
+
+import static app.revanced.integrations.shared.StringRef.str;
+import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.DEVICE_HAS_HARDWARE_DECODING_VP9;
+
+import android.content.Context;
+import android.preference.SwitchPreference;
+import android.util.AttributeSet;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ForceAVCSpoofingPreference extends SwitchPreference {
+ {
+ if (!DEVICE_HAS_HARDWARE_DECODING_VP9) {
+ setSummaryOn(str("revanced_spoof_video_streams_ios_force_avc_no_hardware_vp9_summary_on"));
+ }
+ }
+
+ public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ForceAVCSpoofingPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ForceAVCSpoofingPreference(Context context) {
+ super(context);
+ }
+
+ private void updateUI() {
+ if (DEVICE_HAS_HARDWARE_DECODING_VP9) {
+ return;
+ }
+
+ // Temporarily remove the preference key to allow changing this preference without
+ // causing the settings UI listeners from showing reboot dialogs by the changes made here.
+ String key = getKey();
+ setKey(null);
+
+ // This setting cannot be changed by the user.
+ super.setEnabled(false);
+ super.setChecked(true);
+
+ setKey(key);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ updateUI();
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ super.setChecked(checked);
+
+ updateUI();
+ }
+}
diff --git a/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java b/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java
deleted file mode 100644
index cdf2593e79..0000000000
--- a/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package org.chromium.net;
-
-public abstract class ExperimentalUrlRequest {
- public abstract class Builder {
- public abstract ExperimentalUrlRequest.Builder addHeader(String name, String value);
- public abstract ExperimentalUrlRequest build();
- }
-}
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();
+ }
}