From 04682353af9831d312a82264a8944268c7901db7 Mon Sep 17 00:00:00 2001 From: Zain Date: Wed, 18 Sep 2024 05:45:14 +0700 Subject: [PATCH] fix(YouTube): Fix issues related to playback by replace streaming data (#680) Co-authored-by: kitadai31 <90122968+kitadai31@users.noreply.github.com> Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Co-authored-by: oSumAtrIX --- .../youtube/patches/spoof/ClientType.java | 79 +++++ .../patches/spoof/DeviceHardwareSupport.java | 53 ++++ .../patches/spoof/SpoofClientPatch.java | 279 ------------------ .../patches/spoof/SpoofSignaturePatch.java | 242 --------------- .../patches/spoof/SpoofVideoStreamsPatch.java | 170 +++++++++++ .../patches/spoof/StoryboardRenderer.java | 36 --- .../patches/spoof/requests/PlayerRoutes.java | 80 ++--- .../requests/StoryboardRendererRequester.java | 153 ---------- .../spoof/requests/StreamingDataRequest.java | 215 ++++++++++++++ .../youtube/settings/Settings.java | 12 +- .../ForceAVCSpoofingPreference.java | 61 ++++ .../chromium/net/ExperimentalUrlRequest.java | 8 - .../java/org/chromium/net/UrlRequest.java | 4 + 13 files changed, 615 insertions(+), 777 deletions(-) create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java delete mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java delete mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java delete mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java delete mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java delete mode 100644 stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java 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 00000000..f5300cb8 --- /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 00000000..6b147cd6 --- /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 14e5e2f1..00000000 --- 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 41f03ed7..00000000 --- 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 00000000..d3c96407 --- /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 5014a5fc..00000000 --- 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 1927b1d6..299110f4 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 0cbec194..00000000 --- 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 00000000..c86b352f --- /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 3cbd7b58..85bc6c8e 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.DeAr 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.shared.settings.Setting.*; 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 00000000..8d37017e --- /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 cdf2593e..00000000 --- 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 565fc222..4c02f1a4 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(); + } }