From 42e5efd0e42f63e307e9f1767c2156b28bcaba5a Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Fri, 6 Oct 2023 00:48:46 +0400 Subject: [PATCH] Refactor: RYD Prefetching --- .../patches/ReturnYouTubeDislikePatch.java | 198 ++++-- .../ReturnYouTubeDislikeFilterPatch.java | 54 +- .../ReturnYouTubeDislike.java | 605 ++++++++---------- .../requests/ReturnYouTubeDislikeApi.java | 28 +- .../ReturnYouTubeDislikeSettingsFragment.java | 4 +- 5 files changed, 470 insertions(+), 419 deletions(-) diff --git a/app/src/main/java/app/revanced/integrations/patches/ReturnYouTubeDislikePatch.java b/app/src/main/java/app/revanced/integrations/patches/ReturnYouTubeDislikePatch.java index 2060e71623..18bd3ac7ba 100644 --- a/app/src/main/java/app/revanced/integrations/patches/ReturnYouTubeDislikePatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/ReturnYouTubeDislikePatch.java @@ -7,9 +7,10 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; + +import app.revanced.integrations.patches.components.ReturnYouTubeDislikeFilterPatch; import app.revanced.integrations.patches.spoof.SpoofAppVersionPatch; import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike; -import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.shared.PlayerType; import app.revanced.integrations.utils.LogHelper; @@ -24,16 +25,39 @@ /** * Handles all interaction of UI patch components. - * - * Does not handle creating dislike spans or anything to do with {@link ReturnYouTubeDislikeApi}. */ public class ReturnYouTubeDislikePatch { + /** + * RYD data for the current video on screen. + */ + @Nullable + private static volatile ReturnYouTubeDislike currentVideoData; + + /** + * The last litho based shorts loaded. + * May be the same value as {@link #currentVideoData}, but usually is the next short to swipe to. + */ + @Nullable + private static volatile ReturnYouTubeDislike lastLithoShortsVideoData; + + /** + * Because the litho shorts spans are created after {@link ReturnYouTubeDislikeFilterPatch} + * detects the video ids, after the user votes the litho will update + * but {@link #lastLithoShortsVideoData} is not the correct data to use. + * If this is true, then instead use {@link #currentVideoData}. + */ + private static volatile boolean lithoShortsShouldUseCurrentData; + + /** + * Last video id prefetched. Field is prevent prefetching the same video id multiple times in a row. + */ @Nullable - private static String currentVideoId; + private static volatile String lastPrefetchedVideoId; + // - // 17.x non litho player. + // 17.x non litho regular video player. // /** @@ -79,11 +103,15 @@ public void afterTextChanged(Editable s) { }; private static void updateOldUIDislikesTextView() { + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + return; + } TextView oldUITextView = oldUITextViewRef.get(); if (oldUITextView == null) { return; } - oldUIReplacementSpan = ReturnYouTubeDislike.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false); + oldUIReplacementSpan = videoData.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false); if (!oldUIReplacementSpan.equals(oldUITextView.getText())) { oldUITextView.setText(oldUIReplacementSpan); } @@ -162,13 +190,41 @@ public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, final Spanned replacement; if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) { // Regular video - replacement = ReturnYouTubeDislike.getDislikesSpanForRegularVideo((Spannable) original, true); + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + return original; // User enabled RYD while a video was on screen. + } + replacement = videoData.getDislikesSpanForRegularVideo((Spannable) original, true); // 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 this. - } else if (SettingsEnum.RYD_SHORTS.getBoolean() && conversionContextString.contains("|shorts_dislike_button.eml|")) { + // but spoofing to that range gives a broken UI layout so no point checking for that. + } else if (conversionContextString.contains("|shorts_dislike_button.eml|")) { + 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. + currentVideoData = null; + return original; + } + ReturnYouTubeDislike videoData = lastLithoShortsVideoData; // Litho shorts player - replacement = ReturnYouTubeDislike.getDislikeSpanForShort((Spannable) original); + if (videoData == null) { + // Should not happen, as user cannot turn on RYD while leaving a short on screen. + // If this does happen, then the litho video id filter did not detect the video id. + LogHelper.printDebug(() -> "Error: Litho video data is null, but it should not be"); + return original; + } + // Use the correct dislikes data after voting. + if (lithoShortsShouldUseCurrentData) { + lithoShortsShouldUseCurrentData = false; + videoData = currentVideoData; + if (videoData == null) { + LogHelper.printException(() -> "Error: currentVideoData is null"); // Should never happen + return original; + } + LogHelper.printDebug(() -> "Using current video data for litho span"); + } + replacement = videoData.getDislikeSpanForShort((Spannable) original); } else { return original; } @@ -200,15 +256,13 @@ public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, private static final List> shortsTextViewRefs = new ArrayList<>(); private static void clearRemovedShortsTextViews() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // YouTube requires Android N or greater shortsTextViewRefs.removeIf(ref -> ref.get() == null); - return; } - throw new IllegalStateException(); // YouTube requires Android N or greater } /** - * Injection point. Called when a Shorts dislike is updated. + * Injection point. Called when a Shorts dislike is updated. Always on main thread. * Handles update asynchronously, otherwise Shorts video will be frozen while the UI thread is blocked. * * @return if RYD is enabled and the TextView was updated @@ -221,7 +275,7 @@ public static boolean setShortsDislikes(@NonNull View likeDislikeView) { if (!SettingsEnum.RYD_SHORTS.getBoolean()) { // Must clear the data here, in case a new video was loaded while PlayerType // suggested the video was not a short (can happen when spoofing to an old app version). - ReturnYouTubeDislike.setCurrentVideoId(null); + currentVideoData = null; return false; } LogHelper.printDebug(() -> "setShortsDislikes"); @@ -232,7 +286,8 @@ public static boolean setShortsDislikes(@NonNull View likeDislikeView) { if (likeDislikeView.isSelected() && isShortTextViewOnScreen(textView)) { LogHelper.printDebug(() -> "Shorts dislike is already selected"); - ReturnYouTubeDislike.setUserVote(Vote.DISLIKE); + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData != null) videoData.setUserVote(Vote.DISLIKE); } // For the first short played, the shorts dislike hook is called after the video id hook. @@ -257,13 +312,17 @@ private static void updateOnScreenShortsTextViews(boolean forceUpdate) { if (shortsTextViewRefs.isEmpty()) { return; } + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + return; + } LogHelper.printDebug(() -> "updateShortsTextViews"); - String videoId = VideoInformation.getVideoId(); Runnable update = () -> { - Spanned shortsDislikesSpan = ReturnYouTubeDislike.getDislikeSpanForShort(SHORTS_LOADING_SPAN); + Spanned shortsDislikesSpan = videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN); ReVancedUtils.runOnMainThreadNowOrLater(() -> { + String videoId = videoData.getVideoId(); if (!videoId.equals(VideoInformation.getVideoId())) { // User swiped to new video before fetch completed LogHelper.printDebug(() -> "Ignoring stale dislikes data for short: " + videoId); @@ -287,7 +346,7 @@ private static void updateOnScreenShortsTextViews(boolean forceUpdate) { } }); }; - if (ReturnYouTubeDislike.fetchCompleted()) { + if (videoData.fetchCompleted()) { update.run(); // Network call is completed, no need to wait on background thread. } else { ReVancedUtils.runOnBackgroundThread(update); @@ -313,55 +372,83 @@ private static boolean isShortTextViewOnScreen(@NonNull View view) { // - // Video Id and voting hooks (all players) + // Video Id and voting hooks (all players). // /** - * Injection point. + * Injection point. Uses 'playback response' video id hook to preload RYD. + */ + public static void preloadPlayerResponseVideoId(@NonNull String videoId) { + if (!SettingsEnum.RYD_ENABLED.getBoolean()) { + return; + } + if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneOrHidden()) { + return; + } + if (videoId.equals(lastPrefetchedVideoId)) { + return; + } + lastPrefetchedVideoId = videoId; + LogHelper.printDebug(() -> "Prefetching RYD for video: " + videoId); + ReturnYouTubeDislike.getFetchForVideoId(videoId); + } + + /** + * Injection point. Uses 'current playing' video id hook. Always called on main thread. */ public static void newVideoLoaded(@NonNull String videoId) { - newVideoLoaded(videoId, true); + newVideoLoaded(videoId, false); } /** - * @param isVideoInformationHook If the video id is from {@link VideoInformation}. + * Called both on and off main thread. + * + * @param isShortsLithoVideoId If the video id is from {@link ReturnYouTubeDislikeFilterPatch}. */ - public static void newVideoLoaded(@NonNull String videoId, boolean isVideoInformationHook) { + public static void newVideoLoaded(@NonNull String videoId, boolean isShortsLithoVideoId) { try { if (!SettingsEnum.RYD_ENABLED.getBoolean()) return; - if (!videoId.equals(currentVideoId)) { - final boolean isNoneOrHidden = PlayerType.getCurrent().isNoneOrHidden(); - if (isNoneOrHidden) { - if (!SettingsEnum.RYD_SHORTS.getBoolean()) { - // Clear the video id to prevent voting for the wrong video if spoofing to 16.x - currentVideoId = null; - ReturnYouTubeDislike.setCurrentVideoId(null); - return; - } - if (isVideoInformationHook && shortsTextViewRefs.isEmpty() && oldUITextViewRef.get() == null) { - // App is using the new litho shorts player, because no dislike text views were hooked. - // Do not set the video id here, and instead it's set from the litho filter. - currentVideoId = null; - LogHelper.printDebug(() -> "Ignoring VideoInformation hook as litho shorts player is in use for video: " + videoId); - return; - } + PlayerType currentPlayerType = PlayerType.getCurrent(); + final boolean isNoneOrHidden = currentPlayerType.isNoneOrHidden(); + if (isNoneOrHidden && !SettingsEnum.RYD_SHORTS.getBoolean()) { + return; + } + + if (isShortsLithoVideoId) { + // Non litho shorts video. + if (videoIdIsSame(lastLithoShortsVideoData, videoId)) { + return; } + ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); + videoData.setVideoIdIsShort(true); + lastLithoShortsVideoData = videoData; + lithoShortsShouldUseCurrentData = false; + } else { + if (videoIdIsSame(currentVideoData, videoId)) { + return; + } + // All playback (including non shorts litho) + currentVideoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); + } - currentVideoId = videoId; - ReturnYouTubeDislike.newVideoLoaded(videoId); + LogHelper.printDebug(() -> "New video id: " + videoId + " playerType: " + currentPlayerType + + " isCurrentVideo: " + !isShortsLithoVideoId); - if (isNoneOrHidden) { - // Shorts TextView hook can be called out of order with the video id hook. - // Must manually update again here. - updateOnScreenShortsTextViews(true); - } + if (isNoneOrHidden) { + // Non litho shorts TextView hook can be called out of order with the video id hook. + // Must manually update again here. + updateOnScreenShortsTextViews(true); } } catch (Exception ex) { LogHelper.printException(() -> "newVideoLoaded failure", ex); } } + private static boolean videoIdIsSame(ReturnYouTubeDislike fetch, String videoId) { + return fetch != null && fetch.getVideoId().equals(videoId); + } + /** * Injection point. * @@ -374,18 +461,21 @@ public static void sendVote(int vote) { if (!SettingsEnum.RYD_ENABLED.getBoolean()) { return; } - if (PlayerType.getCurrent().isNoneHiddenOrMinimized()) { - if (!SettingsEnum.RYD_SHORTS.getBoolean()) return; - // Because the video id is initially set from the Litho filter, - // the last loaded short will be the next short (and not the current). - // Fix this by setting the current short that is on screen now. - ReturnYouTubeDislike.newVideoLoaded(VideoInformation.getVideoId()); + if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneHiddenOrMinimized()) { + return; + } + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + return; // User enabled RYD while a regular video was minimized. } for (Vote v : Vote.values()) { if (v.value == vote) { - ReturnYouTubeDislike.sendVote(v); + videoData.sendVote(v); + if (lastLithoShortsVideoData != null) { + lithoShortsShouldUseCurrentData = true; + } updateOldUIDislikesTextView(); return; } diff --git a/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java b/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java index defaa92ac2..54d068ad45 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java @@ -9,20 +9,23 @@ import app.revanced.integrations.patches.ReturnYouTubeDislikePatch; import app.revanced.integrations.settings.SettingsEnum; -import app.revanced.integrations.utils.LogHelper; @RequiresApi(api = Build.VERSION_CODES.N) public final class ReturnYouTubeDislikeFilterPatch extends Filter { - private static final String VIDEO_ID_PREFIX_TEXT = "ic_right_dislike_off_shadowed"; - - private final ByteArrayAsStringFilterGroup videoIdFilterGroup - = new ByteArrayAsStringFilterGroup(null, VIDEO_ID_PREFIX_TEXT); + private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList(); public ReturnYouTubeDislikeFilterPatch() { pathFilterGroupList.addAll( new StringFilterGroup(SettingsEnum.RYD_SHORTS, "|shorts_dislike_button.eml|") ); + // After the dislikes icon name is some binary data and then the video id for that specific short. + videoIdFilterGroup.addAll( + // Video was previously disliked before video was opened. + new ByteArrayAsStringFilterGroup(null, "ic_right_dislike_on_shadowed"), + // Video was not already disliked. + new ByteArrayAsStringFilterGroup(null, "ic_right_dislike_off_shadowed") + ); } @Override @@ -30,13 +33,13 @@ public boolean isFiltered(@Nullable String identifier, String path, byte[] proto FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray); if (result.isFiltered()) { - final int minimumYouTubeVideoIdLength = 11; - final int subStringSearchStartIndex = result.getMatchedIndex() + VIDEO_ID_PREFIX_TEXT.length(); - String videoId = findSubString(protobufBufferArray, subStringSearchStartIndex, - minimumYouTubeVideoIdLength, (byte) ':'); + // The video length must be hard coded to 11, as there is additional ASCII text that + // appears immediately after the id if the dislike button is already selected. + final int videoIdLength = 11; + final int subStringSearchStartIndex = result.getMatchedIndex() + result.getMatchedLength(); + String videoId = findSubString(protobufBufferArray, subStringSearchStartIndex, videoIdLength); if (videoId != null) { - LogHelper.printDebug(() -> "Found shorts litho video id: " + videoId); - ReturnYouTubeDislikePatch.newVideoLoaded(videoId, false); + ReturnYouTubeDislikePatch.newVideoLoaded(videoId, true); } } @@ -44,39 +47,30 @@ public boolean isFiltered(@Nullable String identifier, String path, byte[] proto } /** - * Find a minimum length ASCII substring starting from a given index, - * and the substring ends with a specific character. + * Find an exact length ASCII substring starting from a given index. * * Similar to the String finding code in {@link LithoFilterPatch}, * but refactoring it to also handle this use case became messy and overly complicated. - * - * @param terminatingByte Terminating byte at the end of a the minimum ascii substring. - * The substring will include this terminating character, - * if it appears before the minimum length. */ @Nullable - private static String findSubString(byte[] buffer, int bufferStartIndex, - int minimumSubStringLength, byte terminatingByte) { + private static String findSubString(byte[] buffer, int bufferStartIndex, int subStringLength) { // Valid ASCII values (ignore control characters). final int minimumAscii = 32; // 32 = space character final int maximumAscii = 126; // 127 = delete character final int bufferLength = buffer.length; int start = bufferStartIndex; - int end = 0; - while (end < bufferLength) { + int end = bufferStartIndex; + do { final int value = buffer[end]; - if (value == terminatingByte) { - final int subStringLength = end - start; - if (subStringLength >= minimumSubStringLength) { - return new String(buffer, start, subStringLength, StandardCharsets.US_ASCII); - } - } - end++; if (value < minimumAscii || value > maximumAscii) { - start = end; + start = end + 1; + } else if (end - start == subStringLength) { + return new String(buffer, start, subStringLength, StandardCharsets.US_ASCII); } - } + end++; + } while (end < bufferLength); + return null; } } diff --git a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java index 4b4ddd55be..c9d23f8656 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java @@ -45,40 +45,21 @@ import app.revanced.integrations.utils.ThemeHelper; /** + * Handles fetching and creation/replacing of RYD dislike text spans. + * * Because Litho creates spans using multiple threads, this entire class supports multithreading as well. */ public class ReturnYouTubeDislike { - /** - * Simple wrapper to cache a Future. - */ - private static class RYDCachedFetch { - /** - * How long to retain cached RYD fetches. - */ - static final long CACHE_TIMEOUT_MILLISECONDS = 10 * 60 * 1000; // 10 Minutes - - @NonNull - final Future future; - final String videoId; - final long timeFetched; - RYDCachedFetch(@NonNull Future future, @NonNull String videoId) { - this.future = Objects.requireNonNull(future); - this.videoId = Objects.requireNonNull(videoId); - this.timeFetched = System.currentTimeMillis(); - } + public enum Vote { + LIKE(1), + DISLIKE(-1), + LIKE_REMOVE(0); - boolean isExpired(long now) { - return (now - timeFetched) > CACHE_TIMEOUT_MILLISECONDS; - } + public final int value; - boolean futureInProgressOrFinishedSuccessfully() { - try { - return !future.isDone() || future.get(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH, TimeUnit.MILLISECONDS) != null; - } catch (ExecutionException | InterruptedException | TimeoutException ex) { - LogHelper.printInfo(() -> "failed to lookup cache", ex); // will never happen - } - return false; + Vote(int value) { + this.value = value; } } @@ -90,6 +71,11 @@ boolean futureInProgressOrFinishedSuccessfully() { */ private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000; + /** + * How long to retain cached RYD fetches. + */ + private static final long CACHE_TIMEOUT_MILLISECONDS = 5 * 60 * 1000; // 5 Minutes + /** * Unique placeholder character, used to detect if a segmented span already has dislikes added to it. * Can be any almost any non-visible character. @@ -97,10 +83,10 @@ boolean futureInProgressOrFinishedSuccessfully() { private static final char MIDDLE_SEPARATOR_CHARACTER = '\u2009'; // 'narrow space' character /** - * Cached lookup of RYD fetches. + * Cached lookup of all video ids. */ - @GuardedBy("videoIdLockObject") - private static final Map futureCache = new HashMap<>(); + @GuardedBy("itself") + private static final Map fetchCache = new HashMap<>(); /** * Used to send votes, one by one, in the same order the user created them. @@ -108,190 +94,119 @@ boolean futureInProgressOrFinishedSuccessfully() { private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor(); /** - * Used to guard {@link #currentVideoId} and {@link #voteFetchFuture}. + * For formatting dislikes as number. */ - private static final Object videoIdLockObject = new Object(); - - @Nullable - @GuardedBy("videoIdLockObject") - private static String currentVideoId; + @GuardedBy("ReturnYouTubeDislike.class") // not thread safe + private static CompactDecimalFormat dislikeCountFormatter; /** - * If {@link #currentVideoId} and the RYD data is for the last shorts loaded. + * For formatting dislikes as percentage. */ - private static volatile boolean dislikeDataIsShort; + @GuardedBy("ReturnYouTubeDislike.class") + private static NumberFormat dislikePercentageFormatter; + + // Used for segmented dislike spans in Litho regular player. + private static final Rect leftSeparatorBounds; + private static final Rect middleSeparatorBounds; + + static { + DisplayMetrics dp = Objects.requireNonNull(ReVancedUtils.getContext()).getResources().getDisplayMetrics(); + + leftSeparatorBounds = new Rect(0, 0, + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp), + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 18, dp)); + final int middleSeparatorSize = + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp); + middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize); + } + + private final String videoId; /** * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes. + * Absolutely cannot be holding any lock during calls to {@link Future#get()}. */ - @Nullable - @GuardedBy("videoIdLockObject") - private static Future voteFetchFuture; + private final Future future; /** - * Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing. + * Time this instance and the future was created. */ - @Nullable - @GuardedBy("videoIdLockObject") - private static Vote userVote; + private final long timeFetched; /** - * Original dislike span, before modifications. + * If the video id is for a Short. + * Value of TRUE indicates it was previously loaded for a Short + * and FALSE indicates a regular video. + * NULL values means short status is not yet known. */ @Nullable - @GuardedBy("videoIdLockObject") - private static Spanned originalDislikeSpan; + @GuardedBy("this") + private Boolean isShort; /** - * Replacement like/dislike span that includes formatted dislikes. - * Used to prevent recreating the same span multiple times. + * Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing. */ @Nullable - @GuardedBy("videoIdLockObject") - private static SpannableString replacementLikeDislikeSpan; + @GuardedBy("this") + private Vote userVote; /** - * For formatting dislikes as number. + * Original dislike span, before modifications. */ - @GuardedBy("ReturnYouTubeDislike.class") // not thread safe - private static CompactDecimalFormat dislikeCountFormatter; + @Nullable + @GuardedBy("this") + private Spanned originalDislikeSpan; /** - * For formatting dislikes as percentage. + * Replacement like/dislike span that includes formatted dislikes. + * Used to prevent recreating the same span multiple times. */ - @GuardedBy("ReturnYouTubeDislike.class") - private static NumberFormat dislikePercentageFormatter; - - public enum Vote { - LIKE(1), - DISLIKE(-1), - LIKE_REMOVE(0); - - public final int value; - - Vote(int value) { - this.value = value; - } - } - - private ReturnYouTubeDislike() { - } // only static methods + @Nullable + @GuardedBy("this") + private SpannableString replacementLikeDislikeSpan; public static void onEnabledChange(boolean enabled) { if (!enabled) { - // Must clear old values, to protect against using stale data + // Must clear old values to protect against using stale data // if the user re-enables RYD while watching a video. - setCurrentVideoId(null); + synchronized (fetchCache) { + fetchCache.clear(); + } } } - public static void setCurrentVideoId(@Nullable String videoId) { - synchronized (videoIdLockObject) { - if (videoId == null && currentVideoId != null) { - LogHelper.printDebug(() -> "Clearing data"); - } - + @NonNull + public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) { + Objects.requireNonNull(videoId); + synchronized (fetchCache) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { final long now = System.currentTimeMillis(); - futureCache.values().removeIf(value -> { + fetchCache.values().removeIf(value -> { final boolean expired = value.isExpired(now); - if (expired) LogHelper.printDebug(() -> "Removing expired fetch: " + value.videoId); + if (expired) + LogHelper.printDebug(() -> "Removing expired fetch: " + value.videoId); return expired; }); - } else { - throw new IllegalStateException(); // YouTube requires Android N or greater } - currentVideoId = videoId; - dislikeDataIsShort = false; - userVote = null; - voteFetchFuture = null; - originalDislikeSpan = null; - replacementLikeDislikeSpan = null; - } - } - /** - * Should be called after a user dislikes, or if the user changes settings for dislikes appearance. - */ - public static void clearCache() { - synchronized (videoIdLockObject) { - if (replacementLikeDislikeSpan != null) { - LogHelper.printDebug(() -> "Clearing replacement spans"); + ReturnYouTubeDislike fetch = fetchCache.get(videoId); + if (fetch == null || !fetch.futureInProgressOrFinishedSuccessfully()) { + fetch = new ReturnYouTubeDislike(videoId); + fetchCache.put(videoId, fetch); } - replacementLikeDislikeSpan = null; - } - } - - @Nullable - private static String getCurrentVideoId() { - synchronized (videoIdLockObject) { - return currentVideoId; - } - } - - @Nullable - private static Future getVoteFetchFuture() { - synchronized (videoIdLockObject) { - return voteFetchFuture; - } - } - - public static void newVideoLoaded(@NonNull String videoId) { - Objects.requireNonNull(videoId); - - synchronized (videoIdLockObject) { - if (videoId.equals(currentVideoId)) { - return; // already loaded - } - if (!ReVancedUtils.isNetworkConnected()) { // must do network check after verifying it's a new video id - LogHelper.printDebug(() -> "Network not connected, ignoring video: " + videoId); - setCurrentVideoId(null); - return; - } - PlayerType currentPlayerType = PlayerType.getCurrent(); - LogHelper.printDebug(() -> "New video loaded: " + videoId + " playerType: " + currentPlayerType); - setCurrentVideoId(videoId); - - // If a Short is opened while a regular video is on screen, this will incorrectly set this as false. - // But this check is needed to fix unusual situations of opening/closing the app - // while both a regular video and a short are on screen. - dislikeDataIsShort = currentPlayerType.isNoneOrHidden(); - - RYDCachedFetch entry = futureCache.get(videoId); - if (entry != null && entry.futureInProgressOrFinishedSuccessfully()) { - LogHelper.printDebug(() -> "Using cached RYD fetch: "+ entry.videoId); - voteFetchFuture = entry.future; - return; - } - voteFetchFuture = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId)); - futureCache.put(videoId, new RYDCachedFetch(voteFetchFuture, videoId)); + return fetch; } } /** - * @return the replacement span containing dislikes, or the original span if RYD is not available. + * Should be called if the user changes settings for dislikes appearance. */ - @NonNull - public static Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, boolean isSegmentedButton) { - if (dislikeDataIsShort) { - // user: - // 1, opened a video - // 2. opened a short (without closing the regular video) - // 3. closed the short - // 4. regular video is now present, but the videoId and RYD data is still for the short - LogHelper.printDebug(() -> "Ignoring dislike span, as data loaded is for prior short"); - return original; + public static void clearAllCaches() { + synchronized (fetchCache) { + for (ReturnYouTubeDislike fetch : fetchCache.values()) { + fetch.clearCache(); + } } - return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton); - } - - /** - * Called when a Shorts dislike Spannable is created. - */ - @NonNull - public static Spanned getDislikeSpanForShort(@NonNull Spanned original) { - dislikeDataIsShort = true; // it's now certain the video and data are a short - return waitForFetchAndUpdateReplacementSpan(original, false); } // Alternatively, this could check if the span contains one of the custom created spans, but this is simple and quick. @@ -299,162 +214,6 @@ private static boolean isPreviouslyCreatedSegmentedSpan(@NonNull Spanned span) { return span.toString().indexOf(MIDDLE_SEPARATOR_CHARACTER) != -1; } - @NonNull - private static Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned oldSpannable, boolean isSegmentedButton) { - try { - Future fetchFuture = getVoteFetchFuture(); - if (fetchFuture == null) { - LogHelper.printDebug(() -> "fetch future not available (user enabled RYD while video was playing?)"); - return oldSpannable; - } - // Absolutely cannot be holding any lock during get(). - RYDVoteData votingData = fetchFuture.get(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH, TimeUnit.MILLISECONDS); - if (votingData == null) { - LogHelper.printDebug(() -> "Cannot add dislike to UI (RYD data not available)"); - return oldSpannable; - } - - // Must check against existing replacements, after the fetch, - // otherwise concurrent threads can create the same replacement same multiple times. - // Also do the replacement comparison and creation in a single synchronized block. - synchronized (videoIdLockObject) { - if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) { - if (spansHaveEqualTextAndColor(oldSpannable, replacementLikeDislikeSpan)) { - LogHelper.printDebug(() -> "Ignoring previously created dislikes span"); - return oldSpannable; - } - if (spansHaveEqualTextAndColor(oldSpannable, originalDislikeSpan)) { - LogHelper.printDebug(() -> "Replacing span with previously created dislike span"); - return replacementLikeDislikeSpan; - } - } - if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(oldSpannable)) { - // need to recreate using original, as oldSpannable has prior outdated dislike values - if (originalDislikeSpan == null) { - LogHelper.printDebug(() -> "Cannot add dislikes - original span is null"); // should never happen - return oldSpannable; - } - oldSpannable = originalDislikeSpan; - } - - // No replacement span exist, create it now. - - if (userVote != null) { - votingData.updateUsingVote(userVote); - } - originalDislikeSpan = oldSpannable; - replacementLikeDislikeSpan = createDislikeSpan(oldSpannable, isSegmentedButton, votingData); - LogHelper.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '" + replacementLikeDislikeSpan + "'"); - - return replacementLikeDislikeSpan; - } - } catch (TimeoutException e) { - LogHelper.printDebug(() -> "UI timed out while waiting for fetch votes to complete"); // show no toast - } catch (Exception e) { - LogHelper.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen - } - return oldSpannable; - } - - /** - * @return if the RYD fetch call has completed. - */ - public static boolean fetchCompleted() { - Future future = getVoteFetchFuture(); - return future != null && future.isDone(); - } - - public static void sendVote(@NonNull Vote vote) { - ReVancedUtils.verifyOnMainThread(); - Objects.requireNonNull(vote); - try { - // Must make a local copy of videoId, since it may change between now and when the vote thread runs. - String videoIdToVoteFor = getCurrentVideoId(); - if (videoIdToVoteFor == null || - (SettingsEnum.RYD_SHORTS.getBoolean() && dislikeDataIsShort != PlayerType.getCurrent().isNoneOrHidden())) { - // User enabled RYD after starting playback of a video. - // Or shorts was loaded with regular video present, then shorts was closed, - // and then user voted on the now visible original video. - // Cannot send a vote, because the loaded videoId is for the wrong video. - ReVancedUtils.showToastLong(str("revanced_ryd_failure_ryd_enabled_while_playing_video_then_user_voted")); - return; - } - - voteSerialExecutor.execute(() -> { - try { // must wrap in try/catch to properly log exceptions - String userId = getUserId(); - if (userId != null) { - ReturnYouTubeDislikeApi.sendVote(videoIdToVoteFor, userId, vote); - } - } catch (Exception ex) { - LogHelper.printException(() -> "Failed to send vote", ex); - } - }); - - setUserVote(vote); - } catch (Exception ex) { - LogHelper.printException(() -> "Error trying to send vote", ex); - } - } - - public static void setUserVote(@NonNull Vote vote) { - Objects.requireNonNull(vote); - try { - LogHelper.printDebug(() -> "setUserVote: " + vote); - - // Update the downloaded vote data. - Future future = getVoteFetchFuture(); - if (future != null && future.isDone()) { - RYDVoteData voteData; - try { - voteData = future.get(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH, TimeUnit.MILLISECONDS); - } catch (ExecutionException | InterruptedException | TimeoutException ex) { - // Should never happen - LogHelper.printInfo(() -> "Could not update vote data", ex); - return; - } - if (voteData == null) { - // RYD fetch failed - LogHelper.printDebug(() -> "Cannot update UI (vote data not available)"); - return; - } - - voteData.updateUsingVote(vote); - } // Else, vote will be applied after vote data is received - - synchronized (videoIdLockObject) { - if (userVote != vote) { - userVote = vote; - clearCache(); // UI needs updating - } - } - } catch (Exception ex) { - LogHelper.printException(() -> "setUserVote failure", ex); - } - } - - /** - * Must call off main thread, as this will make a network call if user is not yet registered. - * - * @return ReturnYouTubeDislike user ID. If user registration has never happened - * and the network call fails, this returns NULL. - */ - @Nullable - private static String getUserId() { - ReVancedUtils.verifyOffMainThread(); - - String userId = SettingsEnum.RYD_USER_ID.getString(); - if (!userId.isEmpty()) { - return userId; - } - - userId = ReturnYouTubeDislikeApi.registerAsNewUser(); - if (userId != null) { - SettingsEnum.RYD_USER_ID.saveValue(userId); - } - return userId; - } - /** * @param isSegmentedButton If UI is using the segmented single UI component for both like and dislike. */ @@ -493,13 +252,9 @@ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, final int separatorColor = ThemeHelper.isDarkTheme() ? 0x29AAAAAA // transparent dark gray : 0xFFD9D9D9; // light gray - DisplayMetrics dp = Objects.requireNonNull(ReVancedUtils.getContext()).getResources().getDisplayMetrics(); if (!compactLayout) { // left separator - final Rect leftSeparatorBounds = new Rect(0, 0, - (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp), - (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 18, dp)); String leftSeparatorString = ReVancedUtils.isRightToLeftTextLayout() ? "\u200F " // u200F = right to left character : "\u200E "; // u200E = left to right character @@ -520,8 +275,6 @@ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, ? " " + MIDDLE_SEPARATOR_CHARACTER + " " : " \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character final int shapeInsertionIndex = middleSeparatorString.length() / 2; - final int middleSeparatorSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp); - final Rect middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize); Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString); ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape()); shapeDrawable.getPaint().setColor(separatorColor); @@ -622,6 +375,196 @@ private static String formatDislikePercentage(float dislikePercentage) { return dislikePercentageFormatter.format(dislikePercentage); } } + + private ReturnYouTubeDislike(@NonNull String videoId) { + this.videoId = Objects.requireNonNull(videoId); + this.timeFetched = System.currentTimeMillis(); + this.future = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId)); + } + + private boolean isExpired(long now) { + return timeFetched != 0 && (now - timeFetched) > CACHE_TIMEOUT_MILLISECONDS; + } + + @Nullable + public RYDVoteData getFetchData(long maxTimeToWait) { + try { + return future.get(maxTimeToWait, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + LogHelper.printDebug(() -> "Waited but future was not complete after: " + maxTimeToWait + "ms"); + } catch (ExecutionException | InterruptedException ex) { + LogHelper.printException(() -> "Future failure ", ex); // will never happen + } + return null; + } + + private boolean futureInProgressOrFinishedSuccessfully() { + return !future.isDone() || getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH) != null; + } + + private synchronized void clearCache() { + if (replacementLikeDislikeSpan != null) { + LogHelper.printDebug(() -> "Clearing replacement span for: " + videoId); + } + replacementLikeDislikeSpan = null; + } + + @NonNull + public String getVideoId() { + return videoId; + } + + /** + * Pre-emptively set this as a Short. + * Should only be used immediately after creation of this instance. + */ + public synchronized void setVideoIdIsShort(boolean isShort) { + this.isShort = isShort; + } + + /** + * @return the replacement span containing dislikes, or the original span if RYD is not available. + */ + @NonNull + public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, boolean isSegmentedButton) { + return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, false); + } + + /** + * Called when a Shorts dislike Spannable is created. + */ + @NonNull + public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) { + return waitForFetchAndUpdateReplacementSpan(original, false, true); + } + + @NonNull + private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, + boolean isSegmentedButton, + boolean spanIsForShort) { + try { + RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); + if (votingData == null) { + LogHelper.printDebug(() -> "Cannot add dislike to UI (RYD data not available)"); + return original; + } + + synchronized (this) { + if (isShort != null) { + if (isShort != spanIsForShort) { + // user: + // 1, opened a video + // 2. opened a short (without closing the regular video) + // 3. closed the short + // 4. regular video is now present, but the videoId and RYD data is still for the short + LogHelper.printDebug(() -> "Ignoring dislike span, as data loaded was previously" + + " used for a different video type."); + return original; + } + } else { + isShort = spanIsForShort; + } + + if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) { + if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) { + LogHelper.printDebug(() -> "Ignoring previously created dislikes span"); + return original; + } + if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) { + LogHelper.printDebug(() -> "Replacing span with previously created dislike span"); + return replacementLikeDislikeSpan; + } + } + if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original)) { + // need to recreate using original, as original has prior outdated dislike values + if (originalDislikeSpan == null) { + LogHelper.printDebug(() -> "Cannot add dislikes - original span is null"); // should never happen + return original; + } + original = originalDislikeSpan; + } + + // No replacement span exist, create it now. + + if (userVote != null) { + votingData.updateUsingVote(userVote); + } + originalDislikeSpan = original; + replacementLikeDislikeSpan = createDislikeSpan(original, isSegmentedButton, votingData); + LogHelper.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '" + + replacementLikeDislikeSpan + "'" + " using video: " + videoId); + + return replacementLikeDislikeSpan; + } + } catch (Exception e) { + LogHelper.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen + } + return original; + } + + /** + * @return if the RYD fetch call has completed. + */ + public boolean fetchCompleted() { + return future.isDone(); + } + + public void sendVote(@NonNull Vote vote) { + ReVancedUtils.verifyOnMainThread(); + Objects.requireNonNull(vote); + try { + if (isShort != null && isShort != PlayerType.getCurrent().isNoneOrHidden()) { + // Shorts was loaded with regular video present, then shorts was closed. + // and then user voted on the now visible original video. + // Cannot send a vote, because the loaded videoId is for the wrong video. + ReVancedUtils.showToastLong(str("revanced_ryd_failure_ryd_enabled_while_playing_video_then_user_voted")); + return; + } + + setUserVote(vote); + + voteSerialExecutor.execute(() -> { + try { // must wrap in try/catch to properly log exceptions + ReturnYouTubeDislikeApi.sendVote(videoId, vote); + } catch (Exception ex) { + LogHelper.printException(() -> "Failed to send vote", ex); + } + }); + } catch (Exception ex) { + LogHelper.printException(() -> "Error trying to send vote", ex); + } + } + + /** + * 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) { + Objects.requireNonNull(vote); + try { + LogHelper.printDebug(() -> "setUserVote: " + vote); + + synchronized (this) { + userVote = vote; + clearCache(); // UI needs updating + } + + if (future.isDone()) { + // Update the fetched vote data. + RYDVoteData voteData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); + if (voteData == null) { + // RYD fetch failed + LogHelper.printDebug(() -> "Cannot update UI (vote data not available)"); + return; + } + voteData.updateUsingVote(vote); + } // Else, vote will be applied after fetch completes. + + } catch (Exception ex) { + LogHelper.printException(() -> "setUserVote failure", ex); + } + } } class VerticallyCenteredImageSpan extends ImageSpan { 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 0965f493d0..a7e82576d4 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 @@ -391,13 +391,37 @@ private static String confirmRegistration(String userId, String solution) { return null; } - public static boolean sendVote(String videoId, String userId, ReturnYouTubeDislike.Vote vote) { + /** + * Must call off main thread, as this will make a network call if user is not yet registered. + * + * @return ReturnYouTubeDislike user ID. If user registration has never happened + * and the network call fails, this returns NULL. + */ + @Nullable + private static String getUserId() { + ReVancedUtils.verifyOffMainThread(); + + String userId = SettingsEnum.RYD_USER_ID.getString(); + if (!userId.isEmpty()) { + return userId; + } + + userId = registerAsNewUser(); + if (userId != null) { + SettingsEnum.RYD_USER_ID.saveValue(userId); + } + return userId; + } + + public static boolean sendVote(String videoId, ReturnYouTubeDislike.Vote vote) { ReVancedUtils.verifyOffMainThread(); Objects.requireNonNull(videoId); - Objects.requireNonNull(userId); Objects.requireNonNull(vote); try { + String userId = getUserId(); + if (userId == null) return false; + if (checkIfRateLimitInEffect("sendVote")) { return false; } diff --git a/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java b/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java index dadc85beb7..5a4b6af740 100644 --- a/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java +++ b/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java @@ -89,7 +89,7 @@ public void onCreate(Bundle savedInstanceState) { percentagePreference.setSummaryOff(str("revanced_ryd_dislike_percentage_summary_off")); percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> { SettingsEnum.RYD_DISLIKE_PERCENTAGE.saveValue(newValue); - ReturnYouTubeDislike.clearCache(); + ReturnYouTubeDislike.clearAllCaches(); updateUIState(); return true; }); @@ -102,7 +102,7 @@ public void onCreate(Bundle savedInstanceState) { compactLayoutPreference.setSummaryOff(str("revanced_ryd_compact_layout_summary_off")); compactLayoutPreference.setOnPreferenceChangeListener((pref, newValue) -> { SettingsEnum.RYD_COMPACT_LAYOUT.saveValue(newValue); - ReturnYouTubeDislike.clearCache(); + ReturnYouTubeDislike.clearAllCaches(); updateUIState(); return true; });