diff --git a/app/src/main/java/app/revanced/integrations/patches/HideBreakingNewsPatch.java b/app/src/main/java/app/revanced/integrations/patches/HideBreakingNewsPatch.java index 465c28367d..bb2d76b2f9 100644 --- a/app/src/main/java/app/revanced/integrations/patches/HideBreakingNewsPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/HideBreakingNewsPatch.java @@ -2,28 +2,27 @@ import android.view.View; +import app.revanced.integrations.patches.spoof.SpoofAppVersionPatch; import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.utils.ReVancedUtils; public class HideBreakingNewsPatch { /** - * When spoofing to app versions older than 17.30.35, the watch history preview bar uses + * When spoofing to app versions 17.31.00 and older, the watch history preview bar uses * the same layout components as the breaking news shelf. * * Breaking news does not appear to be present in these older versions anyways. */ - private static boolean isSpoofingOldVersionWithHorizontalCardListWatchHistory() { - return SettingsEnum.SPOOF_APP_VERSION.getBoolean() - && SettingsEnum.SPOOF_APP_VERSION_TARGET.getString().compareTo("17.30.35") < 0; - } + private static final boolean isSpoofingOldVersionWithHorizontalCardListWatchHistory = + SpoofAppVersionPatch.isSpoofingToEqualOrLessThan("17.31.00"); /** * Injection point. */ public static void hideBreakingNews(View view) { if (!SettingsEnum.HIDE_BREAKING_NEWS.getBoolean() - || isSpoofingOldVersionWithHorizontalCardListWatchHistory()) return; + || isSpoofingOldVersionWithHorizontalCardListWatchHistory) return; ReVancedUtils.hideViewByLayoutParams(view); } } 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 cd5ef761bf..c4e395728a 100644 --- a/app/src/main/java/app/revanced/integrations/patches/ReturnYouTubeDislikePatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/ReturnYouTubeDislikePatch.java @@ -1,19 +1,20 @@ package app.revanced.integrations.patches; -import static app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike.Vote; - import android.graphics.Rect; +import android.graphics.drawable.ShapeDrawable; import android.os.Build; -import android.text.Editable; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.TextWatcher; +import android.text.*; +import android.view.Gravity; 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.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.integrations.settings.SettingsEnum; +import app.revanced.integrations.shared.PlayerType; +import app.revanced.integrations.utils.LogHelper; +import app.revanced.integrations.utils.ReVancedUtils; import java.lang.ref.WeakReference; import java.util.ArrayList; @@ -21,12 +22,7 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; -import app.revanced.integrations.patches.components.ReturnYouTubeDislikeFilterPatch; -import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike; -import app.revanced.integrations.settings.SettingsEnum; -import app.revanced.integrations.shared.PlayerType; -import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.utils.ReVancedUtils; +import static app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike.Vote; /** * Handles all interaction of UI patch components. @@ -35,13 +31,14 @@ * Litho based Shorts player can experience temporarily frozen video playback if the RYD fetch takes too long. * * Temporary work around: - * Enable app spoofing to version 18.20.39 or older, as that uses a non litho Shorts player. + * Enable app spoofing to version 18.33.40 or older, as that uses a non litho Shorts player. * * Permanent fix (yet to be implemented), either of: * - Modify patch to hook onto the Shorts Litho TextView, and update the dislikes asynchronously. * - Find a way to force Litho to rebuild it's component tree * (and use that hook to force the shorts dislikes to update after the fetch is completed). */ +@SuppressWarnings("unused") public class ReturnYouTubeDislikePatch { /** @@ -75,12 +72,18 @@ 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; + clearData(); } } + private static void clearData() { + currentVideoData = null; + lastLithoShortsVideoData = null; + lithoShortsShouldUseCurrentData = false; + // Rolling number text should not be cleared, + // as it's used if incognito Short is opened/closed + // while a regular video is on screen. + } // // 17.x non litho regular video player. @@ -137,7 +140,7 @@ private static void updateOldUIDislikesTextView() { if (oldUITextView == null) { return; } - oldUIReplacementSpan = videoData.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false); + oldUIReplacementSpan = videoData.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false, false); if (!oldUIReplacementSpan.equals(oldUITextView.getText())) { oldUITextView.setText(oldUIReplacementSpan); } @@ -188,55 +191,70 @@ public static void setOldUILayoutDislikes(int buttonViewResourceId, @Nullable Te /** * Injection point. * + * For Litho segmented buttons and Litho Shorts player. + */ + @NonNull + public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, + @Nullable AtomicReference textRef, + @NonNull CharSequence original) { + return onLithoTextLoaded(conversionContext, textRef, original, false); + } + + /** * Called when a litho text component is initially created, * and also when a Span is later reused again (such as scrolling off/on screen). * * This method is sometimes called on the main thread, but it usually is called _off_ the main thread. * This method can be called multiple times for the same UI element (including after dislikes was added). * - * @param textRef Cache reference to the like/dislike char sequence, + * @param textRef Optional cache reference to the like/dislike char sequence, * which may or may not be the same as the original span parameter. * If dislikes are added, the atomic reference must be set to the replacement span. - * @param original Original span that was created or reused by Litho. - * @return The original span (if nothing should change), or a replacement span that contains dislikes. + * @param original Original char sequence was created or reused by Litho. + * @param isRollingNumber If the span is for a Rolling Number. + * @return The original char sequence (if nothing should change), or a replacement char sequence that contains dislikes. */ @NonNull - public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, - @NonNull AtomicReference textRef, - @NonNull CharSequence original) { + private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, + @Nullable AtomicReference textRef, + @NonNull CharSequence original, + boolean isRollingNumber) { try { 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 Spanned replacement; + final CharSequence replacement; if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) { - // Regular video + // 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|")) { + if (!(original instanceof Spanned)) { + original = new SpannableString(original); + } + replacement = videoData.getDislikesSpanForRegularVideo((Spanned) original, + true, isRollingNumber); + + // When spoofing between 17.09.xx and 17.30.xx the UI is the old layout + // but uses litho and the dislikes is "|dislike_button.eml|". + // But spoofing to that range gives a broken UI layout so no point checking for that. + } else if (!isRollingNumber && conversionContextString.contains("|shorts_dislike_button.eml|")) { // Litho Shorts player. if (!SettingsEnum.RYD_SHORTS.getBoolean()) { // 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; + clearData(); return original; } ReturnYouTubeDislike videoData = lastLithoShortsVideoData; if (videoData == null) { // The Shorts litho video id filter did not detect the video id. - // This is normal if in incognito mode, but otherwise is not normal. + // This is normal in incognito mode, but otherwise is abnormal. LogHelper.printDebug(() -> "Cannot modify Shorts litho span, data is null"); return original; } @@ -250,12 +268,12 @@ public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, } LogHelper.printDebug(() -> "Using current video data for litho span"); } - replacement = videoData.getDislikeSpanForShort((Spannable) original); + replacement = videoData.getDislikeSpanForShort((Spanned) original); } else { return original; } - textRef.set(replacement); + if (textRef != null) textRef.set(replacement); return replacement; } catch (Exception ex) { LogHelper.printException(() -> "onLithoTextLoaded failure", ex); @@ -263,6 +281,123 @@ public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, return original; } + // + // Rolling Number + // + + /** + * Current regular video rolling number text, if rolling number is in use. + * This is saved to a field as it's used in every draw() call. + */ + @Nullable + private static volatile CharSequence rollingNumberSpan; + + /** + * Injection point. + */ + public static String onRollingNumberLoaded(@NonNull Object conversionContext, + @NonNull String original) { + try { + CharSequence replacement = onLithoTextLoaded(conversionContext, null, original, true); + if (!replacement.toString().equals(original)) { + rollingNumberSpan = replacement; + return replacement.toString(); + } // Else, the text was not a likes count but instead the view count or something else. + } catch (Exception ex) { + LogHelper.printException(() -> "onRollingNumberLoaded failure", ex); + } + return original; + } + + /** + * Remove Rolling Number text view modifications made by this patch. + * Required as it appears text views can be reused for other rolling numbers (view count, upload time, etc). + */ + private static void removeRollingNumberPatchChanges(TextView view) { + if (view.getCompoundDrawablePadding() != 0) { + LogHelper.printDebug(() -> "Removing rolling number styling from TextView"); + view.setCompoundDrawablePadding(0); + view.setCompoundDrawables(null, null, null, null); + view.setGravity(Gravity.NO_GRAVITY); + view.setTextAlignment(View.TEXT_ALIGNMENT_INHERIT); + view.setSingleLine(false); + } + } + + /** + * Add Rolling Number text view modifications. + */ + private static void addRollingNumberPatchChanges(TextView view) { + if (view.getCompoundDrawablePadding() == 0) { + LogHelper.printDebug(() -> "Adding rolling number styling to TextView"); + // YouTube Rolling Numbers do not use compound drawables or drawable padding. + // + // Single line mode prevents entire words from being entirely clipped, + // and instead only clips the portion of text that runs off. + // The text should not clip due to the empty end padding, + // but use the feature just in case. + view.setSingleLine(true); + // Center align to distribute the horizontal padding. + view.setGravity(Gravity.CENTER); + view.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); + ShapeDrawable shapeDrawable = ReturnYouTubeDislike.getLeftSeparatorDrawable(); + view.setCompoundDrawables(shapeDrawable, null, null, null); + view.setCompoundDrawablePadding(ReturnYouTubeDislike.leftSeparatorShapePaddingPixels); + } + } + + /** + * Injection point. + */ + public static CharSequence updateRollingNumber(TextView view, CharSequence original) { + try { + if (!SettingsEnum.RYD_ENABLED.getBoolean()) { + removeRollingNumberPatchChanges(view); + return original; + } + // Called for all instances of RollingNumber, so must check if text is for a dislikes. + // Text will already have the correct content but it's missing the drawable separators. + if (!ReturnYouTubeDislike.isPreviouslyCreatedSegmentedSpan(original.toString())) { + // The text is the video view count, upload time, or some other text. + removeRollingNumberPatchChanges(view); + return original; + } + + CharSequence replacement = rollingNumberSpan; + if (replacement == null) { + // User enabled RYD while a video was open, + // or user opened/closed a Short while a regular video was opened. + LogHelper.printDebug(() -> "Cannot update rolling number (field is null"); + removeRollingNumberPatchChanges(view); + return original; + } + + // TextView does not display the tall left separator correctly, + // as it goes outside the height bounds and messes up the layout. + // Fix this by applying the left separator as a text view compound drawable. + // This creates a new issue as the compound drawable is not taken into the + // layout width sizing, but that is fixed in the span itself where it uses a blank + // padding string that adds to the layout width but is later ignored during UI drawing. + if (SettingsEnum.RYD_COMPACT_LAYOUT.getBoolean()) { + // Do not apply any TextView changes, and text should always fit without clipping. + removeRollingNumberPatchChanges(view); + } else { + addRollingNumberPatchChanges(view); + } + + // Remove any padding set by Rolling Number. + view.setPadding(0, 0, 0, 0); + + // When displaying dislikes, the rolling animation is not visually correct + // and the dislikes always animate (even though the dislike count has not changed). + // The animation is caused by an image span attached to the span, + // and using only the modified segmented span prevents the animation from showing. + return replacement; + } catch (Exception ex) { + LogHelper.printException(() -> "updateRollingNumber failure", ex); + return original; + } + } // // Non litho Shorts player. @@ -301,7 +436,7 @@ public static boolean setShortsDislikes(@NonNull View likeDislikeView) { if (!SettingsEnum.RYD_SHORTS.getBoolean()) { // Must clear the data here, in case a new video was loaded while PlayerType // suggested the video was not a short (can happen when spoofing to an old app version). - currentVideoData = null; + clearData(); return false; } LogHelper.printDebug(() -> "setShortsDislikes"); @@ -405,90 +540,59 @@ private static boolean isShortTextViewOnScreen(@NonNull View view) { * Injection point. Uses 'playback response' video id hook to preload RYD. */ public static void preloadVideoId(@NonNull String videoId, boolean videoIsOpeningOrPlaying) { - // Shorts shelf in home and subscription feed causes player response hook to be called, - // and the 'is opening/playing' parameter will be false. - // This hook will be called again when the Short is actually opened. - if (!videoIsOpeningOrPlaying || !SettingsEnum.RYD_ENABLED.getBoolean()) { - return; - } - if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneHiddenOrSlidingMinimized()) { - return; - } - if (videoId.equals(lastPrefetchedVideoId)) { - return; + try { + // Shorts shelf in home and subscription feed causes player response hook to be called, + // and the 'is opening/playing' parameter will be false. + // This hook will be called again when the Short is actually opened. + if (!videoIsOpeningOrPlaying || !SettingsEnum.RYD_ENABLED.getBoolean()) { + return; + } + if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneHiddenOrSlidingMinimized()) { + return; + } + if (videoId.equals(lastPrefetchedVideoId)) { + return; + } + lastPrefetchedVideoId = videoId; + LogHelper.printDebug(() -> "Prefetching RYD for video: " + videoId); + ReturnYouTubeDislike.getFetchForVideoId(videoId); + } catch (Exception ex) { + LogHelper.printException(() -> "preloadVideoId failure", ex); } - 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}. - * if true, then the video id can be null indicating the filter did - * not find any video id. - */ - public static void newVideoLoaded(@Nullable String videoId, boolean isShortsLithoVideoId) { try { if (!SettingsEnum.RYD_ENABLED.getBoolean()) return; + Objects.requireNonNull(videoId); PlayerType currentPlayerType = PlayerType.getCurrent(); final boolean isNoneHiddenOrSlidingMinimized = currentPlayerType.isNoneHiddenOrSlidingMinimized(); if (isNoneHiddenOrSlidingMinimized && !SettingsEnum.RYD_SHORTS.getBoolean()) { // Must clear here, otherwise the wrong data can be used for a minimized regular video. - currentVideoData = null; + clearData(); return; } - if (isShortsLithoVideoId) { - // Litho Shorts video. - if (videoIdIsSame(lastLithoShortsVideoData, videoId)) { - return; - } - if (videoId == null) { - // Litho filter did not detect the video id. App is in incognito mode, - // or the proto buffer structure was changed and the video id is no longer present. - // Must clear both currently playing and last litho data otherwise the - // next regular video may use the wrong data. - LogHelper.printDebug(() -> "Litho filter did not find any video ids"); - currentVideoData = null; - lastLithoShortsVideoData = null; - lithoShortsShouldUseCurrentData = false; - return; - } - ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); - videoData.setVideoIdIsShort(true); - lastLithoShortsVideoData = videoData; - lithoShortsShouldUseCurrentData = false; - } else { - Objects.requireNonNull(videoId); - // All other playback (including non-litho Shorts). - if (videoIdIsSame(currentVideoData, videoId)) { - return; - } - ReturnYouTubeDislike data = ReturnYouTubeDislike.getFetchForVideoId(videoId); - // Pre-emptively set the data to short status. - // Required to prevent Shorts data from being used on a minimized video in incognito mode. - if (isNoneHiddenOrSlidingMinimized) { - data.setVideoIdIsShort(true); - } - currentVideoData = data; + if (videoIdIsSame(currentVideoData, videoId)) { + return; } + LogHelper.printDebug(() -> "New video id: " + videoId + " playerType: " + currentPlayerType); - LogHelper.printDebug(() -> "New video id: " + videoId + " playerType: " + currentPlayerType - + " isShortsLithoHook: " + isShortsLithoVideoId); + ReturnYouTubeDislike data = ReturnYouTubeDislike.getFetchForVideoId(videoId); + // Pre-emptively set the data to short status. + // Required to prevent Shorts data from being used on a minimized video in incognito mode. + if (isNoneHiddenOrSlidingMinimized) { + data.setVideoIdIsShort(true); + } + currentVideoData = data; // Current video id hook can be called out of order with the non litho Shorts text view hook. // Must manually update again here. - if (!isShortsLithoVideoId && isNoneHiddenOrSlidingMinimized) { + if (isNoneHiddenOrSlidingMinimized) { updateOnScreenShortsTextViews(true); } } catch (Exception ex) { @@ -496,6 +600,26 @@ public static void newVideoLoaded(@Nullable String videoId, boolean isShortsLith } } + public static void setLastLithoShortsVideoId(@Nullable String videoId) { + if (videoIdIsSame(lastLithoShortsVideoData, videoId)) { + return; + } + if (videoId == null) { + // Litho filter did not detect the video id. App is in incognito mode, + // or the proto buffer structure was changed and the video id is no longer present. + // Must clear both currently playing and last litho data otherwise the + // next regular video may use the wrong data. + LogHelper.printDebug(() -> "Litho filter did not find any video ids"); + clearData(); + return; + } + LogHelper.printDebug(() -> "New litho Shorts video id: " + videoId); + ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); + videoData.setVideoIdIsShort(true); + lastLithoShortsVideoData = videoData; + lithoShortsShouldUseCurrentData = false; + } + private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, @Nullable String videoId) { return (fetch == null && videoId == null) || (fetch != null && fetch.getVideoId().equals(videoId)); diff --git a/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java b/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java index b1ff2d2e18..48c44f36ce 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/ReturnYouTubeDislikeFilterPatch.java @@ -93,7 +93,7 @@ public boolean isFiltered(@Nullable String identifier, String path, byte[] proto // Must pass a null id to correctly clear out the current video data. // Otherwise if a Short is opened in non-incognito, then incognito is enabled and another Short is opened, // the new incognito Short will show the old prior data. - ReturnYouTubeDislikePatch.newVideoLoaded(matchedVideoId, true); + ReturnYouTubeDislikePatch.setLastLithoShortsVideoId(matchedVideoId); } return false; diff --git a/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofAppVersionPatch.java b/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofAppVersionPatch.java index c43d603127..a861708fda 100644 --- a/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofAppVersionPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofAppVersionPatch.java @@ -4,10 +4,19 @@ public class SpoofAppVersionPatch { + private static final boolean SPOOF_APP_VERSION_ENABLED = SettingsEnum.SPOOF_APP_VERSION.getBoolean(); + private static final String SPOOF_APP_VERSION_TARGET = SettingsEnum.SPOOF_APP_VERSION_TARGET.getString(); + + /** + * Injection point + */ public static String getYouTubeVersionOverride(String version) { - if (SettingsEnum.SPOOF_APP_VERSION.getBoolean()) { - return SettingsEnum.SPOOF_APP_VERSION_TARGET.getString(); - } + if (SPOOF_APP_VERSION_ENABLED) return SPOOF_APP_VERSION_TARGET; return version; } + + public static boolean isSpoofingToEqualOrLessThan(String version) { + return SPOOF_APP_VERSION_ENABLED && SPOOF_APP_VERSION_TARGET.compareTo(version) <= 0; + } + } 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 9890708622..f1ed3d12e3 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java @@ -17,6 +17,7 @@ import android.text.Spanned; import android.text.style.ForegroundColorSpan; import android.text.style.ImageSpan; +import android.text.style.ReplacementSpan; import android.util.DisplayMetrics; import android.util.TypedValue; @@ -69,7 +70,7 @@ public enum Vote { * 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 = 4500; + private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000; /** * How long to retain successful RYD fetches. @@ -84,9 +85,9 @@ public enum Vote { /** * Unique placeholder character, used to detect if a segmented span already has dislikes added to it. - * Can be any almost any non-visible character. + * Must be something YouTube is unlikely to use, as it's searched for in all usage of Rolling Number. */ - private static final char MIDDLE_SEPARATOR_CHARACTER = '\u2009'; // 'narrow space' character + private static final char MIDDLE_SEPARATOR_CHARACTER = '◎'; // 'bullseye' /** * Cached lookup of all video ids. @@ -115,6 +116,12 @@ public enum Vote { private static final Rect leftSeparatorBounds; private static final Rect middleSeparatorBounds; + /** + * Left separator horizontal padding for Rolling Number layout. + */ + public static final int leftSeparatorShapePaddingPixels; + private static final ShapeDrawable leftSeparatorShape; + static { DisplayMetrics dp = Objects.requireNonNull(ReVancedUtils.getContext()).getResources().getDisplayMetrics(); @@ -124,6 +131,11 @@ public enum Vote { final int middleSeparatorSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp); middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize); + + leftSeparatorShapePaddingPixels = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10.0f, dp); + + leftSeparatorShape = new ShapeDrawable(new RectShape()); + leftSeparatorShape.setBounds(leftSeparatorBounds); } private final String videoId; @@ -167,19 +179,31 @@ public enum Vote { @GuardedBy("this") private SpannableString replacementLikeDislikeSpan; + private static int getSeparatorColor() { + return ThemeHelper.isDarkTheme() + ? 0x33FFFFFF // transparent dark gray + : 0xFFD9D9D9; // light gray + } + + public static ShapeDrawable getLeftSeparatorDrawable() { + leftSeparatorShape.getPaint().setColor(getSeparatorColor()); + return leftSeparatorShape; + } + /** * @param isSegmentedButton If UI is using the segmented single UI component for both like and dislike. */ @NonNull - private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, boolean isSegmentedButton, @NonNull RYDVoteData voteData) { + private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, + boolean isSegmentedButton, + boolean isRollingNumber, + @NonNull RYDVoteData voteData) { if (!isSegmentedButton) { // Simple replacement of 'dislike' with a number/percentage. return newSpannableWithDislikes(oldSpannable, voteData); } - // 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. + // 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(); @@ -202,21 +226,25 @@ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, SpannableStringBuilder builder = new SpannableStringBuilder(); final boolean compactLayout = SettingsEnum.RYD_COMPACT_LAYOUT.getBoolean(); - final int separatorColor = ThemeHelper.isDarkTheme() - ? 0x29AAAAAA // transparent dark gray - : 0xFFD9D9D9; // light gray if (!compactLayout) { - // left separator String leftSeparatorString = ReVancedUtils.isRightToLeftTextLayout() - ? "\u200F " // u200F = right to left character - : "\u200E "; // u200E = left to right character - Spannable leftSeparatorSpan = new SpannableString(leftSeparatorString); - ShapeDrawable shapeDrawable = new ShapeDrawable(new RectShape()); - shapeDrawable.getPaint().setColor(separatorColor); - shapeDrawable.setBounds(leftSeparatorBounds); - leftSeparatorSpan.setSpan(new VerticallyCenteredImageSpan(shapeDrawable), 1, 2, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE); // drawable cannot overwrite RTL or LTR character + ? "\u200F" // u200F = right to left character + : "\u200E"; // u200E = left to right character + final Spannable leftSeparatorSpan; + if (isRollingNumber) { + leftSeparatorSpan = new SpannableString(leftSeparatorString); + } else { + leftSeparatorString += " "; + leftSeparatorSpan = new SpannableString(leftSeparatorString); + // Styling spans cannot overwrite RTL or LTR character. + leftSeparatorSpan.setSpan( + new VerticallyCenteredImageSpan(getLeftSeparatorDrawable(), false), + 1, 2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + leftSeparatorSpan.setSpan( + new FixedWidthEmptySpan(leftSeparatorShapePaddingPixels), + 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } builder.append(leftSeparatorSpan); } @@ -230,21 +258,41 @@ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, final int shapeInsertionIndex = middleSeparatorString.length() / 2; Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString); ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape()); - shapeDrawable.getPaint().setColor(separatorColor); + shapeDrawable.getPaint().setColor(getSeparatorColor()); shapeDrawable.setBounds(middleSeparatorBounds); - middleSeparatorSpan.setSpan(new VerticallyCenteredImageSpan(shapeDrawable), shapeInsertionIndex, shapeInsertionIndex + 1, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + // Use original text width if using compact layout with Rolling Number, + // as there is no empty padding to allow any layout width differences. + middleSeparatorSpan.setSpan( + new VerticallyCenteredImageSpan(shapeDrawable, isRollingNumber && compactLayout), + shapeInsertionIndex, shapeInsertionIndex + 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); builder.append(middleSeparatorSpan); // dislikes builder.append(newSpannableWithDislikes(oldSpannable, voteData)); + // Add some padding for Rolling Number segmented span. + // Use an empty width span, as the layout uses the measured text width and not the + // actual span width. So adding padding and then removing it while drawing gives some + // extra wiggle room for the left separator drawable (which is not included in layout width). + if (isRollingNumber && !compactLayout) { + // To test this, set the device system font to the smallest available. + // If text clipping still occurs, then increase the number of padding spaces below. + // Any extra width will be padded around the like/dislike string + // as it's set to center text alignment. + Spannable rightPaddingString = new SpannableString(" "); + rightPaddingString.setSpan(new FixedWidthEmptySpan(0), 0, + rightPaddingString.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + builder.append(rightPaddingString); + } + 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; + /** + * @return If the text is likely for a previously created likes/dislikes segmented span. + */ + public static boolean isPreviouslyCreatedSegmentedSpan(@NonNull String text) { + return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0; } /** @@ -429,8 +477,10 @@ public synchronized void setVideoIdIsShort(boolean 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); + public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, + boolean isSegmentedButton, + boolean isRollingNumber) { + return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, isRollingNumber,false); } /** @@ -438,12 +488,13 @@ public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned orig */ @NonNull public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) { - return waitForFetchAndUpdateReplacementSpan(original, false, true); + return waitForFetchAndUpdateReplacementSpan(original, false, false, true); } @NonNull private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, boolean isSegmentedButton, + boolean isRollingNumber, boolean spanIsForShort) { try { RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); @@ -481,7 +532,7 @@ private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, return replacementLikeDislikeSpan; } } - if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original)) { + if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original.toString())) { // need to recreate using original, as original has prior outdated dislike values if (originalDislikeSpan == null) { // Should never happen. @@ -497,7 +548,7 @@ private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, votingData.updateUsingVote(userVote); } originalDislikeSpan = original; - replacementLikeDislikeSpan = createDislikeSpan(original, isSegmentedButton, votingData); + replacementLikeDislikeSpan = createDislikeSpan(original, isSegmentedButton, isRollingNumber, votingData); LogHelper.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '" + replacementLikeDislikeSpan + "'" + " using video: " + videoId); @@ -567,9 +618,44 @@ public void setUserVote(@NonNull Vote vote) { } } +/** + * Styles a Spannable with an empty fixed width. + */ +class FixedWidthEmptySpan extends ReplacementSpan { + final int fixedWidth; + /** + * @param fixedWith Fixed width in screen pixels. + */ + FixedWidthEmptySpan(int fixedWith) { + this.fixedWidth = fixedWith; + if (fixedWith < 0) throw new IllegalArgumentException(); + } + @Override + public int getSize(@NonNull Paint paint, @NonNull CharSequence text, + int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) { + return fixedWidth; + } + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, + float x, int top, int y, int bottom, @NonNull Paint paint) { + // Nothing to draw. + } +} + +/** + * Vertically centers a Spanned Drawable. + */ class VerticallyCenteredImageSpan extends ImageSpan { - public VerticallyCenteredImageSpan(Drawable drawable) { + final boolean useOriginalWidth; + + /** + * @param useOriginalWidth Use the original layout width of the text this span is applied to, + * and not the bounds of the Drawable. Drawable is always displayed using it's own bounds, + * and this setting only affects the layout width of the entire span. + */ + public VerticallyCenteredImageSpan(Drawable drawable, boolean useOriginalWidth) { super(drawable); + this.useOriginalWidth = useOriginalWidth; } @Override @@ -581,13 +667,17 @@ public int getSize(@NonNull Paint paint, @NonNull CharSequence text, Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt(); final int fontHeight = paintMetrics.descent - paintMetrics.ascent; final int drawHeight = bounds.bottom - bounds.top; + final int halfDrawHeight = drawHeight / 2; final int yCenter = paintMetrics.ascent + fontHeight / 2; - fontMetrics.ascent = yCenter - drawHeight / 2; + fontMetrics.ascent = yCenter - halfDrawHeight; fontMetrics.top = fontMetrics.ascent; - fontMetrics.bottom = yCenter + drawHeight / 2; + fontMetrics.bottom = yCenter + halfDrawHeight; fontMetrics.descent = fontMetrics.bottom; } + if (useOriginalWidth) { + return (int) paint.measureText(text, start, end); + } return bounds.right; } @@ -600,8 +690,13 @@ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, final int fontHeight = paintMetrics.descent - paintMetrics.ascent; final int yCenter = y + paintMetrics.descent - fontHeight / 2; final Rect drawBounds = drawable.getBounds(); + float translateX = x; + if (useOriginalWidth) { + // Horizontally center the drawable in the same space as the original text. + translateX += (paint.measureText(text, start, end) - (drawBounds.right - drawBounds.left)) / 2; + } final int translateY = yCenter - (drawBounds.bottom - drawBounds.top) / 2; - canvas.translate(x, translateY); + canvas.translate(translateX, translateY); drawable.draw(canvas); canvas.restore(); } 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 9403ee0c2c..8daa380517 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 @@ -38,7 +38,7 @@ public class ReturnYouTubeDislikeApi { * {@link #fetchVotes(String)} HTTP read timeout. * To locally debug and force timeouts, change this to a very small number (ie: 100) */ - private static final int API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS = 5 * 1000; // 5 Seconds. + private static final int API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS = 4 * 1000; // 4 Seconds. /** * Default connection and response timeout for voting and registration. 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 45fffc2cef..e269c9328b 100644 --- a/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java +++ b/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java @@ -13,6 +13,7 @@ import android.preference.SwitchPreference; import app.revanced.integrations.patches.ReturnYouTubeDislikePatch; +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; @@ -21,8 +22,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment { private static final boolean IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER = - SettingsEnum.SPOOF_APP_VERSION.getBoolean() - && SettingsEnum.SPOOF_APP_VERSION_TARGET.getString().compareTo("18.33.40") <= 0; + SpoofAppVersionPatch.isSpoofingToEqualOrLessThan("18.33.40"); /** * If dislikes are shown on Shorts. 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 918d55b7f3..6b94bbd972 100644 --- a/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java +++ b/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java @@ -92,7 +92,7 @@ public static void hideViewUnderCondition(SettingsEnum condition, View view) { * All tasks run at max thread priority. */ private static final ThreadPoolExecutor backgroundThreadPool = new ThreadPoolExecutor( - 2, // 2 threads always ready to go + 3, // 3 threads always ready to go Integer.MAX_VALUE, 10, // For any threads over the minimum, keep them alive 10 seconds after they go idle TimeUnit.SECONDS,