diff --git a/app/src/main/java/app/revanced/integrations/shared/Utils.java b/app/src/main/java/app/revanced/integrations/shared/Utils.java index 4b13a7876d..21a97a9a7f 100644 --- a/app/src/main/java/app/revanced/integrations/shared/Utils.java +++ b/app/src/main/java/app/revanced/integrations/shared/Utils.java @@ -363,6 +363,23 @@ public static boolean isRightToLeftTextLayout() { return isRightToLeftTextLayout; } + /** + * @return if the text contains at least 1 number character, + * including any unicode numbers such as Arabic. + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean containsNumber(@NonNull CharSequence text) { + for (int index = 0, length = text.length(); index < length;) { + final int codePoint = Character.codePointAt(text, index); + if (Character.isDigit(codePoint)) { + return true; + } + index += Character.charCount(codePoint); + } + + return false; + } + /** * Safe to call from any thread */ diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/ReturnYouTubeDislikePatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/ReturnYouTubeDislikePatch.java index 0fb8482957..8aa2c5ca5a 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/ReturnYouTubeDislikePatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/ReturnYouTubeDislikePatch.java @@ -225,7 +225,6 @@ private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, return original; } - final CharSequence replacement; if (conversionContextString.contains("segmented_like_dislike_button.eml")) { // Regular video. ReturnYouTubeDislike videoData = currentVideoData; @@ -235,46 +234,62 @@ private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, if (!(original instanceof Spanned)) { original = new SpannableString(original); } - replacement = videoData.getDislikesSpanForRegularVideo((Spanned) original, + return videoData.getDislikesSpanForRegularVideo((Spanned) original, true, isRollingNumber); - } else if (!isRollingNumber && conversionContextString.contains("|shorts_dislike_button.eml|")) { - // Litho Shorts player. - if (!Settings.RYD_SHORTS.get() || Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) { - // Must clear the current video here, otherwise if the user opens a regular video - // then opens a litho short (while keeping the regular video on screen), then closes the short, - // the original video may show the incorrect dislike value. - clearData(); - return original; - } - ReturnYouTubeDislike videoData = lastLithoShortsVideoData; - if (videoData == null) { - // The Shorts litho video id filter did not detect the video id. - // This is normal in incognito mode, but otherwise is abnormal. - Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null"); - return original; - } - // Use the correct dislikes data after voting. - if (lithoShortsShouldUseCurrentData) { - lithoShortsShouldUseCurrentData = false; - videoData = currentVideoData; - if (videoData == null) { - Logger.printException(() -> "currentVideoData is null"); // Should never happen - return original; - } - Logger.printDebug(() -> "Using current video data for litho span"); - } - replacement = videoData.getDislikeSpanForShort((Spanned) original); - } else { - return original; } - return replacement; + if (isRollingNumber) { + return original; // No need to check for Shorts in the context. + } + + if (conversionContextString.contains("|shorts_dislike_button.eml")) { + return getShortsSpan(original, true); + } + + if (conversionContextString.contains("|shorts_like_button.eml") + && !Utils.containsNumber(original)) { + Logger.printDebug(() -> "Replacing hidden likes count"); + return getShortsSpan(original, false); + } } catch (Exception ex) { Logger.printException(() -> "onLithoTextLoaded failure", ex); } return original; } + private static CharSequence getShortsSpan(@NonNull CharSequence original, boolean isDislikesSpan) { + // Litho Shorts player. + if (!Settings.RYD_SHORTS.get() || (isDislikesSpan && Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) + || (!isDislikesSpan && Settings.HIDE_SHORTS_LIKE_BUTTON.get())) { + return original; + } + + ReturnYouTubeDislike videoData = lastLithoShortsVideoData; + if (videoData == null) { + // The Shorts litho video id filter did not detect the video id. + // This is normal in incognito mode, but otherwise is abnormal. + Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null"); + return original; + } + + // Use the correct dislikes data after voting. + if (lithoShortsShouldUseCurrentData) { + if (isDislikesSpan) { + lithoShortsShouldUseCurrentData = false; + } + videoData = currentVideoData; + if (videoData == null) { + Logger.printException(() -> "currentVideoData is null"); // Should never happen + return original; + } + Logger.printDebug(() -> "Using current video data for litho span"); + } + + return isDislikesSpan + ? videoData.getDislikeSpanForShort((Spanned) original) + : videoData.getLikeSpanForShort((Spanned) original); + } + // // Rolling Number // @@ -597,6 +612,7 @@ public static void preloadVideoId(@NonNull String videoId, boolean isShortAndOpe Logger.printDebug(() -> "Waiting for prefetch to complete: " + videoId); fetch.getFetchData(20000); // Any arbitrarily large max wait time. } + // Set the fields after the fetch completes, so any concurrent calls will also wait. lastPlayerResponseWasShort = videoIdIsShort; lastPrefetchedVideoId = videoId; @@ -657,6 +673,7 @@ public static void setLastLithoShortsVideoId(@Nullable String videoId) { clearData(); return; } + Logger.printDebug(() -> "New litho Shorts video id: " + videoId); ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); videoData.setVideoIdIsShort(true); diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java index 927e449341..11bffcc54e 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java @@ -52,7 +52,7 @@ protected boolean removeEldestEntry(Entry eldest) { @SuppressWarnings("unused") public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) { try { - if (!isShortAndOpeningOrPlaying || !Settings.RYD_SHORTS.get()) { + if (!isShortAndOpeningOrPlaying || !Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) { return; } synchronized (lastVideoIds) { @@ -68,21 +68,28 @@ public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOp private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList(); public ReturnYouTubeDislikeFilterPatch() { + // Likes always seems to load before the dislikes, but if this + // ever changes then both likes and dislikes need callbacks. addPathCallbacks( - new StringFilterGroup(Settings.RYD_SHORTS, "|shorts_dislike_button.eml|") + new StringFilterGroup(null, "|shorts_like_button.eml") ); - // After the dislikes icon name is some binary data and then the video id for that specific short. + + // After the likes 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 ByteArrayFilterGroup(null, "ic_right_dislike_on_shadowed"), - // Video was not already disliked. - new ByteArrayFilterGroup(null, "ic_right_dislike_off_shadowed") + // Video was previously liked before video was opened. + new ByteArrayFilterGroup(null, "ic_right_like_on_shadowed"), + // Video was not already liked. + new ByteArrayFilterGroup(null, "ic_right_like_off_shadowed") ); } @Override boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (!Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) { + return false; + } + FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray); if (result.isFiltered()) { String matchedVideoId = findVideoId(protobufBufferArray); diff --git a/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java b/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java index ef409b52d3..c62e34f591 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java +++ b/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java @@ -23,6 +23,9 @@ public static HttpURLConnection getConnectionFromRoute(String apiUrl, Route rout public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException { String url = apiUrl + route.getCompiledRoute(); HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + // Request data is in the URL parameters and no body is sent. + // The calling code must set a length if using a request body. + connection.setFixedLengthStreamingMode(0); connection.setRequestMethod(route.getMethod().name()); String agentString = System.getProperty("http.agent") + "; ReVanced/" + Utils.getAppVersionName() diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java index bfff1b155b..b63d0484e0 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java @@ -10,6 +10,9 @@ import android.graphics.drawable.shapes.OvalShape; import android.graphics.drawable.shapes.RectShape; import android.icu.text.CompactDecimalFormat; +import android.icu.text.DecimalFormat; +import android.icu.text.DecimalFormatSymbols; +import android.icu.text.NumberFormat; import android.os.Build; import android.text.Spannable; import android.text.SpannableString; @@ -25,17 +28,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import java.text.NumberFormat; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.concurrent.*; import app.revanced.integrations.shared.Logger; import app.revanced.integrations.shared.Utils; @@ -223,32 +220,29 @@ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, // Note: Some locales use right to left layout (Arabic, Hebrew, etc). // If making changes to this code, change device settings to a RTL language and verify layout is correct. - String oldLikesString = oldSpannable.toString(); + CharSequence oldLikes = oldSpannable; // YouTube creators can hide the like count on a video, // and the like count appears as a device language specific string that says 'Like'. // Check if the string contains any numbers. - if (!stringContainsNumber(oldLikesString)) { - // Likes are hidden. - // RYD does not provide usable data for these types of videos, - // and the API returns bogus data (zero likes and zero dislikes) - // discussion about this: https://github.com/Anarios/return-youtube-dislike/discussions/530 + if (!Utils.containsNumber(oldLikes)) { + // Likes are hidden by video creator + // + // RYD does not directly provide like data, but can use an estimated likes + // using the same scale factor RYD applied to the raw dislikes. // // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw // - // Change the "Likes" string to show that likes and dislikes are hidden. - String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner"); - return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString); + Logger.printDebug(() -> "Using estimated likes"); + oldLikes = formatDislikeCount(voteData.getLikeCount()); } SpannableStringBuilder builder = new SpannableStringBuilder(); final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get(); if (!compactLayout) { - String leftSeparatorString = Utils.isRightToLeftTextLayout() - ? "\u200F" // u200F = right to left character - : "\u200E"; // u200E = left to right character + String leftSeparatorString = getTextDirectionString(); final Spannable leftSeparatorSpan; if (isRollingNumber) { leftSeparatorSpan = new SpannableString(leftSeparatorString); @@ -267,7 +261,7 @@ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, } // likes - builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikesString)); + builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes)); // middle separator String middleSeparatorString = compactLayout @@ -292,6 +286,12 @@ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, return new SpannableString(builder); } + private static @NonNull String getTextDirectionString() { + return Utils.isRightToLeftTextLayout() + ? "\u200F" // u200F = right to left character + : "\u200E"; // u200E = left to right character + } + /** * @return If the text is likely for a previously created likes/dislikes segmented span. */ @@ -299,20 +299,6 @@ public static boolean isPreviouslyCreatedSegmentedSpan(@NonNull String text) { return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0; } - /** - * Correctly handles any unicode numbers (such as Arabic numbers). - * - * @return if the string contains at least 1 number. - */ - private static boolean stringContainsNumber(@NonNull String text) { - for (int index = 0, length = text.length(); index < length; index++) { - if (Character.isDigit(text.codePointAt(index))) { - return true; - } - } - return false; - } - private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) { // Cannot use equals on the span, because many of the inner styling spans do not implement equals. // Instead, compare the underlying text and the text color to handle when dark mode is changed. @@ -334,6 +320,10 @@ private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull return true; } + private static SpannableString newSpannableWithLikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) { + return newSpanUsingStylingOfAnotherSpan(sourceStyling, formatDislikeCount(voteData.getLikeCount())); + } + private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) { return newSpanUsingStylingOfAnotherSpan(sourceStyling, Settings.RYD_DISLIKE_PERCENTAGE.get() @@ -342,11 +332,16 @@ private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceS } private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) { + if (sourceStyle == newSpanText && sourceStyle instanceof SpannableString) { + return (SpannableString) sourceStyle; // Nothing to do. + } + SpannableString destination = new SpannableString(newSpanText); Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class); for (Object span : spans) { destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span)); } + return destination; } @@ -354,13 +349,18 @@ private static String formatDislikeCount(long dislikeCount) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize if (dislikeCountFormatter == null) { - // Note: Java number formatters will use the locale specific number characters. - // such as Arabic which formats "1.234" into "۱,۲۳٤" - // But YouTube disregards locale specific number characters - // and instead shows english number characters everywhere. Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; - Logger.printDebug(() -> "Locale: " + locale); dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); + + // YouTube disregards locale specific number characters + // and instead shows english number characters everywhere. + // To use the same behavior, override the digit characters to use English + // so languages such as Arabic will show "1.234" instead of the native "۱,۲۳٤" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale); + symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings()); + dislikeCountFormatter.setDecimalFormatSymbols(symbols); + } } return dislikeCountFormatter.format(dislikeCount); } @@ -371,19 +371,31 @@ private static String formatDislikeCount(long dislikeCount) { } private static String formatDislikePercentage(float dislikePercentage) { - synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize - if (dislikePercentageFormatter == null) { - Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; - Logger.printDebug(() -> "Locale: " + locale); - dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); - } - if (dislikePercentage >= 0.01) { // at least 1% - dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points - } else { - dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikePercentageFormatter == null) { + Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; + dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); + + // Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + && dislikePercentageFormatter instanceof DecimalFormat) { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale); + symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings()); + ((DecimalFormat) dislikePercentageFormatter).setDecimalFormatSymbols(symbols); + } + } + if (dislikePercentage >= 0.01) { // at least 1% + dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points + } else { + dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision + } + return dislikePercentageFormatter.format(dislikePercentage); } - return dislikePercentageFormatter.format(dislikePercentage); } + + // Will never be reached, as the oldest supported YouTube app requires Android N or greater. + return String.valueOf((int) (dislikePercentage * 100)); } @NonNull @@ -484,7 +496,17 @@ public synchronized void setVideoIdIsShort(boolean isShort) { public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, boolean isSegmentedButton, boolean isRollingNumber) { - return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, isRollingNumber,false); + return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, + isRollingNumber, false, false); + } + + /** + * Called when a Shorts like Spannable is created. + */ + @NonNull + public synchronized Spanned getLikeSpanForShort(@NonNull Spanned original) { + return waitForFetchAndUpdateReplacementSpan(original, false, + false, true, true); } /** @@ -492,14 +514,16 @@ public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned orig */ @NonNull public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) { - return waitForFetchAndUpdateReplacementSpan(original, false, false, true); + return waitForFetchAndUpdateReplacementSpan(original, false, + false, true, false); } @NonNull private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, boolean isSegmentedButton, boolean isRollingNumber, - boolean spanIsForShort) { + boolean spanIsForShort, + boolean spanIsForLikes) { try { RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); if (votingData == null) { @@ -526,24 +550,17 @@ private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, return original; } - if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) { - if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) { - Logger.printDebug(() -> "Ignoring previously created dislikes span of data: " + videoId); - return original; - } - if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) { - Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId); - return replacementLikeDislikeSpan; - } + if (spanIsForLikes) { + // Scrolling Shorts does not cause the Spans to be reloaded, + // so there is no need to cache the likes for this situations. + Logger.printDebug(() -> "Creating likes span for: " + votingData.videoId); + return newSpannableWithLikes(original, votingData); } - if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original.toString())) { - // need to recreate using original, as original has prior outdated dislike values - if (originalDislikeSpan == null) { - // Should never happen. - Logger.printDebug(() -> "Cannot add dislikes - original span is null. videoId: " + videoId); - return original; - } - original = originalDislikeSpan; + + if (originalDislikeSpan != null && replacementLikeDislikeSpan != null + && spansHaveEqualTextAndColor(original, originalDislikeSpan)) { + Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId); + return replacementLikeDislikeSpan; } // No replacement span exist, create it now. @@ -558,9 +575,10 @@ private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, return replacementLikeDislikeSpan; } - } catch (Exception e) { - Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen + } catch (Exception ex) { + Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", ex); } + return original; } diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java index 820c0492f3..239ad2b095 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java @@ -3,10 +3,13 @@ import static app.revanced.integrations.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.json.JSONException; import org.json.JSONObject; +import app.revanced.integrations.shared.Logger; + /** * ReturnYouTubeDislike API estimated like/dislike/view counts. * @@ -23,38 +26,65 @@ public final class RYDVoteData { public final long viewCount; private final long fetchedLikeCount; - private volatile long likeCount; // read/write from different threads + private volatile long likeCount; // Read/write from different threads. + /** + * Like count can be hidden by video creator, but RYD still tracks the number + * of like/dislikes it received thru it's browser extension and and API. + * The raw like/dislikes can be used to calculate a percentage. + * + * Raw values can be null, especially for older videos with little to no views. + */ + @Nullable + private final Long fetchedRawLikeCount; private volatile float likePercentage; private final long fetchedDislikeCount; - private volatile long dislikeCount; // read/write from different threads + private volatile long dislikeCount; // Read/write from different threads. + @Nullable + private final Long fetchedRawDislikeCount; private volatile float dislikePercentage; + @Nullable + private static Long getLongIfExist(JSONObject json, String key) throws JSONException { + return json.isNull(key) + ? null + : json.getLong(key); + } + /** * @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values) */ public RYDVoteData(@NonNull JSONObject json) throws JSONException { videoId = json.getString("id"); viewCount = json.getLong("viewCount"); + fetchedLikeCount = json.getLong("likes"); + fetchedRawLikeCount = getLongIfExist(json, "rawLikes"); + fetchedDislikeCount = json.getLong("dislikes"); + fetchedRawDislikeCount = getLongIfExist(json, "rawDislikes"); + if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) { throw new JSONException("Unexpected JSON values: " + json); } likeCount = fetchedLikeCount; dislikeCount = fetchedDislikeCount; - updatePercentages(); + + updateUsingVote(Vote.LIKE_REMOVE); // Calculate percentages. } /** - * Estimated like count + * Public like count of the video, as reported by YT when RYD last updated it's data. + * + * If the likes were hidden by the video creator, then this returns an + * estimated likes using the same extrapolation as the dislikes. */ public long getLikeCount() { return likeCount; } /** - * Estimated dislike count + * Estimated total dislike count, extrapolated from the public like count using RYD data. */ public long getDislikeCount() { return dislikeCount; @@ -79,28 +109,56 @@ public float getDislikePercentage() { } public void updateUsingVote(Vote vote) { + final int likesToAdd, dislikesToAdd; + switch (vote) { case LIKE: - likeCount = fetchedLikeCount + 1; - dislikeCount = fetchedDislikeCount; + likesToAdd = 1; + dislikesToAdd = 0; break; case DISLIKE: - likeCount = fetchedLikeCount; - dislikeCount = fetchedDislikeCount + 1; + likesToAdd = 0; + dislikesToAdd = 1; break; case LIKE_REMOVE: - likeCount = fetchedLikeCount; - dislikeCount = fetchedDislikeCount; + likesToAdd = 0; + dislikesToAdd = 0; break; default: throw new IllegalStateException(); } - updatePercentages(); - } - private void updatePercentages() { - likePercentage = (likeCount == 0 ? 0 : (float) likeCount / (likeCount + dislikeCount)); - dislikePercentage = (dislikeCount == 0 ? 0 : (float) dislikeCount / (likeCount + dislikeCount)); + // If a video has no public likes but RYD has raw like data, + // then use the raw data instead. + final boolean videoHasNoPublicLikes = fetchedLikeCount == 0; + final boolean hasRawData = fetchedRawLikeCount != null && fetchedRawDislikeCount != null; + + if (videoHasNoPublicLikes && hasRawData && fetchedRawDislikeCount > 0) { + // YT creator has hidden the likes count, and this is an older video that + // RYD does not provide estimated like counts. + // + // But we can calculate the public likes the same way RYD does for newer videos with hidden likes, + // by using the same raw to estimated scale factor applied to dislikes. + // This calculation exactly matches the public likes RYD provides for newer hidden videos. + final float estimatedRawScaleFactor = (float) fetchedDislikeCount / fetchedRawDislikeCount; + likeCount = (long) (estimatedRawScaleFactor * fetchedRawLikeCount) + likesToAdd; + Logger.printDebug(() -> "Using locally calculated estimated likes since RYD did not return an estimate"); + } else { + likeCount = fetchedLikeCount + likesToAdd; + } + // RYD now always returns an estimated dislike count, even if the likes are hidden. + dislikeCount = fetchedDislikeCount + dislikesToAdd; + + // Update percentages. + + final float totalCount = likeCount + dislikeCount; + if (totalCount == 0) { + likePercentage = 0; + dislikePercentage = 0; + } else { + likePercentage = likeCount / totalCount; + dislikePercentage = dislikeCount / totalCount; + } } @NonNull diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java index bc729e4794..cb211ea501 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java @@ -197,7 +197,7 @@ private static boolean checkIfRateLimitWasHit(int httpResponseCode) { return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT; } - @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are estimates. + @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are only estimates. private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) { if (connectionError && rateLimitHit) { throw new IllegalArgumentException(); @@ -368,10 +368,12 @@ private static String confirmRegistration(String userId, String solution) { applyCommonPostRequestSettings(connection); String jsonInputString = "{\"solution\": \"" + solution + "\"}"; + byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); try (OutputStream os = connection.getOutputStream()) { - byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + os.write(body); } + final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); // disconnect, as no more connections will be made for a little while @@ -440,9 +442,10 @@ public static boolean sendVote(String videoId, ReturnYouTubeDislike.Vote vote) { applyCommonPostRequestSettings(connection); String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}"; + byte[] body = voteJsonString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); try (OutputStream os = connection.getOutputStream()) { - byte[] input = voteJsonString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + os.write(body); } final int responseCode = connection.getResponseCode(); @@ -490,10 +493,12 @@ private static boolean confirmVote(String videoId, String userId, String solutio applyCommonPostRequestSettings(connection); String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}"; + byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); try (OutputStream os = connection.getOutputStream()) { - byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + os.write(body); } + final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); // disconnect, as no more connections will be made for a little while