From 245c3b35373313d49cc5b1c2fd8e9deebb6258a5 Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Sat, 7 Oct 2023 11:31:25 +0200 Subject: [PATCH] feat(YouTube - Return YouTube Dislike): Support version `18.37.36` (#490) Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> --- .../patches/ReturnYouTubeDislikePatch.java | 218 +++++- .../patches/components/LithoFilterPatch.java | 74 +- .../ReturnYouTubeDislikeFilterPatch.java | 76 ++ .../ReturnYouTubeDislike.java | 722 ++++++++---------- .../requests/RYDVoteData.java | 28 +- .../requests/ReturnYouTubeDislikeApi.java | 28 +- .../ReturnYouTubeDislikeSettingsFragment.java | 7 +- .../integrations/utils/ReVancedUtils.java | 14 +- .../integrations/utils/TrieSearch.java | 9 +- 9 files changed, 704 insertions(+), 472 deletions(-) create mode 100644 app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java 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 2c3b3d0f..1dd5fb53 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.view.View; 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,14 +25,50 @@ import static app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislik /** * 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 String currentVideoId; + 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 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. + currentVideoData = null; + lastLithoShortsVideoData = null; + lithoShortsShouldUseCurrentData = false; + } + } + + + // + // 17.x non litho regular video player. + // /** * Resource identifier of old UI dislike button. @@ -76,11 +113,15 @@ public class ReturnYouTubeDislikePatch { }; 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); } @@ -124,6 +165,10 @@ public class ReturnYouTubeDislikePatch { } + // + // Litho player for both regular videos and Shorts. + // + /** * Injection point. * @@ -144,23 +189,56 @@ public class ReturnYouTubeDislikePatch { @NonNull AtomicReference textRef, @NonNull CharSequence original) { try { - if (!SettingsEnum.RYD_ENABLED.getBoolean() || PlayerType.getCurrent().isNoneOrHidden()) { + if (!SettingsEnum.RYD_ENABLED.getBoolean()) { return original; } String conversionContextString = conversionContext.toString(); + // Remove this log statement after the a/b new litho dislikes is fixed. LogHelper.printDebug(() -> "conversionContext: " + conversionContextString); - final boolean isSegmentedButton; + final Spanned replacement; if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) { - isSegmentedButton = true; - } else if (conversionContextString.contains("|dislike_button.eml|")) { - isSegmentedButton = false; + // Regular video + 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 that. + } else if (conversionContextString.contains("|shorts_dislike_button.eml|")) { + // Litho Shorts player. + 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; + 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(() -> "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; } - Spanned replacement = ReturnYouTubeDislike.getDislikesSpanForRegularVideo((Spannable) original, isSegmentedButton); textRef.set(replacement); return replacement; } catch (Exception ex) { @@ -170,6 +248,10 @@ public class ReturnYouTubeDislikePatch { } + // + // Non litho Shorts player. + // + /** * Replacement text to use for "Dislikes" while RYD is fetching. */ @@ -184,18 +266,16 @@ public class ReturnYouTubeDislikePatch { 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 + * @return if RYD is enabled and the TextView was updated. */ public static boolean setShortsDislikes(@NonNull View likeDislikeView) { try { @@ -205,21 +285,22 @@ public class ReturnYouTubeDislikePatch { 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"); TextView textView = (TextView) likeDislikeView; - textView.setText(SHORTS_LOADING_SPAN); // Change 'Dislike' text to the loading text + textView.setText(SHORTS_LOADING_SPAN); // Change 'Dislike' text to the loading text. shortsTextViewRefs.add(new WeakReference<>(textView)); 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. + // For the first short played, the Shorts dislike hook is called after the video id hook. // But for most other times this hook is called before the video id (which is not ideal). // Must update the TextViews here, and also after the videoId changes. updateOnScreenShortsTextViews(false); @@ -241,13 +322,17 @@ public class ReturnYouTubeDislikePatch { 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); @@ -271,13 +356,13 @@ public class ReturnYouTubeDislikePatch { } }); }; - if (ReturnYouTubeDislike.fetchCompleted()) { + if (videoData.fetchCompleted()) { update.run(); // Network call is completed, no need to wait on background thread. } else { ReVancedUtils.runOnBackgroundThread(update); } } catch (Exception ex) { - LogHelper.printException(() -> "updateVisibleShortsTextViews failure", ex); + LogHelper.printException(() -> "updateOnScreenShortsTextViews failure", ex); } } @@ -295,35 +380,85 @@ public class ReturnYouTubeDislikePatch { return location[0] < windowRect.width() && location[1] < windowRect.height(); } + + // + // Video Id and voting hooks (all players). + // + /** - * Injection point. + * Injection point. Uses 'playback response' video id hook to preload RYD. + */ + public static void preloadVideoId(@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, false); + } + + /** + * 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 isShortsLithoVideoId) { try { if (!SettingsEnum.RYD_ENABLED.getBoolean()) return; - if (!videoId.equals(currentVideoId)) { - currentVideoId = videoId; + PlayerType currentPlayerType = PlayerType.getCurrent(); + final boolean isNoneOrHidden = currentPlayerType.isNoneOrHidden(); + if (isNoneOrHidden && !SettingsEnum.RYD_SHORTS.getBoolean()) { + return; + } - final boolean noneHiddenOrMinimized = PlayerType.getCurrent().isNoneOrHidden(); - if (noneHiddenOrMinimized && !SettingsEnum.RYD_SHORTS.getBoolean()) { - ReturnYouTubeDislike.setCurrentVideoId(null); + if (isShortsLithoVideoId) { + // Litho Shorts video. + if (videoIdIsSame(lastLithoShortsVideoData, videoId)) { return; } - - ReturnYouTubeDislike.newVideoLoaded(videoId); - - if (noneHiddenOrMinimized) { - // Shorts TextView hook can be called out of order with the video id hook. - // Must manually update again here. - updateOnScreenShortsTextViews(true); + ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); + videoData.setVideoIdIsShort(true); + lastLithoShortsVideoData = videoData; + lithoShortsShouldUseCurrentData = false; + } else { + if (videoIdIsSame(currentVideoData, videoId)) { + return; } + // All other playback (including litho Shorts). + currentVideoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); + } + + LogHelper.printDebug(() -> "New video id: " + videoId + " playerType: " + currentPlayerType + + " isShortsLithoHook: " + isShortsLithoVideoId); + + if (isNoneOrHidden) { + // Current video id hook can be called out of order with the non litho Shorts text view hook. + // Must manually update again here. + updateOnScreenShortsTextViews(true); } } catch (Exception ex) { LogHelper.printException(() -> "newVideoLoaded failure", ex); } } + private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, String videoId) { + return fetch != null && fetch.getVideoId().equals(videoId); + } + /** * Injection point. * @@ -339,11 +474,18 @@ public class ReturnYouTubeDislikePatch { 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/LithoFilterPatch.java b/app/src/main/java/app/revanced/integrations/patches/components/LithoFilterPatch.java index 7cfe060b..5099a4df 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/LithoFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/LithoFilterPatch.java @@ -4,6 +4,7 @@ import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; + import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.utils.*; @@ -13,12 +14,24 @@ import java.util.function.Consumer; abstract class FilterGroup { final static class FilterGroupResult { - SettingsEnum setting; - boolean filtered; + private SettingsEnum setting; + private int matchedIndex; + private int matchedLength; + // In the future it might be useful to include which pattern matched, + // but for now that is not needed. - FilterGroupResult(SettingsEnum setting, boolean filtered) { + FilterGroupResult() { + this(null, -1, 0); + } + + FilterGroupResult(SettingsEnum setting, int matchedIndex, int matchedLength) { + setValues(setting, matchedIndex, matchedLength); + } + + public void setValues(SettingsEnum setting, int matchedIndex, int matchedLength) { this.setting = setting; - this.filtered = filtered; + this.matchedIndex = matchedIndex; + this.matchedLength = matchedLength; } /** @@ -30,7 +43,21 @@ abstract class FilterGroup { } public boolean isFiltered() { - return filtered; + return matchedIndex >= 0; + } + + /** + * Matched index of first pattern that matched, or -1 if nothing matched. + */ + public int getMatchedIndex() { + return matchedIndex; + } + + /** + * Length of the matched filter pattern. + */ + public int getMatchedLength() { + return matchedLength; } } @@ -81,7 +108,21 @@ class StringFilterGroup extends FilterGroup { @Override public FilterGroupResult check(final String string) { - return new FilterGroupResult(setting, isEnabled() && ReVancedUtils.containsAny(string, filters)); + int matchedIndex = -1; + int matchedLength = 0; + if (isEnabled()) { + for (String pattern : filters) { + if (!string.isEmpty()) { + final int indexOf = pattern.indexOf(string); + if (indexOf >= 0) { + matchedIndex = indexOf; + matchedLength = pattern.length(); + break; + } + } + } + } + return new FilterGroupResult(setting, matchedIndex, matchedLength); } } @@ -155,19 +196,22 @@ class ByteArrayFilterGroup extends FilterGroup { @Override public FilterGroupResult check(final byte[] bytes) { - var matched = false; + int matchedLength = 0; + int matchedIndex = -1; if (isEnabled()) { if (failurePatterns == null) { buildFailurePatterns(); // Lazy load. } for (int i = 0, length = filters.length; i < length; i++) { - if (indexOf(bytes, filters[i], failurePatterns[i]) >= 0) { - matched = true; + byte[] filter = filters[i]; + matchedIndex = indexOf(bytes, filter, failurePatterns[i]); + if (matchedIndex >= 0) { + matchedLength = filter.length; break; } } } - return new FilterGroupResult(setting, matched); + return new FilterGroupResult(setting, matchedIndex, matchedLength); } } @@ -204,11 +248,10 @@ abstract class FilterGroupList> implements Iterable< continue; } for (V pattern : group.filters) { - search.addPattern(pattern, (textSearched, matchedStartIndex, callbackParameter) -> { + search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> { if (group.isEnabled()) { FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter; - result.setting = group.setting; - result.filtered = true; + result.setValues(group.setting, matchedStartIndex, matchedLength); return true; } return false; @@ -241,9 +284,10 @@ abstract class FilterGroupList> implements Iterable< if (search == null) { buildSearch(); // Lazy load. } - FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult(null, false); + FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult(); search.matches(stack, result); return result; + } protected abstract TrieSearch createSearchGraph(); @@ -399,7 +443,7 @@ public final class LithoFilterPatch { continue; } for (T pattern : group.filters) { - pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, callbackParameter) -> { + pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> { if (!group.isEnabled()) return false; LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter; return filter.isFiltered(parameters.identifier, parameters.path, parameters.protoBuffer, 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 new file mode 100644 index 00000000..54d068ad --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java @@ -0,0 +1,76 @@ +package app.revanced.integrations.patches.components; + +import android.os.Build; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import java.nio.charset.StandardCharsets; + +import app.revanced.integrations.patches.ReturnYouTubeDislikePatch; +import app.revanced.integrations.settings.SettingsEnum; + +@RequiresApi(api = Build.VERSION_CODES.N) +public final class ReturnYouTubeDislikeFilterPatch extends Filter { + + 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 + public boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray); + if (result.isFiltered()) { + // 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) { + ReturnYouTubeDislikePatch.newVideoLoaded(videoId, true); + } + } + + return false; + } + + /** + * 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. + */ + @Nullable + 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 = bufferStartIndex; + do { + final int value = buffer[end]; + if (value < minimumAscii || value > maximumAscii) { + 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 21f72b9d..d68d0c1e 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java @@ -45,123 +45,12 @@ import app.revanced.integrations.utils.ReVancedUtils; 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 = 4 * 60 * 1000; // 4 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(); - } - - boolean isExpired(long now) { - return (now - timeFetched) > CACHE_TIMEOUT_MILLISECONDS; - } - - 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; - } - } - - /** - * Maximum amount of time to block the UI from updates while waiting for network call to complete. - * - * Must be less than 5 seconds, as per: - * https://developer.android.com/topic/performance/vitals/anr - */ - private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000; - - /** - * Unique placeholder character, used to detect if a segmented span already has dislikes added to it. - * Can be any almost any non-visible character. - */ - private static final char MIDDLE_SEPARATOR_CHARACTER = '\u2009'; // 'narrow space' character - - /** - * Cached lookup of RYD fetches. - */ - @GuardedBy("videoIdLockObject") - private static final Map futureCache = new HashMap<>(); - - /** - * Used to send votes, one by one, in the same order the user created them. - */ - private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor(); - - /** - * Used to guard {@link #currentVideoId} and {@link #voteFetchFuture}. - */ - private static final Object videoIdLockObject = new Object(); - - @Nullable - @GuardedBy("videoIdLockObject") - private static String currentVideoId; - - /** - * If {@link #currentVideoId} and the RYD data is for the last shorts loaded. - */ - private static volatile boolean dislikeDataIsShort; - - /** - * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes. - */ - @Nullable - @GuardedBy("videoIdLockObject") - private static Future voteFetchFuture; - - /** - * 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 Vote userVote; - - /** - * Original dislike span, before modifications. - */ - @Nullable - @GuardedBy("videoIdLockObject") - private static Spanned originalDislikeSpan; - - /** - * Replacement like/dislike span that includes formatted dislikes. - * Used to prevent recreating the same span multiple times. - */ - @Nullable - @GuardedBy("videoIdLockObject") - private static SpannableString replacementLikeDislikeSpan; - - /** - * For formatting dislikes as number. - */ - @GuardedBy("ReturnYouTubeDislike.class") // not thread safe - private static CompactDecimalFormat dislikeCountFormatter; - - /** - * For formatting dislikes as percentage. - */ - @GuardedBy("ReturnYouTubeDislike.class") - private static NumberFormat dislikePercentageFormatter; - public enum Vote { LIKE(1), DISLIKE(-1), @@ -174,286 +63,107 @@ public class ReturnYouTubeDislike { } } - private ReturnYouTubeDislike() { - } // only static methods - - public static void onEnabledChange(boolean enabled) { - if (!enabled) { - // Must clear old values, to protect against using stale data - // if the user re-enables RYD while watching a video. - setCurrentVideoId(null); - } - } - - public static void setCurrentVideoId(@Nullable String videoId) { - synchronized (videoIdLockObject) { - if (videoId == null && currentVideoId != null) { - LogHelper.printDebug(() -> "Clearing data"); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - final long now = System.currentTimeMillis(); - futureCache.values().removeIf(value -> { - final boolean expired = value.isExpired(now); - 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"); - } - 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 the replacement span containing dislikes, or the original span if RYD is not available. - */ - @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; - } - 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. - 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. + * Maximum amount of time to block the UI from updates while waiting for network call to complete. * - * @return ReturnYouTubeDislike user ID. If user registration has never happened - * and the network call fails, this returns NULL. + * Must be less than 5 seconds, as per: + * https://developer.android.com/topic/performance/vitals/anr + */ + 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. + */ + private static final char MIDDLE_SEPARATOR_CHARACTER = '\u2009'; // 'narrow space' character + + /** + * Cached lookup of all video ids. + */ + @GuardedBy("itself") + private static final Map fetchCache = new HashMap<>(); + + /** + * Used to send votes, one by one, in the same order the user created them. + */ + private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor(); + + /** + * For formatting dislikes as number. + */ + @GuardedBy("ReturnYouTubeDislike.class") // not thread safe + private static CompactDecimalFormat dislikeCountFormatter; + + /** + * For formatting dislikes as percentage. + */ + @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()}. + */ + private final Future future; + + /** + * Time this instance and the future was created. + */ + private final long timeFetched; + + /** + * 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 - private static String getUserId() { - ReVancedUtils.verifyOffMainThread(); + @GuardedBy("this") + private Boolean isShort; - String userId = SettingsEnum.RYD_USER_ID.getString(); - if (!userId.isEmpty()) { - return userId; - } + /** + * Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing. + */ + @Nullable + @GuardedBy("this") + private Vote userVote; - userId = ReturnYouTubeDislikeApi.registerAsNewUser(); - if (userId != null) { - SettingsEnum.RYD_USER_ID.saveValue(userId); - } - return userId; - } + /** + * Original dislike span, before modifications. + */ + @Nullable + @GuardedBy("this") + private Spanned originalDislikeSpan; + + /** + * Replacement like/dislike span that includes formatted dislikes. + * Used to prevent recreating the same span multiple times. + */ + @Nullable + @GuardedBy("this") + private SpannableString replacementLikeDislikeSpan; /** * @param isSegmentedButton If UI is using the segmented single UI component for both like and dislike. @@ -493,13 +203,9 @@ public class ReturnYouTubeDislike { 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 +226,6 @@ public class ReturnYouTubeDislike { ? " " + 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); @@ -536,6 +240,11 @@ public class ReturnYouTubeDislike { return new SpannableString(builder); } + // Alternatively, this could check if the span contains one of the custom created spans, but this is simple and quick. + private static boolean isPreviouslyCreatedSegmentedSpan(@NonNull Spanned span) { + return span.toString().indexOf(MIDDLE_SEPARATOR_CHARACTER) != -1; + } + /** * Correctly handles any unicode numbers (such as Arabic numbers). * @@ -603,7 +312,7 @@ public class ReturnYouTubeDislike { } } - // will never be reached, as the oldest supported YouTube app requires Android N or greater + // Will never be reached, as the oldest supported YouTube app requires Android N or greater. return String.valueOf(dislikeCount); } @@ -622,6 +331,231 @@ public class ReturnYouTubeDislike { return dislikePercentageFormatter.format(dislikePercentage); } } + + @NonNull + public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) { + Objects.requireNonNull(videoId); + synchronized (fetchCache) { + // Remove any expired entries. + 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 || !fetch.futureInProgressOrFinishedSuccessfully()) { + fetch = new ReturnYouTubeDislike(videoId); + fetchCache.put(videoId, fetch); + } + return fetch; + } + } + + /** + * Should be called if the user changes settings for dislikes appearance. + */ + public static void clearAllUICaches() { + synchronized (fetchCache) { + for (ReturnYouTubeDislike fetch : fetchCache.values()) { + fetch.clearUICache(); + } + } + } + + 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 clearUICache() { + 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; + clearUICache(); + } + + 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/RYDVoteData.java b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/RYDVoteData.java index 149f7ffd..faf61619 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/RYDVoteData.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/RYDVoteData.java @@ -7,8 +7,6 @@ import androidx.annotation.NonNull; import org.json.JSONException; import org.json.JSONObject; -import app.revanced.integrations.utils.LogHelper; - /** * ReturnYouTubeDislike API estimated like/dislike/view counts. * @@ -81,17 +79,21 @@ public final class RYDVoteData { } public void updateUsingVote(Vote vote) { - if (vote == Vote.LIKE) { - likeCount = fetchedLikeCount + 1; - dislikeCount = fetchedDislikeCount; - } else if (vote == Vote.DISLIKE) { - likeCount = fetchedLikeCount; - dislikeCount = fetchedDislikeCount + 1; - } else if (vote == Vote.LIKE_REMOVE) { - likeCount = fetchedLikeCount; - dislikeCount = fetchedDislikeCount; - } else { - throw new IllegalStateException(); + switch (vote) { + case LIKE: + likeCount = fetchedLikeCount + 1; + dislikeCount = fetchedDislikeCount; + break; + case DISLIKE: + likeCount = fetchedLikeCount; + dislikeCount = fetchedDislikeCount + 1; + break; + case LIKE_REMOVE: + likeCount = fetchedLikeCount; + dislikeCount = fetchedDislikeCount; + break; + default: + throw new IllegalStateException(); } updatePercentages(); } 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 0965f493..a7e82576 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 @@ public class ReturnYouTubeDislikeApi { 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 dadc85be..711d0f20 100644 --- a/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java +++ b/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java @@ -12,6 +12,7 @@ import android.preference.PreferenceFragment; import android.preference.PreferenceScreen; import android.preference.SwitchPreference; +import app.revanced.integrations.patches.ReturnYouTubeDislikePatch; import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike; import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; import app.revanced.integrations.settings.SettingsEnum; @@ -63,7 +64,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment { enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> { final boolean rydIsEnabled = (Boolean) newValue; SettingsEnum.RYD_ENABLED.saveValue(rydIsEnabled); - ReturnYouTubeDislike.onEnabledChange(rydIsEnabled); + ReturnYouTubeDislikePatch.onRYDStatusChange(rydIsEnabled); updateUIState(); return true; @@ -89,7 +90,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment { percentagePreference.setSummaryOff(str("revanced_ryd_dislike_percentage_summary_off")); percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> { SettingsEnum.RYD_DISLIKE_PERCENTAGE.saveValue(newValue); - ReturnYouTubeDislike.clearCache(); + ReturnYouTubeDislike.clearAllUICaches(); updateUIState(); return true; }); @@ -102,7 +103,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment { compactLayoutPreference.setSummaryOff(str("revanced_ryd_compact_layout_summary_off")); compactLayoutPreference.setOnPreferenceChangeListener((pref, newValue) -> { SettingsEnum.RYD_COMPACT_LAYOUT.saveValue(newValue); - ReturnYouTubeDislike.clearCache(); + ReturnYouTubeDislike.clearAllUICaches(); updateUIState(); return true; }); 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 940dd508..286a03bf 100644 --- a/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java +++ b/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java @@ -83,9 +83,17 @@ public class ReVancedUtils { } public static boolean containsAny(@NonNull String value, @NonNull String... targets) { - for (String string : targets) - if (!string.isEmpty() && value.contains(string)) return true; - return false; + return indexOfFirstFound(value, targets) >= 0; + } + + public static int indexOfFirstFound(@NonNull String value, @NonNull String... targets) { + for (String string : targets) { + if (!string.isEmpty()) { + final int indexOf = value.indexOf(string); + if (indexOf >= 0) return indexOf; + } + } + return -1; } /** diff --git a/app/src/main/java/app/revanced/integrations/utils/TrieSearch.java b/app/src/main/java/app/revanced/integrations/utils/TrieSearch.java index 6458f07c..8b9fecb8 100644 --- a/app/src/main/java/app/revanced/integrations/utils/TrieSearch.java +++ b/app/src/main/java/app/revanced/integrations/utils/TrieSearch.java @@ -23,11 +23,12 @@ public abstract class TrieSearch { * * @param textSearched Text that was searched. * @param matchedStartIndex Start index of the search text, where the pattern was matched. + * @param matchedLength Length of the match. * @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}. * @return True, if the search should stop here. * If false, searching will continue to look for other matches. */ - boolean patternMatched(T textSearched, int matchedStartIndex, Object callbackParameter); + boolean patternMatched(T textSearched, int matchedStartIndex, int matchedLength, Object callbackParameter); } /** @@ -64,8 +65,8 @@ public abstract class TrieSearch { return false; } } - return callback == null - || callback.patternMatched(searchText, searchTextIndex - patternStartIndex, callbackParameter); + return callback == null || callback.patternMatched(searchText, + searchTextIndex - patternStartIndex, patternLength, callbackParameter); } } @@ -161,7 +162,7 @@ public abstract class TrieSearch { if (callback == null) { return true; // No callback and all matches are valid. } - if (callback.patternMatched(searchText, matchStartIndex, callbackParameter)) { + if (callback.patternMatched(searchText, matchStartIndex, currentMatchLength, callbackParameter)) { return true; // Callback confirmed the match. } }