feat(YouTube - Return YouTube Dislike): Support version 18.43.45 and 18.44.41 (#514)

Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
This commit is contained in:
oSumAtrIX 2023-11-18 00:50:55 +01:00 committed by GitHub
parent 9a6ec6be8c
commit a5245b85a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 376 additions and 149 deletions

View File

@ -2,28 +2,27 @@ package app.revanced.integrations.patches;
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);
}
}

View File

@ -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.List;
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 @@ import app.revanced.integrations.utils.ReVancedUtils;
* 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 class ReturnYouTubeDislikePatch {
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 @@ public class ReturnYouTubeDislikePatch {
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 class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* For Litho segmented buttons and Litho Shorts player.
*/
@NonNull
public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
@Nullable AtomicReference<CharSequence> 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<CharSequence> textRef,
@NonNull CharSequence original) {
private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
@Nullable AtomicReference<CharSequence> 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 class ReturnYouTubeDislikePatch {
}
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 class ReturnYouTubeDislikePatch {
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 class ReturnYouTubeDislikePatch {
if (!SettingsEnum.RYD_SHORTS.getBoolean()) {
// Must clear the data here, in case a new video was loaded while PlayerType
// suggested the video was not a short (can happen when spoofing to an old app version).
currentVideoData = null;
clearData();
return false;
}
LogHelper.printDebug(() -> "setShortsDislikes");
@ -405,90 +540,59 @@ public class ReturnYouTubeDislikePatch {
* 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;
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);
}
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);
}
/**
* 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 class ReturnYouTubeDislikePatch {
}
}
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));

View File

@ -93,7 +93,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
// 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;

View File

@ -4,10 +4,19 @@ import app.revanced.integrations.settings.SettingsEnum;
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;
}
}

View File

@ -17,6 +17,7 @@ import android.text.SpannableStringBuilder;
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 class ReturnYouTubeDislike {
* 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 class ReturnYouTubeDislike {
/**
* 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 class ReturnYouTubeDislike {
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 class ReturnYouTubeDislike {
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 class ReturnYouTubeDislike {
@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 @@ public class ReturnYouTubeDislike {
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 @@ public class ReturnYouTubeDislike {
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 class ReturnYouTubeDislike {
* @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 class ReturnYouTubeDislike {
*/
@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 @@ public class ReturnYouTubeDislike {
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 @@ public class ReturnYouTubeDislike {
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 class ReturnYouTubeDislike {
}
}
/**
* 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 @@ class VerticallyCenteredImageSpan extends ImageSpan {
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 @@ class VerticallyCenteredImageSpan extends ImageSpan {
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();
}

View File

@ -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.

View File

@ -13,6 +13,7 @@ import android.preference.PreferenceScreen;
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 @@ import app.revanced.integrations.settings.SharedPrefCategory;
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.

View File

@ -92,7 +92,7 @@ public class ReVancedUtils {
* 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,