diff --git a/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofSignaturePatch.java b/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofSignaturePatch.java index af7e7cb0..114640e1 100644 --- a/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofSignaturePatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofSignaturePatch.java @@ -1,6 +1,5 @@ package app.revanced.integrations.patches.spoof; -import static app.revanced.integrations.patches.spoof.requests.StoryboardRendererRequester.getStoryboardRenderer; import static app.revanced.integrations.utils.ReVancedUtils.containsAny; import android.view.View; @@ -9,16 +8,11 @@ import android.widget.ImageView; import androidx.annotation.Nullable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - import app.revanced.integrations.patches.VideoInformation; +import app.revanced.integrations.patches.spoof.requests.StoryboardRendererRequester; import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.shared.PlayerType; import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.utils.ReVancedUtils; /** @noinspection unused*/ public class SpoofSignaturePatch { @@ -51,29 +45,16 @@ public class SpoofSignaturePatch { /** * Last video id loaded. Used to prevent reloading the same spec multiple times. */ + @Nullable private static volatile String lastPlayerResponseVideoId; - private static volatile Future rendererFuture; + @Nullable + private static volatile StoryboardRenderer videoRenderer; private static volatile boolean useOriginalStoryboardRenderer; private static volatile boolean isPlayingShorts; - @Nullable - private static StoryboardRenderer getRenderer() { - if (rendererFuture != null) { - try { - return rendererFuture.get(2000, TimeUnit.MILLISECONDS); - } catch (TimeoutException ex) { - LogHelper.printDebug(() -> "Could not get renderer (get timed out)"); - } catch (ExecutionException | InterruptedException ex) { - // Should never happen. - LogHelper.printException(() -> "Could not get renderer", ex); - } - } - return null; - } - /** * Injection point. * @@ -82,62 +63,64 @@ public class SpoofSignaturePatch { * @param parameters Original protobuf parameter value. */ public static String spoofParameter(String parameters) { - LogHelper.printDebug(() -> "Original protobuf parameter value: " + parameters); + try { + LogHelper.printDebug(() -> "Original protobuf parameter value: " + parameters); - if (!SettingsEnum.SPOOF_SIGNATURE.getBoolean()) return parameters; + if (!SettingsEnum.SPOOF_SIGNATURE.getBoolean()) 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. - if (useOriginalStoryboardRenderer = parameters.length() > 150) 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. + if (useOriginalStoryboardRenderer = parameters.length() > 150) return parameters; - // Shorts do not need to be spoofed. - if (useOriginalStoryboardRenderer = parameters.startsWith(SHORTS_PLAYER_PARAMETERS)) { - isPlayingShorts = true; - return parameters; - } - isPlayingShorts = false; - - boolean isPlayingFeed = PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL - && containsAny(parameters, AUTOPLAY_PARAMETERS); - if (isPlayingFeed) { - if (useOriginalStoryboardRenderer = !SettingsEnum.SPOOF_SIGNATURE_IN_FEED.getBoolean()) { - // Don't spoof the feed video playback. This will cause video playback issues, - // but only if user continues watching for more than 1 minute. + // Shorts do not need to be spoofed. + if (useOriginalStoryboardRenderer = parameters.startsWith(SHORTS_PLAYER_PARAMETERS)) { + isPlayingShorts = true; return parameters; } - // Spoof the feed video. Video will show up in watch history and video subtitles are missing. - fetchStoryboardRenderer(); - return SCRIM_PARAMETER + INCOGNITO_PARAMETERS; - } + isPlayingShorts = false; - fetchStoryboardRenderer(); + boolean isPlayingFeed = PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL + && containsAny(parameters, AUTOPLAY_PARAMETERS); + if (isPlayingFeed) { + if (useOriginalStoryboardRenderer = !SettingsEnum.SPOOF_SIGNATURE_IN_FEED.getBoolean()) { + // 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) { + LogHelper.printException(() -> "spoofParameter failure", ex); + } return INCOGNITO_PARAMETERS; } private static void fetchStoryboardRenderer() { if (!SettingsEnum.SPOOF_STORYBOARD_RENDERER.getBoolean()) { lastPlayerResponseVideoId = null; - rendererFuture = null; + videoRenderer = null; return; } String videoId = VideoInformation.getPlayerResponseVideoId(); if (!videoId.equals(lastPlayerResponseVideoId)) { - rendererFuture = ReVancedUtils.submitOnBackgroundThread(() -> getStoryboardRenderer(videoId)); lastPlayerResponseVideoId = videoId; + // This will block starting video playback until the fetch completes. + // This is desired because if this returns without finishing the fetch, + // then video will start playback but the image will be frozen + // while the main thread call for the renderer waits for the fetch to complete. + videoRenderer = StoryboardRendererRequester.getStoryboardRenderer(videoId); } - // Block until the fetch is completed. Without this, occasionally when a new video is opened - // the video will be frozen a few seconds while the audio plays. - // This is because the main thread is calling to get the storyboard but the fetch is not completed. - // To prevent this, call get() here and block until the fetch is completed. - // So later when the main thread calls to get the renderer it will never block as the future is done. - getRenderer(); } private static String getStoryboardRendererSpec(String originalStoryboardRendererSpec, boolean returnNullIfLiveStream) { if (SettingsEnum.SPOOF_SIGNATURE.getBoolean() && !useOriginalStoryboardRenderer) { - StoryboardRenderer renderer = getRenderer(); + StoryboardRenderer renderer = videoRenderer; if (renderer != null) { if (returnNullIfLiveStream && renderer.isLiveStream()) return null; return renderer.getSpec(); @@ -171,7 +154,7 @@ public class SpoofSignaturePatch { */ public static int getRecommendedLevel(int originalLevel) { if (SettingsEnum.SPOOF_SIGNATURE.getBoolean() && !useOriginalStoryboardRenderer) { - StoryboardRenderer renderer = getRenderer(); + StoryboardRenderer renderer = videoRenderer; if (renderer != null) { Integer recommendedLevel = renderer.getRecommendedLevel(); if (recommendedLevel != null) return recommendedLevel; @@ -195,15 +178,19 @@ public class SpoofSignaturePatch { * @param view seekbar thumbnail view. Includes both shorts and regular videos. */ public static void seekbarImageViewCreated(ImageView view) { - if (!SettingsEnum.SPOOF_SIGNATURE.getBoolean() - || SettingsEnum.SPOOF_STORYBOARD_RENDERER.getBoolean()) { - return; - } - if (isPlayingShorts) return; + try { + if (!SettingsEnum.SPOOF_SIGNATURE.getBoolean() + || SettingsEnum.SPOOF_STORYBOARD_RENDERER.getBoolean()) { + 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); + 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) { + LogHelper.printException(() -> "seekbarImageViewCreated failure", ex); + } } } diff --git a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/PlayerRoutes.java b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/PlayerRoutes.java index 7a769aa2..17fff6d1 100644 --- a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/PlayerRoutes.java +++ b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/PlayerRoutes.java @@ -23,6 +23,11 @@ final class PlayerRoutes { 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 = 4 * 1000; // 4 Seconds. + static { JSONObject innerTubeBody = new JSONObject(); @@ -88,8 +93,8 @@ final class PlayerRoutes { connection.setUseCaches(false); connection.setDoOutput(true); - connection.setConnectTimeout(5000); - connection.setReadTimeout(5000); + connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS); return connection; } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryboardRendererRequester.java b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryboardRendererRequester.java index 38904dc2..addae2d7 100644 --- a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryboardRendererRequester.java +++ b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryboardRendererRequester.java @@ -1,27 +1,48 @@ package app.revanced.integrations.patches.spoof.requests; +import static app.revanced.integrations.patches.spoof.requests.PlayerRoutes.ANDROID_INNER_TUBE_BODY; +import static app.revanced.integrations.patches.spoof.requests.PlayerRoutes.GET_STORYBOARD_SPEC_RENDERER; +import static app.revanced.integrations.patches.spoof.requests.PlayerRoutes.TV_EMBED_INNER_TUBE_BODY; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import app.revanced.integrations.patches.spoof.StoryboardRenderer; -import app.revanced.integrations.requests.Requester; -import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.utils.ReVancedUtils; + 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.patches.spoof.requests.PlayerRoutes.*; +import app.revanced.integrations.patches.spoof.StoryboardRenderer; +import app.revanced.integrations.requests.Requester; +import app.revanced.integrations.settings.SettingsEnum; +import app.revanced.integrations.utils.LogHelper; +import app.revanced.integrations.utils.ReVancedUtils; 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; + ReVancedUtils.doNothingForDuration(maximumTimeToRandomlyWait); + } + } + + private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex, + boolean showToastOnIOException) { + if (showToastOnIOException) ReVancedUtils.showToastShort(toastMessage); + LogHelper.printInfo(() -> toastMessage, ex); + } + @Nullable - private static JSONObject fetchPlayerResponse(@NonNull String requestBody) { + private static JSONObject fetchPlayerResponse(@NonNull String requestBody, boolean showToastOnIOException) { final long startTime = System.currentTimeMillis(); try { ReVancedUtils.verifyOffMainThread(); @@ -33,14 +54,21 @@ public class StoryboardRendererRequester { connection.getOutputStream().write(innerTubeBody, 0, innerTubeBody.length); final int responseCode = connection.getResponseCode(); + randomlyWaitIfLocallyDebugging(); if (responseCode == 200) return Requester.parseJSONObject(connection); - LogHelper.printException(() -> "API not available: " + responseCode); + // Always show a toast for this, as a non 200 response means something is broken. + handleConnectionError("Spoof storyboard not available: " + responseCode, + null, showToastOnIOException || SettingsEnum.DEBUG_TOAST_ON_ERROR.getBoolean()); connection.disconnect(); } catch (SocketTimeoutException ex) { - LogHelper.printException(() -> "API timed out", ex); + handleConnectionError("Spoof storyboard temporarily not available (API timed out)", + ex, showToastOnIOException); + } catch (IOException ex) { + handleConnectionError("Spoof storyboard temporarily not available: " + ex.getMessage(), + ex, showToastOnIOException); } catch (Exception ex) { - LogHelper.printException(() -> "Failed to fetch storyboard URL", ex); + LogHelper.printException(() -> "Spoof storyboard fetch failed", ex); // Should never happen. } finally { LogHelper.printDebug(() -> "Request took: " + (System.currentTimeMillis() - startTime) + "ms"); } @@ -64,8 +92,9 @@ public class StoryboardRendererRequester { * @return StoryboardRenderer or null if playabilityStatus is not OK. */ @Nullable - private static StoryboardRenderer getStoryboardRendererUsingBody(@NonNull String innerTubeBody) { - final JSONObject playerResponse = fetchPlayerResponse(innerTubeBody); + private static StoryboardRenderer getStoryboardRendererUsingBody(@NonNull String innerTubeBody, + boolean showToastOnIOException) { + final JSONObject playerResponse = fetchPlayerResponse(innerTubeBody, showToastOnIOException); if (playerResponse != null && isPlayabilityStatusOk(playerResponse)) return getStoryboardRendererUsingResponse(playerResponse); @@ -103,23 +132,19 @@ public class StoryboardRendererRequester { @Nullable public static StoryboardRenderer getStoryboardRenderer(@NonNull String videoId) { - try { - Objects.requireNonNull(videoId); + Objects.requireNonNull(videoId); - var renderer = getStoryboardRendererUsingBody(String.format(ANDROID_INNER_TUBE_BODY, videoId)); + var renderer = getStoryboardRendererUsingBody( + String.format(ANDROID_INNER_TUBE_BODY, videoId), false); + if (renderer == null) { + LogHelper.printDebug(() -> videoId + " not available using Android client"); + renderer = getStoryboardRendererUsingBody( + String.format(TV_EMBED_INNER_TUBE_BODY, videoId, videoId), true); if (renderer == null) { - LogHelper.printDebug(() -> videoId + " not available using Android client"); - renderer = getStoryboardRendererUsingBody(String.format(TV_EMBED_INNER_TUBE_BODY, videoId, videoId)); - if (renderer == null) { - LogHelper.printDebug(() -> videoId + " not available using TV embedded client"); - } + LogHelper.printDebug(() -> videoId + " not available using TV embedded client"); } - - return renderer; - } catch (Exception ex) { - LogHelper.printException(() -> "Failed to fetch storyboard URL", ex); } - return null; + return renderer; } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java index 8daa3805..e08b48db 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java @@ -139,25 +139,13 @@ public class ReturnYouTubeDislikeApi { * Simulates a slow response by doing meaningless calculations. * Used to debug the app UI and verify UI timeout logic works */ - @SuppressWarnings("UnusedReturnValue") - private static long randomlyWaitIfLocallyDebugging() { + private static void randomlyWaitIfLocallyDebugging() { final boolean DEBUG_RANDOMLY_DELAY_NETWORK_CALLS = false; // set true to debug UI if (DEBUG_RANDOMLY_DELAY_NETWORK_CALLS) { final long amountOfTimeToWaste = (long) (Math.random() * (API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS + API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS)); - final long timeCalculationStarted = System.currentTimeMillis(); - LogHelper.printDebug(() -> "Artificially creating network delay of: " + amountOfTimeToWaste + "ms"); - - long meaninglessValue = 0; - while (System.currentTimeMillis() - timeCalculationStarted < amountOfTimeToWaste) { - // could do a thread sleep, but that will trigger an exception if the thread is interrupted - meaninglessValue += Long.numberOfLeadingZeros((long)Math.exp(Math.random())); - } - // return the value, otherwise the compiler or VM might optimize and remove the meaningless time wasting work, - // leaving an empty loop that hammers on the System.currentTimeMillis native call - return meaninglessValue; + ReVancedUtils.doNothingForDuration(amountOfTimeToWaste); } - return 0; } /** diff --git a/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java b/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java index 6b94bbd9..ac32c8f4 100644 --- a/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java +++ b/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java @@ -112,6 +112,26 @@ public class ReVancedUtils { return backgroundThreadPool.submit(call); } + /** + * Simulates a delay by doing meaningless calculations. + * Used for debugging to verify UI timeout logic. + */ + @SuppressWarnings("UnusedReturnValue") + public static long doNothingForDuration(long amountOfTimeToWaste) { + final long timeCalculationStarted = System.currentTimeMillis(); + LogHelper.printDebug(() -> "Artificially creating delay of: " + amountOfTimeToWaste + "ms"); + + long meaninglessValue = 0; + while (System.currentTimeMillis() - timeCalculationStarted < amountOfTimeToWaste) { + // could do a thread sleep, but that will trigger an exception if the thread is interrupted + meaninglessValue += Long.numberOfLeadingZeros((long) Math.exp(Math.random())); + } + // return the value, otherwise the compiler or VM might optimize and remove the meaningless time wasting work, + // leaving an empty loop that hammers on the System.currentTimeMillis native call + return meaninglessValue; + } + + public static boolean containsAny(@NonNull String value, @NonNull String... targets) { return indexOfFirstFound(value, targets) >= 0; }