diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java index b0411267c5..a9c02b8a5b 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java @@ -36,13 +36,13 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter { @GuardedBy("itself") private static final Map lastVideoIds = new LinkedHashMap<>() { /** - * Number of video id's to keep track of for searching thru the buffer. + * Number of video id's to keep track of for searching through the buffer. * A minimum value of 3 should be sufficient, but check a few more just in case. */ private static final int NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK = 5; @Override - protected boolean removeEldestEntry(Map.Entry eldest) { + protected boolean removeEldestEntry(Entry eldest) { return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK; } }; @@ -64,6 +64,7 @@ public ReturnYouTubeDislikeFilterPatch() { /** * Injection point. */ + @SuppressWarnings("unused") public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) { try { if (!isShortAndOpeningOrPlaying || !SettingsEnum.RYD_SHORTS.getBoolean()) { @@ -110,7 +111,7 @@ boolean isFiltered(String path, @Nullable String identifier, String allValue, by String matchedVideoId = findVideoId(protobufBufferArray); // Matched video will be null if in incognito mode. // Must pass a null id to correctly clear out the current video data. - // Otherwise if a Short is opened in non-incognito, then incognito is enabled and another Short is opened, + // Otherwise, if a Short is opened in non-incognito, then incognito is enabled and another Short is opened, // the new incognito Short will show the old prior data. ReturnYouTubeDislikePatch.setLastLithoShortsVideoId(matchedVideoId); } @@ -129,4 +130,4 @@ private String findVideoId(byte[] protobufBufferArray) { return null; } } -} \ No newline at end of file +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/utils/ReturnYouTubeDislikePatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/utils/ReturnYouTubeDislikePatch.java index 70ae6bbd7e..fc747f6396 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/utils/ReturnYouTubeDislikePatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/utils/ReturnYouTubeDislikePatch.java @@ -6,6 +6,7 @@ import android.graphics.Rect; import android.graphics.drawable.ShapeDrawable; +import android.os.Build; import android.text.Editable; import android.text.Spannable; import android.text.SpannableString; @@ -25,6 +26,7 @@ import app.revanced.integrations.youtube.patches.components.ReturnYouTubeDislikeFilterPatch; import app.revanced.integrations.youtube.patches.video.VideoInformation; import app.revanced.integrations.youtube.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.integrations.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; import app.revanced.integrations.youtube.settings.SettingsEnum; import app.revanced.integrations.youtube.shared.PlayerType; import app.revanced.integrations.youtube.utils.LogHelper; @@ -41,7 +43,7 @@ *

* A (yet to be implemented) solution that fixes this problem. Any one of: * - Modify patch to hook onto the Shorts Litho TextView, and update the dislikes text asynchronously. - * - Find a way to force Litho to rebuild it's component tree, + * - Find a way to force Litho to rebuild its component tree, * and use that hook to force the shorts dislikes to update after the fetch is completed. * - Hook into the dislikes button image view, and replace the dislikes thumb down image with a * generated image of the number of dislikes, then update the image asynchronously. This Could @@ -54,11 +56,6 @@ public class ReturnYouTubeDislikePatch { public static final boolean IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER = isSpoofingToLessThan("18.34.00"); - /** - * Injection point. - * Whether to use incognito mode. - */ - public static volatile boolean isIncognito; /** * RYD data for the current video on screen. */ @@ -81,17 +78,16 @@ public class ReturnYouTubeDislikePatch { private static volatile boolean lithoShortsShouldUseCurrentData; /** - * Last video id prefetched. Field is prevent prefetching the same video id multiple times in a row. + * Last video id prefetched. Field is to prevent prefetching the same video id multiple times in a row. */ @Nullable private static volatile String lastPrefetchedVideoId; public static void onRYDStatusChange(boolean rydEnabled) { - if (!rydEnabled) { - // Must remove all values to protect against using stale data - // if the user enables RYD while a video is on screen. - clearData(); - } + ReturnYouTubeDislikeApi.resetRateLimits(); + // Must remove all values to protect against using stale data + // if the user enables RYD while a video is on screen. + clearData(); } private static void clearData() { @@ -166,7 +162,7 @@ private static void updateOldUIDislikesTextView() { /** * Injection point. Called on main thread. - *

+ * * Used when spoofing to 16.x and 17.x versions. */ public static void setOldUILayoutDislikes(int buttonViewResourceId, @Nullable TextView textView) { @@ -202,7 +198,7 @@ public static void setOldUILayoutDislikes(int buttonViewResourceId, @Nullable Te /** * Injection point. - *

+ * * For Litho segmented buttons and Litho Shorts player. */ @NonNull @@ -212,15 +208,13 @@ public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, } /** - * Injection point. - *

* Called when a litho text component is initially created, * and also when a Span is later reused again (such as scrolling off/on screen). - *

+ * * This method is sometimes called on the main thread, but it usually is called _off_ the main thread. * This method can be called multiple times for the same UI element (including after dislikes was added). * - * @param original Original char sequence was created or reused by Litho. + * @param original Original char sequence was created or reused by Litho. * @param isRollingNumber If the span is for a Rolling Number. * @return The original char sequence (if nothing should change), or a replacement char sequence that contains dislikes. */ @@ -247,10 +241,6 @@ private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, } replacement = videoData.getDislikesSpanForRegularVideo((Spanned) original, true, isRollingNumber); - - // When spoofing between 17.09.xx and 17.30.xx the UI is the old layout - // but uses litho and the dislikes is "|dislike_button.eml|". - // But spoofing to that range gives a broken UI layout so no point checking for that. } else if (!isRollingNumber && conversionContextString.contains("|shorts_dislike_button.eml|")) { // Litho Shorts player. if (!SettingsEnum.RYD_SHORTS.getBoolean()) { @@ -289,63 +279,6 @@ private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, return original; } - // - // Litho Shorts player in the incognito mode / live stream. - // - - /** - * Injection point. - *

- * This method is used in the following situations. - *

- * 1. When the dislike counts are fetched in the Incognito mode. - * 2. When the dislike counts are fetched in the live stream. - * - * @param original Original span that was created or reused by Litho. - * @return The original span (if nothing should change), or a replacement span that contains dislikes. - */ - public static CharSequence onCharSequenceLoaded(@NonNull Object conversionContext, - @NonNull CharSequence original) { - try { - String conversionContextString = conversionContext.toString(); - if (!SettingsEnum.RYD_ENABLED.getBoolean()) { - return original; - } - if (!SettingsEnum.RYD_SHORTS.getBoolean()) { - // Must clear the current video here, otherwise if the user opens a regular video - // then opens a litho short (while keeping the regular video on screen), then closes the short, - // the original video may show the incorrect dislike value. - clearData(); - return original; - } - - final boolean fetchDislikeIncognito = - conversionContextString.contains("|shorts_dislike_button.eml|") - && isIncognito; - final boolean fetchDislikeLiveStream = - conversionContextString.contains("immersive_live_video_action_bar.eml") - && conversionContextString.contains("|dislike_button.eml|"); - - if (fetchDislikeIncognito) { - LogHelper.printDebug(() -> "setShortsDislikes in Incognito mode"); - } else if (fetchDislikeLiveStream) { - LogHelper.printDebug(() -> "setShortsDislikes in LiveStream"); - } else { - return original; - } - - ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(VideoInformation.getVideoId()); - videoData.setVideoIdIsShort(true); - lastLithoShortsVideoData = videoData; - lithoShortsShouldUseCurrentData = false; - - return videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN); - } catch (Exception ex) { - LogHelper.printException(() -> "onCharSequenceLoaded failure", ex); - } - return original; - } - // // Rolling Number // @@ -364,9 +297,10 @@ public static String onRollingNumberLoaded(@NonNull Object conversionContext, @NonNull String original) { try { CharSequence replacement = onLithoTextLoaded(conversionContext, original, true); - if (!replacement.toString().equals(original)) { + String replacementString = replacement.toString(); + if (!replacementString.equals(original)) { rollingNumberSpan = replacement; - return replacement.toString(); + return replacementString; } // Else, the text was not a likes count but instead the view count or something else. } catch (Exception ex) { LogHelper.printException(() -> "onRollingNumberLoaded failure", ex); @@ -400,6 +334,7 @@ public static float onRollingNumberMeasured(String text, float measuredTextWidth } catch (Exception ex) { LogHelper.printException(() -> "onRollingNumberMeasured failure", ex); } + return measuredTextWidth; } @@ -417,11 +352,12 @@ private static void addRollingNumberPatchChanges(TextView view) { } else { view.setCompoundDrawables(separator, null, null, null); } - // Liking/disliking can cause the span to grow in size, - // which is ok and is laid out correctly, - // but if the user then undoes their action the layout will not remove the extra padding. + + // Disliking can cause the span to grow in size, which is ok and is laid out correctly, + // but if the user then removes their dislike the layout will not adjust to the new shorter width. // Use a center alignment to take up any extra space. view.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); + // Single line mode does not clip words if the span is larger than the view bounds. // The styled span applied to the view should always have the same bounds, // but use this feature just in case the measurements are somehow off by a few pixels. @@ -490,7 +426,7 @@ public static CharSequence updateRollingNumber(TextView view, CharSequence origi } // - // Non litho Shorts player. + // Non-litho Shorts player. // /** @@ -500,14 +436,16 @@ public static CharSequence updateRollingNumber(TextView view, CharSequence origi /** * Dislikes TextViews used by Shorts. - *

+ * * Multiple TextViews are loaded at once (for the prior and next videos to swipe to). * Keep track of all of them, and later pick out the correct one based on their on screen position. */ private static final List> shortsTextViewRefs = new ArrayList<>(); private static void clearRemovedShortsTextViews() { - shortsTextViewRefs.removeIf(ref -> ref.get() == null); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // YouTube requires Android N or greater + shortsTextViewRefs.removeIf(ref -> ref.get() == null); + } } /** @@ -621,7 +559,7 @@ private static boolean isShortTextViewOnScreen(@NonNull View view) { // - // Video Id and voting hooks (all players). + // Video ID and voting hooks (all players). // private static volatile boolean lastPlayerResponseWasShort; @@ -634,10 +572,6 @@ public static void preloadVideoId(@NonNull String videoId, boolean isShortAndOpe if (!SettingsEnum.RYD_ENABLED.getBoolean()) { return; } - if (ReVancedUtils.isNetworkNotConnected()) { - LogHelper.printDebug(() -> "Network not connected, ignoring video"); - return; - } if (videoId.equals(lastPrefetchedVideoId)) { return; } @@ -679,17 +613,10 @@ public static void preloadVideoId(@NonNull String videoId, boolean isShortAndOpe */ public static void newVideoLoaded(@NonNull String videoId) { try { - if (!SettingsEnum.RYD_ENABLED.getBoolean()) { - return; - } + if (!SettingsEnum.RYD_ENABLED.getBoolean()) return; Objects.requireNonNull(videoId); - if (ReVancedUtils.isNetworkNotConnected()) { - LogHelper.printDebug(() -> "Network not connected, ignoring video"); - return; - } - - final PlayerType currentPlayerType = PlayerType.getCurrent(); + PlayerType currentPlayerType = PlayerType.getCurrent(); final boolean isNoneHiddenOrSlidingMinimized = currentPlayerType.isNoneHiddenOrSlidingMinimized(); if (isNoneHiddenOrSlidingMinimized && !SettingsEnum.RYD_SHORTS.getBoolean()) { // Must clear here, otherwise the wrong data can be used for a minimized regular video. @@ -747,10 +674,10 @@ private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, @Null /** * Injection point. - *

+ * * Called when the user likes or dislikes. * - * @param vote int that matches {@link ReturnYouTubeDislike.Vote#value} + * @param vote int that matches {@link Vote#value} */ public static void sendVote(int vote) { try { diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java index ee9374b322..89e729b681 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java @@ -10,6 +10,7 @@ import android.graphics.drawable.shapes.OvalShape; import android.graphics.drawable.shapes.RectShape; import android.icu.text.CompactDecimalFormat; +import android.os.Build; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; @@ -42,6 +43,7 @@ import app.revanced.integrations.youtube.shared.PlayerType; import app.revanced.integrations.youtube.utils.LogHelper; import app.revanced.integrations.youtube.utils.ReVancedUtils; + import app.revanced.integrations.youtube.utils.ThemeHelper; import app.revanced.integrations.youtube.patches.misc.SpoofAppVersionPatch; @@ -150,7 +152,7 @@ public enum Vote { private final Future future; /** - * Time this instance and the future was created. + * Time this instance and the fetch future was created. */ private final long timeFetched; @@ -184,12 +186,12 @@ public enum Vote { /** * Color of the left and middle separator, based on the color of the right separator. - * It's unknown where YT gets the color from, and the colors here are approximated by hand. - * Ideally, the color here would be the actual color YT uses at runtime. + * It's unknown where YT gets the color from, and the values here are approximated by hand. + * Ideally, this would be the actual color YT uses at runtime. * * Older versions before the 'Me' library tab use a slightly different color. * If spoofing was previously used and is now turned off, - * or an old version was recently upgraded then the old colors are sometimes used. + * or an old version was recently upgraded then the old colors are sometimes still used. */ private static int getSeparatorColor() { if (IS_SPOOFING_TO_OLD_SEPARATOR_COLOR) { @@ -198,8 +200,8 @@ private static int getSeparatorColor() { : 0xFFD9D9D9; // light gray } return ThemeHelper.getDayNightTheme() - ? 0x33FFFFFF // transparent dark gray - : 0xFFD9D9D9; // light gray + ? 0x33FFFFFF + : 0xFFD9D9D9; } public static ShapeDrawable getLeftSeparatorDrawable() { @@ -250,7 +252,7 @@ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, : "\u200E"; // u200E = left to right character final Spannable leftSeparatorSpan; if (isRollingNumber) { - leftSeparatorSpan = new SpannableString(leftSeparatorString); + leftSeparatorSpan = new SpannableString(leftSeparatorString); } else { leftSeparatorString += " "; leftSeparatorSpan = new SpannableString(leftSeparatorString); @@ -350,18 +352,23 @@ private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned } private static String formatDislikeCount(long dislikeCount) { - synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize - if (dislikeCountFormatter == null) { - // Note: Java number formatters will use the locale specific number characters. - // such as Arabic which formats "1.234" into "۱,۲۳٤" - // But YouTube disregards locale specific number characters - // and instead shows english number characters everywhere. - Locale locale = Objects.requireNonNull(ReVancedUtils.getContext()).getResources().getConfiguration().locale; - LogHelper.printDebug(() -> "Locale: " + locale); - dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikeCountFormatter == null) { + // Note: Java number formatters will use the locale specific number characters. + // such as Arabic which formats "1.234" into "۱,۲۳٤" + // But YouTube disregards locale specific number characters + // and instead shows english number characters everywhere. + Locale locale = Objects.requireNonNull(ReVancedUtils.getContext()).getResources().getConfiguration().locale; + LogHelper.printDebug(() -> "Locale: " + locale); + dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); + } + return dislikeCountFormatter.format(dislikeCount); } - return dislikeCountFormatter.format(dislikeCount); } + + // Will never be reached, as the oldest supported YouTube app requires Android N or greater. + return String.valueOf(dislikeCount); } private static String formatDislikePercentage(float dislikePercentage) { @@ -385,13 +392,15 @@ public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) Objects.requireNonNull(videoId); synchronized (fetchCache) { // Remove any expired entries. - final long now = System.currentTimeMillis(); - fetchCache.values().removeIf(value -> { - final boolean expired = value.isExpired(now); - if (expired) - LogHelper.printDebug(() -> "Removing expired fetch: " + value.videoId); - return expired; - }); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + final long now = System.currentTimeMillis(); + fetchCache.values().removeIf(value -> { + final boolean expired = value.isExpired(now); + if (expired) + LogHelper.printDebug(() -> "Removing expired fetch: " + value.videoId); + return expired; + }); + } ReturnYouTubeDislike fetch = fetchCache.get(videoId); if (fetch == null) { @@ -403,7 +412,7 @@ public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) } /** - * Should be called if the user changes settings for dislikes appearance. + * Should be called if the user changes dislikes appearance settings. */ public static void clearAllUICaches() { synchronized (fetchCache) { @@ -583,7 +592,7 @@ public void sendVote(@NonNull Vote vote) { /** * Sets the current user vote value, and does not send the vote to the RYD API. - *

+ * * Only used to set value if thumbs up/down is already selected on video load. */ public void setUserVote(@NonNull Vote vote) { @@ -645,8 +654,8 @@ class VerticallyCenteredImageSpan extends ImageSpan { /** * @param useOriginalWidth Use the original layout width of the text this span is applied to, - * and not the bounds of the Drawable. Drawable is always displayed using it's own bounds, - * and this setting only affects the layout width of the entire span. + * and not the bounds of the Drawable. Drawable is always displayed using it's own bounds, + * and this setting only affects the layout width of the entire span. */ public VerticallyCenteredImageSpan(Drawable drawable, boolean useOriginalWidth) { super(drawable); diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java index cd77276c55..7d490a98ce 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java @@ -42,7 +42,7 @@ public class ReturnYouTubeDislikeApi { /** * Default connection and response timeout for voting and registration. - *

+ * * Voting and user registration runs in the background and has has no urgency * so this can be a larger value. */ @@ -58,14 +58,114 @@ public class ReturnYouTubeDislikeApi { */ private static final int HTTP_STATUS_CODE_RATE_LIMIT = 429; + /** + * How long to wait until API calls are resumed, if the API requested a back off. + * No clear guideline of how long to wait until resuming. + */ + private static final int BACKOFF_RATE_LIMIT_MILLISECONDS = 10 * 60 * 1000; // 10 Minutes. + + /** + * How long to wait until API calls are resumed, if any connection error occurs. + */ + private static final int BACKOFF_CONNECTION_ERROR_MILLISECONDS = 2 * 60 * 1000; // 2 Minutes. + /** * If non zero, then the system time of when API calls can resume. */ - private static volatile long timeToResumeAPICalls; // must be volatile, since different threads read/write to this + private static volatile long timeToResumeAPICalls; + + /** + * If the last API getVotes call failed for any reason (including server requested rate limit). + * Used to prevent showing repeat connection toasts when the API is down. + */ + private static volatile boolean lastApiCallFailed; + + /** + * Number of times {@link #HTTP_STATUS_CODE_RATE_LIMIT} was requested by RYD api. + * Does not include network calls attempted while rate limit is in effect, + * and does not include rate limit imposed if a fetch fails. + */ + private static volatile int numberOfRateLimitRequestsEncountered; + + /** + * Number of network calls made in {@link #fetchVotes(String)} + */ + private static volatile int fetchCallCount; + + /** + * Number of times {@link #fetchVotes(String)} failed due to timeout or any other error. + * This does not include when rate limit requests are encountered. + */ + private static volatile int fetchCallNumberOfFailures; + + /** + * Total time spent waiting for {@link #fetchVotes(String)} network call to complete. + * Value does does not persist on app shut down. + */ + private static volatile long fetchCallResponseTimeTotal; + + /** + * Round trip network time for the most recent call to {@link #fetchVotes(String)} + */ + private static volatile long fetchCallResponseTimeLast; + private static volatile long fetchCallResponseTimeMin; + private static volatile long fetchCallResponseTimeMax; + + public static final int FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT = -1; + + /** + * If rate limit was hit, this returns {@link #FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT} + */ + public static long getFetchCallResponseTimeLast() { + return fetchCallResponseTimeLast; + } + public static long getFetchCallResponseTimeMin() { + return fetchCallResponseTimeMin; + } + public static long getFetchCallResponseTimeMax() { + return fetchCallResponseTimeMax; + } + public static long getFetchCallResponseTimeAverage() { + return fetchCallCount == 0 ? 0 : (fetchCallResponseTimeTotal / fetchCallCount); + } + public static int getFetchCallCount() { + return fetchCallCount; + } + public static int getFetchCallNumberOfFailures() { + return fetchCallNumberOfFailures; + } + public static int getNumberOfRateLimitRequestsEncountered() { + return numberOfRateLimitRequestsEncountered; + } private ReturnYouTubeDislikeApi() { } // utility class + /** + * Simulates a slow response by doing meaningless calculations. + * Used to debug the app UI and verify UI timeout logic works + */ + 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)); + ReVancedUtils.doNothingForDuration(amountOfTimeToWaste); + } + } + + /** + * Clears any backoff rate limits in effect. + * Should be called if RYD is turned on/off. + */ + public static void resetRateLimits() { + if (lastApiCallFailed || timeToResumeAPICalls != 0) { + LogHelper.printDebug(() -> "Reset rate limit"); + } + lastApiCallFailed = false; + timeToResumeAPICalls = 0; + } + /** * @return True, if api rate limit is in effect. */ @@ -86,25 +186,61 @@ private static boolean checkIfRateLimitInEffect(String apiEndPointName) { * @return True, if a client rate limit was requested */ private static boolean checkIfRateLimitWasHit(int httpResponseCode) { + final boolean DEBUG_RATE_LIMIT = false; // set to true, to verify rate limit works + if (DEBUG_RATE_LIMIT) { + final double RANDOM_RATE_LIMIT_PERCENTAGE = 0.2; // 20% chance of a triggering a rate limit + if (Math.random() < RANDOM_RATE_LIMIT_PERCENTAGE) { + LogHelper.printDebug(() -> "Artificially triggering rate limit for debug purposes"); + httpResponseCode = HTTP_STATUS_CODE_RATE_LIMIT; + } + } return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT; } - private static void updateRateLimitAndStats(boolean connectionError, boolean rateLimitHit) { - if (rateLimitHit) { - if (connectionError) - throw new IllegalArgumentException(); - else + @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are estimates. + private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) { + if (connectionError && rateLimitHit) { + throw new IllegalArgumentException(); + } + final long responseTimeOfFetchCall = System.currentTimeMillis() - timeNetworkCallStarted; + fetchCallResponseTimeTotal += responseTimeOfFetchCall; + fetchCallResponseTimeMin = (fetchCallResponseTimeMin == 0) ? responseTimeOfFetchCall : Math.min(responseTimeOfFetchCall, fetchCallResponseTimeMin); + fetchCallResponseTimeMax = Math.max(responseTimeOfFetchCall, fetchCallResponseTimeMax); + fetchCallCount++; + if (connectionError) { + timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_CONNECTION_ERROR_MILLISECONDS; + fetchCallResponseTimeLast = responseTimeOfFetchCall; + fetchCallNumberOfFailures++; + lastApiCallFailed = true; + } else if (rateLimitHit) { + LogHelper.printDebug(() -> "API rate limit was hit. Stopping API calls for the next " + + BACKOFF_RATE_LIMIT_MILLISECONDS + " seconds"); + timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_RATE_LIMIT_MILLISECONDS; + numberOfRateLimitRequestsEncountered++; + fetchCallResponseTimeLast = FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT; + if (!lastApiCallFailed && SettingsEnum.RYD_TOAST_ON_CONNECTION_ERROR.getBoolean()) { ReVancedUtils.showToastLong(str("revanced_ryd_failure_client_rate_limit_requested")); + } + lastApiCallFailed = true; + } else { + fetchCallResponseTimeLast = responseTimeOfFetchCall; + lastApiCallFailed = false; } } - private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) { - if (SettingsEnum.RYD_TOAST_ON_CONNECTION_ERROR.getBoolean()) { - ReVancedUtils.showToastShort(toastMessage); - } - if (ex != null) { - LogHelper.printInfo(() -> toastMessage, ex); + private static void handleConnectionError(@NonNull String toastMessage, + @Nullable Exception ex, + boolean showLongToast) { + if (!lastApiCallFailed && SettingsEnum.RYD_TOAST_ON_CONNECTION_ERROR.getBoolean()) { + if (showLongToast) { + ReVancedUtils.showToastLong(toastMessage); + } else { + ReVancedUtils.showToastShort(toastMessage); + } } + lastApiCallFailed = true; + + LogHelper.printInfo(() -> toastMessage, ex); } /** @@ -119,6 +255,7 @@ public static RYDVoteData fetchVotes(String videoId) { return null; } LogHelper.printDebug(() -> "Fetching votes for: " + videoId); + final long timeNetworkCallStarted = System.currentTimeMillis(); try { HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_DISLIKES, videoId); @@ -132,19 +269,21 @@ public static RYDVoteData fetchVotes(String videoId) { connection.setConnectTimeout(API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server connection.setReadTimeout(API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS); // timeout for server response + randomlyWaitIfLocallyDebugging(); + final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); // rate limit hit, should disconnect - updateRateLimitAndStats(false, true); + updateRateLimitAndStats(timeNetworkCallStarted, false, true); return null; } if (responseCode == HTTP_STATUS_CODE_SUCCESS) { - // Do not disconnect, the same server connection will likely be used again soon + // do not disconnect, the same server connection will likely be used again soon JSONObject json = Requester.parseJSONObject(connection); try { RYDVoteData votingData = new RYDVoteData(json); - updateRateLimitAndStats(false, false); + updateRateLimitAndStats(timeNetworkCallStarted, false, false); LogHelper.printDebug(() -> "Voting data fetched: " + votingData); return votingData; } catch (JSONException ex) { @@ -152,19 +291,21 @@ public static RYDVoteData fetchVotes(String videoId) { // fall thru to update statistics } } else { - handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + // Unexpected response code. Most likely RYD is temporarily broken. + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), + null, true); } - connection.disconnect(); // something went wrong, might as well disconnect - } catch (SocketTimeoutException ex) { // connection timed out, response timeout, or some other network error - handleConnectionError((str("revanced_ryd_failure_connection_timeout")), ex); + connection.disconnect(); // Something went wrong, might as well disconnect. + } catch (SocketTimeoutException ex) { + handleConnectionError((str("revanced_ryd_failure_connection_timeout")), ex, false); } catch (IOException ex) { - handleConnectionError((str("revanced_ryd_failure_generic", ex.getMessage())), ex); + handleConnectionError((str("revanced_ryd_failure_generic", ex.getMessage())), ex, true); } catch (Exception ex) { // should never happen LogHelper.printException(() -> "Failed to fetch votes", ex); } - updateRateLimitAndStats(true, false); + updateRateLimitAndStats(timeNetworkCallStarted, true, false); return null; } @@ -178,7 +319,7 @@ public static String registerAsNewUser() { if (checkIfRateLimitInEffect("registerAsNewUser")) { return null; } - String userId = randomString(); + String userId = randomString(36); LogHelper.printDebug(() -> "Trying to register new user"); HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_REGISTRATION, userId); @@ -199,12 +340,13 @@ public static String registerAsNewUser() { String solution = solvePuzzle(challenge, difficulty); return confirmRegistration(userId, solution); } - handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), + null, true); connection.disconnect(); } catch (SocketTimeoutException ex) { - handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex); + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false); } catch (IOException ex) { - handleConnectionError(str("revanced_ryd_failure_generic", "registration failed"), ex); + handleConnectionError(str("revanced_ryd_failure_generic", "registration failed"), ex, true); } catch (Exception ex) { LogHelper.printException(() -> "Failed to register user", ex); // should never happen } @@ -235,20 +377,25 @@ private static String confirmRegistration(String userId, String solution) { connection.disconnect(); // disconnect, as no more connections will be made for a little while return null; } + String result = null; if (responseCode == HTTP_STATUS_CODE_SUCCESS) { - LogHelper.printDebug(() -> "Registration confirmation successful"); - return userId; + result = Requester.parseJson(connection); + if (result.equalsIgnoreCase("true")) { + LogHelper.printDebug(() -> "Registration confirmation successful"); + return userId; + } } - - // Something went wrong, might as well disconnect. - String response = Requester.parseJsonAndDisconnect(connection); + final String resultLog = result == null ? "(no response)" : result; LogHelper.printInfo(() -> "Failed to confirm registration for user: " + userId - + " solution: " + solution + " responseCode: " + responseCode + " response: '" + response + "'"); - handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + + " solution: " + solution + " responseCode: " + responseCode + " responseString: " + resultLog); + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), + null, true); + connection.disconnect(); // something went wrong, might as well disconnect } catch (SocketTimeoutException ex) { - handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex); + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false); } catch (IOException ex) { - handleConnectionError(str("revanced_ryd_failure_generic", "confirm registration failed"), ex); + handleConnectionError(str("revanced_ryd_failure_generic", "confirm registration failed"), + ex, true); } catch (Exception ex) { LogHelper.printException(() -> "Failed to confirm registration for user: " + userId + "solution: " + solution, ex); @@ -278,17 +425,17 @@ private static String getUserId() { return userId; } - public static void sendVote(String videoId, ReturnYouTubeDislike.Vote vote) { + public static boolean sendVote(String videoId, ReturnYouTubeDislike.Vote vote) { ReVancedUtils.verifyOffMainThread(); Objects.requireNonNull(videoId); Objects.requireNonNull(vote); try { String userId = getUserId(); - if (userId == null) return; + if (userId == null) return false; if (checkIfRateLimitInEffect("sendVote")) { - return; + return false; } LogHelper.printDebug(() -> "Trying to vote for video: " + videoId + " with vote: " + vote); @@ -304,7 +451,7 @@ public static void sendVote(String videoId, ReturnYouTubeDislike.Vote vote) { final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); // disconnect, as no more connections will be made for a little while - return; + return false; } if (responseCode == HTTP_STATUS_CODE_SUCCESS) { JSONObject json = Requester.parseJSONObject(connection); @@ -312,24 +459,25 @@ public static void sendVote(String videoId, ReturnYouTubeDislike.Vote vote) { int difficulty = json.getInt("difficulty"); String solution = solvePuzzle(challenge, difficulty); - confirmVote(videoId, userId, solution); - return; + return confirmVote(videoId, userId, solution); } LogHelper.printInfo(() -> "Failed to send vote for video: " + videoId + " vote: " + vote + " response code was: " + responseCode); - handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), + null, true); connection.disconnect(); // something went wrong, might as well disconnect } catch (SocketTimeoutException ex) { - handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex); + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false); } catch (IOException ex) { - handleConnectionError(str("revanced_ryd_failure_generic", "send vote failed"), ex); + handleConnectionError(str("revanced_ryd_failure_generic", "send vote failed"), ex, true); } catch (Exception ex) { // should never happen LogHelper.printException(() -> "Failed to send vote for video: " + videoId + " vote: " + vote, ex); } + return false; } - private static void confirmVote(String videoId, String userId, String solution) { + private static boolean confirmVote(String videoId, String userId, String solution) { ReVancedUtils.verifyOffMainThread(); Objects.requireNonNull(videoId); Objects.requireNonNull(userId); @@ -337,7 +485,7 @@ private static void confirmVote(String videoId, String userId, String solution) try { if (checkIfRateLimitInEffect("confirmVote")) { - return; + return false; } LogHelper.printDebug(() -> "Trying to confirm vote for video: " + videoId + " solution: " + solution); HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_VOTE); @@ -351,26 +499,32 @@ private static void confirmVote(String videoId, String userId, String solution) final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); // disconnect, as no more connections will be made for a little while - return; + return false; } + String result = null; if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + result = Requester.parseJson(connection); + if (result.equalsIgnoreCase("true")) { LogHelper.printDebug(() -> "Vote confirm successful for video: " + videoId); - return; + return true; + } } - - // Something went wrong, might as well disconnect. - String response = Requester.parseJsonAndDisconnect(connection); + final String resultLog = result == null ? "(no response)" : result; LogHelper.printInfo(() -> "Failed to confirm vote for video: " + videoId - + " solution: " + solution + " responseCode: " + responseCode + " response: '" + response + "'"); - handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + + " solution: " + solution + " responseCode: " + responseCode + " responseString: " + resultLog); + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), + null, true); + connection.disconnect(); // something went wrong, might as well disconnect } catch (SocketTimeoutException ex) { - handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex); + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false); } catch (IOException ex) { - handleConnectionError(str("revanced_ryd_failure_generic", "confirm vote failed"), ex); + handleConnectionError(str("revanced_ryd_failure_generic", "confirm vote failed"), + ex, true); } catch (Exception ex) { LogHelper.printException(() -> "Failed to confirm vote for video: " + videoId + " solution: " + solution, ex); // should never happen } + return false; } private static void applyCommonPostRequestSettings(HttpURLConnection connection) throws ProtocolException { @@ -421,12 +575,12 @@ private static String solvePuzzle(String challenge, int difficulty) { } // https://stackoverflow.com/a/157202 - private static String randomString() { + private static String randomString(int len) { String AB = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; SecureRandom rnd = new SecureRandom(); - StringBuilder sb = new StringBuilder(36); - for (int i = 0; i < 36; i++) + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) sb.append(AB.charAt(rnd.nextInt(AB.length()))); return sb.toString(); } diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java index 20a544883a..44385a9e2e 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java @@ -24,5 +24,4 @@ private ReturnYouTubeDislikeRoutes() { static HttpURLConnection getRYDConnectionFromRoute(Route route, String... params) throws IOException { return Requester.getConnectionFromRoute(RYD_API_URL, route, params); } - } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/youtube/utils/ReVancedUtils.java b/app/src/main/java/app/revanced/integrations/youtube/utils/ReVancedUtils.java index 8f4664dba2..386485d7a0 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/utils/ReVancedUtils.java +++ b/app/src/main/java/app/revanced/integrations/youtube/utils/ReVancedUtils.java @@ -255,6 +255,25 @@ public static boolean isNetworkNotConnected() { return networkType == NetworkType.NONE; } + /** + * 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; + } + @SuppressLint("MissingPermission") // permission already included in YouTube public static NetworkType getNetworkType() { if (context == null || !(context.getSystemService(Context.CONNECTIVITY_SERVICE) instanceof ConnectivityManager cm))