diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e25974..cd5adccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [0.89.1-dev.1](https://github.com/revanced/revanced-integrations/compare/v0.89.0...v0.89.1-dev.1) (2022-12-30) + + +### Performance Improvements + +* **youtube/general-ads-patch:** reduce list of ignored component names ([#261](https://github.com/revanced/revanced-integrations/issues/261)) ([8d233a2](https://github.com/revanced/revanced-integrations/commit/8d233a2f82f49b93adb90e2b11c9fa46836dd9b0)) + # [0.89.0](https://github.com/revanced/revanced-integrations/compare/v0.88.0...v0.89.0) (2022-12-30) diff --git a/app/src/main/java/app/revanced/integrations/patches/GeneralAdsPatch.java b/app/src/main/java/app/revanced/integrations/patches/GeneralAdsPatch.java index 0ead2cd8..d0c5379e 100644 --- a/app/src/main/java/app/revanced/integrations/patches/GeneralAdsPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/GeneralAdsPatch.java @@ -13,9 +13,7 @@ public final class GeneralAdsPatch extends Filter { "related_video_with_context", "comment_thread", // skip blocking anything in the comments "|comment.", // skip blocking anything in the comments replies - "download_", "library_recent_shelf", - "playlist_add_to_option_wrapper" // do not block on "add to playlist" flyout menu }; private final BlockRule custom = new CustomBlockRule( 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 d2276b07..1de57b7a 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java @@ -1,15 +1,24 @@ package app.revanced.integrations.returnyoutubedislike; +import static app.revanced.integrations.sponsorblock.StringRef.str; + import android.content.Context; import android.icu.text.CompactDecimalFormat; -import android.icu.text.DecimalFormat; -import android.icu.text.DecimalFormatSymbols; import android.os.Build; +import android.text.Spannable; import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.TextPaint; +import android.text.style.CharacterStyle; +import android.text.style.ForegroundColorSpan; +import android.text.style.MetricAffectingSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.ScaleXSpan; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; +import java.text.NumberFormat; import java.util.Locale; import java.util.Objects; import java.util.concurrent.ExecutorService; @@ -25,15 +34,16 @@ import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.utils.LogHelper; import app.revanced.integrations.utils.ReVancedUtils; import app.revanced.integrations.utils.SharedPrefHelper; +import app.revanced.integrations.utils.ThemeHelper; public class ReturnYouTubeDislike { /** - * Maximum amount of time to block the UI from updates while waiting for dislike network call to complete. - * + * 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 MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE = 4000; + private static final long MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_FETCH_VOTES_TO_COMPLETE = 4000; /** * Used to send votes, one by one, in the same order the user created them @@ -82,8 +92,8 @@ public class ReturnYouTubeDislike { /** * Used to format like/dislike count. */ - @GuardedBy("ReturnYouTubeDislike.class") // not thread safe - private static DecimalFormat dislikePercentageFormatter; + @GuardedBy("ReturnYouTubeDislike.class") + private static NumberFormat dislikePercentageFormatter; public static void onEnabledChange(boolean enabled) { isEnabled = enabled; @@ -111,7 +121,7 @@ public class ReturnYouTubeDislike { synchronized (videoIdLockObject) { currentVideoId = videoId; - // no need to wrap the fetchDislike call in a try/catch, + // no need to wrap the call in a try/catch, // as any exceptions are propagated out in the later Future#Get call voteFetchFuture = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId)); } @@ -120,18 +130,24 @@ public class ReturnYouTubeDislike { } } - // BEWARE! This method is sometimes called on the main thread, but it usually is called _off_ the main thread! + /** + * This method is sometimes called on the main thread, but it usually is called _off_ the main thread. + *

+ * This method can be called multiple times for the same UI element (including after dislikes was added) + * This code should avoid needlessly replacing the same UI element with identical versions. + */ public static void onComponentCreated(Object conversionContext, AtomicReference textRef) { if (!isEnabled) return; try { - var conversionContextString = conversionContext.toString(); + String conversionContextString = conversionContext.toString(); - boolean isSegmentedButton = false; - // Check for new component + final boolean isSegmentedButton; if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) { isSegmentedButton = true; - } else if (!conversionContextString.contains("|dislike_button.eml|")) { + } else if (conversionContextString.contains("|dislike_button.eml|")) { + isSegmentedButton = false; + } else { return; } @@ -144,22 +160,26 @@ public class ReturnYouTubeDislike { if (SettingsEnum.DEBUG.getBoolean() && !fetchFuture.isDone()) { fetchStartTime = System.currentTimeMillis(); } - votingData = fetchFuture.get(MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE, TimeUnit.MILLISECONDS); + votingData = fetchFuture.get(MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_FETCH_VOTES_TO_COMPLETE, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { - LogHelper.printDebug(() -> "UI timed out waiting for dislike fetch to complete"); + LogHelper.printDebug(() -> "UI timed out waiting for fetch votes to complete"); return; } finally { recordTimeUISpentWaitingForNetworkCall(fetchStartTime); } if (votingData == null) { - LogHelper.printDebug(() -> "Cannot add dislike count to UI (RYD data not available)"); + LogHelper.printDebug(() -> "Cannot add dislike to UI (RYD data not available)"); return; } - updateDislike(textRef, isSegmentedButton, votingData); - LogHelper.printDebug(() -> "Updated text"); + if (updateDislike(textRef, isSegmentedButton, votingData)) { + LogHelper.printDebug(() -> "Updated dislike span to: " + textRef.get()); + } else { + LogHelper.printDebug(() -> "Ignoring dislike span: " + textRef.get() + + " that appears to already show voting data: " + votingData); + } } catch (Exception ex) { - LogHelper.printException(() -> "Error while trying to update dislikes text", ex); + LogHelper.printException(() -> "Error while trying to update dislikes", ex); } } @@ -192,10 +212,10 @@ public class ReturnYouTubeDislike { } /** - * Must call off main thread, as this will make a network call if user has not yet been registered + * 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 will return NULL + * and the network call fails, this returns NULL */ @Nullable private static String getUserId() { @@ -213,20 +233,38 @@ public class ReturnYouTubeDislike { return userId; } - private static void updateDislike(AtomicReference textRef, boolean isSegmentedButton, RYDVoteData voteData) { - SpannableString oldSpannableString = (SpannableString) textRef.get(); - String newDislikeString = SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getBoolean() - ? formatDislikePercentage(voteData.dislikePercentage) - : formatDislikeCount(voteData.dislikeCount); + /** + * @param isSegmentedButton if UI is using the segmented single UI component for both like and dislike + * @return false, if the text reference already has dislike information and no changes were made. + */ + private static boolean updateDislike(AtomicReference textRef, boolean isSegmentedButton, RYDVoteData voteData) { + Spannable oldSpannable = (Spannable) textRef.get(); + String oldLikesString = oldSpannable.toString(); + Spannable replacementSpannable; - if (isSegmentedButton) { // both likes and dislikes are on a custom segmented button - // parse out the like count as a string - String oldLikesString = oldSpannableString.toString().split(" \\| ")[0]; + // note: some locales use right to left layout (arabic, hebrew, etc), + // and care must be taken to retain the existing RTL encoding character on the likes string + // otherwise text will incorrectly show as left to right + // if making changes to this code, change device settings to a RTL language and verify layout is correct + + if (!isSegmentedButton) { + // simple replacement of 'dislike' with a number/percentage + if (stringContainsNumber(oldLikesString)) { + // already is a number, and was modified in a previous call to this method + return false; + } + replacementSpannable = newSpannableWithDislikes(oldSpannable, voteData); + } else { + String leftSegmentedSeparatorString = ReVancedUtils.isRightToLeftTextLayout() ? "\u200F| " : "| "; + + if (oldLikesString.contains(leftSegmentedSeparatorString)) { + return false; // dislikes was previously added + } // 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 first character is not a number - if (!Character.isDigit(oldLikesString.charAt(0))) { + // 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) @@ -239,22 +277,126 @@ public class ReturnYouTubeDislike { // // Change the "Likes" string to show that likes and dislikes are hidden // - newDislikeString = "Hidden"; // for now, this is not localized LogHelper.printDebug(() -> "Like count is hidden by video creator. " + "RYD does not provide data for videos with hidden likes."); + + String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner"); + if (hiddenMessageString.equals(oldLikesString)) { + return false; + } + replacementSpannable = newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString); } else { - // temporary fix for https://github.com/revanced/revanced-integrations/issues/118 - newDislikeString = oldLikesString + " | " + newDislikeString; + Spannable likesSpan = newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikesString); + + // left and middle separator + String middleSegmentedSeparatorString = " • "; + Spannable leftSeparatorSpan = newSpanUsingStylingOfAnotherSpan(oldSpannable, leftSegmentedSeparatorString); + Spannable middleSeparatorSpan = newSpanUsingStylingOfAnotherSpan(oldSpannable, middleSegmentedSeparatorString); + // style the separator appearance to mimic the existing layout + final int separatorColor = ThemeHelper.isDarkTheme() + ? 0xFF414141 // dark gray + : 0xFFD9D9D9; // light gray + addSpanStyling(leftSeparatorSpan, new ForegroundColorSpan(separatorColor)); + addSpanStyling(middleSeparatorSpan, new ForegroundColorSpan(separatorColor)); + MetricAffectingSpan separatorStyle = new MetricAffectingSpan() { + final float separatorHorizontalCompression = 0.71f; // horizontally compress the separator and its spacing + + @Override + public void updateMeasureState(TextPaint tp) { + tp.setTextScaleX(separatorHorizontalCompression); + } + + @Override + public void updateDrawState(TextPaint tp) { + tp.setTextScaleX(separatorHorizontalCompression); + tp.setAntiAlias(false); + } + }; + addSpanStyling(leftSeparatorSpan, separatorStyle); + addSpanStyling(middleSeparatorSpan, separatorStyle); + + Spannable dislikeSpan = newSpannableWithDislikes(oldSpannable, voteData); + + // use a larger font size on the left separator, but this causes the entire span (including the like/dislike text) + // to move downward. Use a custom span to adjust the span back upward, at a relative ratio + class RelativeVerticalOffsetSpan extends CharacterStyle { + final float relativeVerticalShiftRatio; + + RelativeVerticalOffsetSpan(float relativeVerticalShiftRatio) { + this.relativeVerticalShiftRatio = relativeVerticalShiftRatio; + } + + @Override + public void updateDrawState(TextPaint tp) { + tp.baselineShift -= (int) (relativeVerticalShiftRatio * tp.getFontMetrics().top); + } + } + + // Ratio values tested on Android 13, Samsung, Google and OnePlus branded phones, using screen densities of 300 to 560 + // On other devices and fonts the left separator may be vertically shifted by a few pixels, + // but it's good enough and still visually better than not doing this scaling/shifting + final float verticalShiftRatio = -0.38f; // shift up by 38% + final float verticalLeftSeparatorShiftRatio = -0.075f; // shift up by 8% + final float horizontalStretchRatio = 0.92f; // stretch narrower by 8% + final float leftSeparatorFontRatio = 1.87f; // increase height by 87% + + addSpanStyling(leftSeparatorSpan, new RelativeSizeSpan(leftSeparatorFontRatio)); + addSpanStyling(leftSeparatorSpan, new ScaleXSpan(horizontalStretchRatio)); + + // shift the left separator up by a smaller amount, to visually align it after changing the size + addSpanStyling(leftSeparatorSpan, new RelativeVerticalOffsetSpan(verticalLeftSeparatorShiftRatio)); + addSpanStyling(likesSpan, new RelativeVerticalOffsetSpan(verticalShiftRatio)); + addSpanStyling(middleSeparatorSpan, new RelativeVerticalOffsetSpan(verticalShiftRatio)); + addSpanStyling(dislikeSpan, new RelativeVerticalOffsetSpan(verticalShiftRatio)); + + // middle separator does not need resizing + + // put everything together + SpannableStringBuilder builder = new SpannableStringBuilder(); + builder.append(leftSeparatorSpan); + builder.append(likesSpan); + builder.append(middleSeparatorSpan); + builder.append(dislikeSpan); + replacementSpannable = new SpannableString(builder); } } - SpannableString newSpannableString = new SpannableString(newDislikeString); - // Copy style (foreground color, etc) to new string - Object[] spans = oldSpannableString.getSpans(0, oldSpannableString.length(), Object.class); - for (Object span : spans) { - newSpannableString.setSpan(span, 0, newDislikeString.length(), oldSpannableString.getSpanFlags(span)); + textRef.set(replacementSpannable); + return true; + } + + /** + * Correctly handles any unicode numbers (such as Arabic numbers) + * + * @return if the string contains at least 1 number + */ + private static boolean stringContainsNumber(String text) { + for (int index = 0, length = text.length(); index < length; index++) { + if (Character.isDigit(text.codePointAt(index))) { + return true; + } } - textRef.set(newSpannableString); + return false; + } + + private static void addSpanStyling(Spannable destination, Object styling) { + destination.setSpan(styling, 0, destination.length(), 0); + } + + private static Spannable newSpannableWithDislikes(Spannable sourceStyling, RYDVoteData voteData) { + return newSpanUsingStylingOfAnotherSpan(sourceStyling, + SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getBoolean() + ? formatDislikePercentage(voteData.dislikePercentage) + : formatDislikeCount(voteData.dislikeCount)); + } + + private static Spannable newSpanUsingStylingOfAnotherSpan(Spannable sourceStyle, String newSpanText) { + 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; } private static String formatDislikeCount(long dislikeCount) { @@ -262,6 +404,10 @@ public class ReturnYouTubeDislike { String formatted; 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.2" into "١٫٢" + // But YouTube disregards locale specific number characters + // and instead shows english number characters everywhere. Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale; LogHelper.printDebug(() -> "Locale: " + locale); dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); @@ -277,28 +423,22 @@ public class ReturnYouTubeDislike { } private static String formatDislikePercentage(float dislikePercentage) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - String formatted; - synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize - if (dislikePercentageFormatter == null) { - Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale; - LogHelper.printDebug(() -> "Locale: " + locale); - dislikePercentageFormatter = new DecimalFormat("", new DecimalFormatSymbols(locale)); - } - if (dislikePercentage == 0 || dislikePercentage >= 0.01) { // zero, or at least 1% - dislikePercentageFormatter.applyLocalizedPattern("0"); // show only whole percentage points - } else { // between (0, 1)% - dislikePercentageFormatter.applyLocalizedPattern("0.#"); // show 1 digit precision - } - final char percentChar = dislikePercentageFormatter.getDecimalFormatSymbols().getPercent(); - formatted = dislikePercentageFormatter.format(100 * dislikePercentage) + percentChar; + String formatted; + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikePercentageFormatter == null) { + Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale; + LogHelper.printDebug(() -> "Locale: " + locale); + dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); } - LogHelper.printDebug(() -> "Dislike percentage: " + dislikePercentage + " formatted as: " + formatted); - return formatted; + 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 + } + formatted = dislikePercentageFormatter.format(dislikePercentage); } - - // never will be reached, as the oldest supported YouTube app requires Android N or greater - return (int) (100 * dislikePercentage) + "%"; + LogHelper.printDebug(() -> "Dislike percentage: " + dislikePercentage + " formatted as: " + formatted); + return formatted; } @@ -324,6 +464,6 @@ public class ReturnYouTubeDislike { final long averageTimeForcedToWait = totalTimeUIWaitedOnNetworkCalls / numberOfTimesUIWaitedOnNetworkCalls; LogHelper.printDebug(() -> "UI thread forced to wait: " + numberOfTimesUIWaitedOnNetworkCalls + " times, " + "total wait time: " + totalTimeUIWaitedOnNetworkCalls + "ms, " - + "average wait time: " + averageTimeForcedToWait + "ms") ; + + "average wait time: " + averageTimeForcedToWait + "ms"); } } 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 73a5711c..f0850c1a 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 @@ -97,7 +97,7 @@ public class ReturnYouTubeDislikeApi { private static volatile long fetchCallResponseTimeMin; private static volatile long fetchCallResponseTimeMax; - public static final int FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT = -2; + public static final int FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT = -1; /** * If rate limit was hit, this returns {@link #FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT} @@ -128,8 +128,8 @@ public class ReturnYouTubeDislikeApi { } // utility class /** - * Only to simulate a slow api call, for debugging the app UI with slow url calls. * Simulates a slow response by doing meaningless calculations. + * Used to debug the app UI and verify UI timeout logic works * * @param maximumTimeToWait maximum time to wait */ 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 4dd98bbc..6799c3f3 100644 --- a/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java +++ b/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java @@ -6,6 +6,8 @@ import android.content.res.Resources; import android.os.Handler; import android.os.Looper; +import java.text.Bidi; +import java.util.Locale; import java.util.concurrent.Callable; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; @@ -125,6 +127,15 @@ public class ReVancedUtils { return context.getResources().getConfiguration().smallestScreenWidthDp >= 600; } + private static final boolean isRightToLeftTextLayout = + new Bidi(Locale.getDefault().getDisplayLanguage(), Bidi.DIRECTION_DEFAULT_RIGHT_TO_LEFT).isRightToLeft(); + /** + * If the device language uses right to left text layout (hebrew, arabic, etc) + */ + public static boolean isRightToLeftTextLayout() { + return isRightToLeftTextLayout; + } + /** * Automatically logs any exceptions the runnable throws */ diff --git a/gradle.properties b/gradle.properties index e06a62d8..2507c510 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ org.gradle.jvmargs = -Xmx2048m android.useAndroidX = true -version = 0.89.0 +version = 0.89.1-dev.1