feat(YouTube - Return YouTube Dislike): Support version 18.37.36 (#490)

Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
This commit is contained in:
oSumAtrIX 2023-10-07 11:31:25 +02:00 committed by GitHub
parent d4b859d6fb
commit 245c3b3537
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 704 additions and 472 deletions

View File

@ -7,9 +7,10 @@ import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.revanced.integrations.patches.components.ReturnYouTubeDislikeFilterPatch;
import app.revanced.integrations.patches.spoof.SpoofAppVersionPatch;
import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike;
import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
import app.revanced.integrations.settings.SettingsEnum;
import app.revanced.integrations.shared.PlayerType;
import app.revanced.integrations.utils.LogHelper;
@ -24,14 +25,50 @@ import static app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislik
/**
* Handles all interaction of UI patch components.
*
* Does not handle creating dislike spans or anything to do with {@link ReturnYouTubeDislikeApi}.
*/
public class ReturnYouTubeDislikePatch {
/**
* RYD data for the current video on screen.
*/
@Nullable
private static String currentVideoId;
private static volatile ReturnYouTubeDislike currentVideoData;
/**
* The last litho based Shorts loaded.
* May be the same value as {@link #currentVideoData}, but usually is the next short to swipe to.
*/
@Nullable
private static volatile ReturnYouTubeDislike lastLithoShortsVideoData;
/**
* Because the litho Shorts spans are created after {@link ReturnYouTubeDislikeFilterPatch}
* detects the video ids, after the user votes the litho will update
* but {@link #lastLithoShortsVideoData} is not the correct data to use.
* If this is true, then instead use {@link #currentVideoData}.
*/
private static volatile boolean lithoShortsShouldUseCurrentData;
/**
* Last video id prefetched. Field is prevent prefetching the same video id multiple times in a row.
*/
@Nullable
private static volatile String lastPrefetchedVideoId;
public static void onRYDStatusChange(boolean rydEnabled) {
if (!rydEnabled) {
// Must remove all values to protect against using stale data
// if the user enables RYD while a video is on screen.
currentVideoData = null;
lastLithoShortsVideoData = null;
lithoShortsShouldUseCurrentData = false;
}
}
//
// 17.x non litho regular video player.
//
/**
* Resource identifier of old UI dislike button.
@ -76,11 +113,15 @@ public class ReturnYouTubeDislikePatch {
};
private static void updateOldUIDislikesTextView() {
ReturnYouTubeDislike videoData = currentVideoData;
if (videoData == null) {
return;
}
TextView oldUITextView = oldUITextViewRef.get();
if (oldUITextView == null) {
return;
}
oldUIReplacementSpan = ReturnYouTubeDislike.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false);
oldUIReplacementSpan = videoData.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false);
if (!oldUIReplacementSpan.equals(oldUITextView.getText())) {
oldUITextView.setText(oldUIReplacementSpan);
}
@ -124,6 +165,10 @@ public class ReturnYouTubeDislikePatch {
}
//
// Litho player for both regular videos and Shorts.
//
/**
* Injection point.
*
@ -144,23 +189,56 @@ public class ReturnYouTubeDislikePatch {
@NonNull AtomicReference<CharSequence> textRef,
@NonNull CharSequence original) {
try {
if (!SettingsEnum.RYD_ENABLED.getBoolean() || PlayerType.getCurrent().isNoneOrHidden()) {
if (!SettingsEnum.RYD_ENABLED.getBoolean()) {
return original;
}
String conversionContextString = conversionContext.toString();
// Remove this log statement after the a/b new litho dislikes is fixed.
LogHelper.printDebug(() -> "conversionContext: " + conversionContextString);
final boolean isSegmentedButton;
final Spanned replacement;
if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) {
isSegmentedButton = true;
} else if (conversionContextString.contains("|dislike_button.eml|")) {
isSegmentedButton = false;
// Regular video
ReturnYouTubeDislike videoData = currentVideoData;
if (videoData == null) {
return original; // User enabled RYD while a video was on screen.
}
replacement = videoData.getDislikesSpanForRegularVideo((Spannable) original, true);
// When spoofing between 17.09.xx and 17.30.xx the UI is the old layout but uses litho
// and the dislikes is "|dislike_button.eml|"
// but spoofing to that range gives a broken UI layout so no point checking for that.
} else if (conversionContextString.contains("|shorts_dislike_button.eml|")) {
// Litho Shorts player.
if (!SettingsEnum.RYD_SHORTS.getBoolean()) {
// Must clear the current video here, otherwise if the user opens a regular video
// then opens a litho short (while keeping the regular video on screen), then closes the short,
// the original video may show the incorrect dislike value.
currentVideoData = null;
return original;
}
ReturnYouTubeDislike videoData = lastLithoShortsVideoData;
if (videoData == null) {
// Should not happen, as user cannot turn on RYD while leaving a short on screen.
// If this does happen, then the litho video id filter did not detect the video id.
LogHelper.printDebug(() -> "Error: Litho video data is null, but it should not be");
return original;
}
// Use the correct dislikes data after voting.
if (lithoShortsShouldUseCurrentData) {
lithoShortsShouldUseCurrentData = false;
videoData = currentVideoData;
if (videoData == null) {
LogHelper.printException(() -> "currentVideoData is null"); // Should never happen
return original;
}
LogHelper.printDebug(() -> "Using current video data for litho span");
}
replacement = videoData.getDislikeSpanForShort((Spannable) original);
} else {
return original;
}
Spanned replacement = ReturnYouTubeDislike.getDislikesSpanForRegularVideo((Spannable) original, isSegmentedButton);
textRef.set(replacement);
return replacement;
} catch (Exception ex) {
@ -170,6 +248,10 @@ public class ReturnYouTubeDislikePatch {
}
//
// Non litho Shorts player.
//
/**
* Replacement text to use for "Dislikes" while RYD is fetching.
*/
@ -184,18 +266,16 @@ public class ReturnYouTubeDislikePatch {
private static final List<WeakReference<TextView>> shortsTextViewRefs = new ArrayList<>();
private static void clearRemovedShortsTextViews() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // YouTube requires Android N or greater
shortsTextViewRefs.removeIf(ref -> ref.get() == null);
return;
}
throw new IllegalStateException(); // YouTube requires Android N or greater
}
/**
* Injection point. Called when a Shorts dislike is updated.
* Injection point. Called when a Shorts dislike is updated. Always on main thread.
* Handles update asynchronously, otherwise Shorts video will be frozen while the UI thread is blocked.
*
* @return if RYD is enabled and the TextView was updated
* @return if RYD is enabled and the TextView was updated.
*/
public static boolean setShortsDislikes(@NonNull View likeDislikeView) {
try {
@ -205,21 +285,22 @@ public class ReturnYouTubeDislikePatch {
if (!SettingsEnum.RYD_SHORTS.getBoolean()) {
// Must clear the data here, in case a new video was loaded while PlayerType
// suggested the video was not a short (can happen when spoofing to an old app version).
ReturnYouTubeDislike.setCurrentVideoId(null);
currentVideoData = null;
return false;
}
LogHelper.printDebug(() -> "setShortsDislikes");
TextView textView = (TextView) likeDislikeView;
textView.setText(SHORTS_LOADING_SPAN); // Change 'Dislike' text to the loading text
textView.setText(SHORTS_LOADING_SPAN); // Change 'Dislike' text to the loading text.
shortsTextViewRefs.add(new WeakReference<>(textView));
if (likeDislikeView.isSelected() && isShortTextViewOnScreen(textView)) {
LogHelper.printDebug(() -> "Shorts dislike is already selected");
ReturnYouTubeDislike.setUserVote(Vote.DISLIKE);
ReturnYouTubeDislike videoData = currentVideoData;
if (videoData != null) videoData.setUserVote(Vote.DISLIKE);
}
// For the first short played, the shorts dislike hook is called after the video id hook.
// For the first short played, the Shorts dislike hook is called after the video id hook.
// But for most other times this hook is called before the video id (which is not ideal).
// Must update the TextViews here, and also after the videoId changes.
updateOnScreenShortsTextViews(false);
@ -241,13 +322,17 @@ public class ReturnYouTubeDislikePatch {
if (shortsTextViewRefs.isEmpty()) {
return;
}
ReturnYouTubeDislike videoData = currentVideoData;
if (videoData == null) {
return;
}
LogHelper.printDebug(() -> "updateShortsTextViews");
String videoId = VideoInformation.getVideoId();
Runnable update = () -> {
Spanned shortsDislikesSpan = ReturnYouTubeDislike.getDislikeSpanForShort(SHORTS_LOADING_SPAN);
Spanned shortsDislikesSpan = videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN);
ReVancedUtils.runOnMainThreadNowOrLater(() -> {
String videoId = videoData.getVideoId();
if (!videoId.equals(VideoInformation.getVideoId())) {
// User swiped to new video before fetch completed
LogHelper.printDebug(() -> "Ignoring stale dislikes data for short: " + videoId);
@ -271,13 +356,13 @@ public class ReturnYouTubeDislikePatch {
}
});
};
if (ReturnYouTubeDislike.fetchCompleted()) {
if (videoData.fetchCompleted()) {
update.run(); // Network call is completed, no need to wait on background thread.
} else {
ReVancedUtils.runOnBackgroundThread(update);
}
} catch (Exception ex) {
LogHelper.printException(() -> "updateVisibleShortsTextViews failure", ex);
LogHelper.printException(() -> "updateOnScreenShortsTextViews failure", ex);
}
}
@ -295,35 +380,85 @@ public class ReturnYouTubeDislikePatch {
return location[0] < windowRect.width() && location[1] < windowRect.height();
}
//
// Video Id and voting hooks (all players).
//
/**
* Injection point.
* Injection point. Uses 'playback response' video id hook to preload RYD.
*/
public static void preloadVideoId(@NonNull String videoId) {
if (!SettingsEnum.RYD_ENABLED.getBoolean()) {
return;
}
if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneOrHidden()) {
return;
}
if (videoId.equals(lastPrefetchedVideoId)) {
return;
}
lastPrefetchedVideoId = videoId;
LogHelper.printDebug(() -> "Prefetching RYD for video: " + videoId);
ReturnYouTubeDislike.getFetchForVideoId(videoId);
}
/**
* Injection point. Uses 'current playing' video id hook. Always called on main thread.
*/
public static void newVideoLoaded(@NonNull String videoId) {
newVideoLoaded(videoId, false);
}
/**
* Called both on and off main thread.
*
* @param isShortsLithoVideoId If the video id is from {@link ReturnYouTubeDislikeFilterPatch}.
*/
public static void newVideoLoaded(@NonNull String videoId, boolean isShortsLithoVideoId) {
try {
if (!SettingsEnum.RYD_ENABLED.getBoolean()) return;
if (!videoId.equals(currentVideoId)) {
currentVideoId = videoId;
PlayerType currentPlayerType = PlayerType.getCurrent();
final boolean isNoneOrHidden = currentPlayerType.isNoneOrHidden();
if (isNoneOrHidden && !SettingsEnum.RYD_SHORTS.getBoolean()) {
return;
}
final boolean noneHiddenOrMinimized = PlayerType.getCurrent().isNoneOrHidden();
if (noneHiddenOrMinimized && !SettingsEnum.RYD_SHORTS.getBoolean()) {
ReturnYouTubeDislike.setCurrentVideoId(null);
if (isShortsLithoVideoId) {
// Litho Shorts video.
if (videoIdIsSame(lastLithoShortsVideoData, videoId)) {
return;
}
ReturnYouTubeDislike.newVideoLoaded(videoId);
if (noneHiddenOrMinimized) {
// Shorts TextView hook can be called out of order with the video id hook.
// Must manually update again here.
updateOnScreenShortsTextViews(true);
ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
videoData.setVideoIdIsShort(true);
lastLithoShortsVideoData = videoData;
lithoShortsShouldUseCurrentData = false;
} else {
if (videoIdIsSame(currentVideoData, videoId)) {
return;
}
// All other playback (including litho Shorts).
currentVideoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
}
LogHelper.printDebug(() -> "New video id: " + videoId + " playerType: " + currentPlayerType
+ " isShortsLithoHook: " + isShortsLithoVideoId);
if (isNoneOrHidden) {
// Current video id hook can be called out of order with the non litho Shorts text view hook.
// Must manually update again here.
updateOnScreenShortsTextViews(true);
}
} catch (Exception ex) {
LogHelper.printException(() -> "newVideoLoaded failure", ex);
}
}
private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, String videoId) {
return fetch != null && fetch.getVideoId().equals(videoId);
}
/**
* Injection point.
*
@ -339,11 +474,18 @@ public class ReturnYouTubeDislikePatch {
if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneHiddenOrMinimized()) {
return;
}
ReturnYouTubeDislike videoData = currentVideoData;
if (videoData == null) {
return; // User enabled RYD while a regular video was minimized.
}
for (Vote v : Vote.values()) {
if (v.value == vote) {
ReturnYouTubeDislike.sendVote(v);
videoData.sendVote(v);
if (lastLithoShortsVideoData != null) {
lithoShortsShouldUseCurrentData = true;
}
updateOldUIDislikesTextView();
return;
}

View File

@ -4,6 +4,7 @@ import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import app.revanced.integrations.settings.SettingsEnum;
import app.revanced.integrations.utils.*;
@ -13,12 +14,24 @@ import java.util.function.Consumer;
abstract class FilterGroup<T> {
final static class FilterGroupResult {
SettingsEnum setting;
boolean filtered;
private SettingsEnum setting;
private int matchedIndex;
private int matchedLength;
// In the future it might be useful to include which pattern matched,
// but for now that is not needed.
FilterGroupResult(SettingsEnum setting, boolean filtered) {
FilterGroupResult() {
this(null, -1, 0);
}
FilterGroupResult(SettingsEnum setting, int matchedIndex, int matchedLength) {
setValues(setting, matchedIndex, matchedLength);
}
public void setValues(SettingsEnum setting, int matchedIndex, int matchedLength) {
this.setting = setting;
this.filtered = filtered;
this.matchedIndex = matchedIndex;
this.matchedLength = matchedLength;
}
/**
@ -30,7 +43,21 @@ abstract class FilterGroup<T> {
}
public boolean isFiltered() {
return filtered;
return matchedIndex >= 0;
}
/**
* Matched index of first pattern that matched, or -1 if nothing matched.
*/
public int getMatchedIndex() {
return matchedIndex;
}
/**
* Length of the matched filter pattern.
*/
public int getMatchedLength() {
return matchedLength;
}
}
@ -81,7 +108,21 @@ class StringFilterGroup extends FilterGroup<String> {
@Override
public FilterGroupResult check(final String string) {
return new FilterGroupResult(setting, isEnabled() && ReVancedUtils.containsAny(string, filters));
int matchedIndex = -1;
int matchedLength = 0;
if (isEnabled()) {
for (String pattern : filters) {
if (!string.isEmpty()) {
final int indexOf = pattern.indexOf(string);
if (indexOf >= 0) {
matchedIndex = indexOf;
matchedLength = pattern.length();
break;
}
}
}
}
return new FilterGroupResult(setting, matchedIndex, matchedLength);
}
}
@ -155,19 +196,22 @@ class ByteArrayFilterGroup extends FilterGroup<byte[]> {
@Override
public FilterGroupResult check(final byte[] bytes) {
var matched = false;
int matchedLength = 0;
int matchedIndex = -1;
if (isEnabled()) {
if (failurePatterns == null) {
buildFailurePatterns(); // Lazy load.
}
for (int i = 0, length = filters.length; i < length; i++) {
if (indexOf(bytes, filters[i], failurePatterns[i]) >= 0) {
matched = true;
byte[] filter = filters[i];
matchedIndex = indexOf(bytes, filter, failurePatterns[i]);
if (matchedIndex >= 0) {
matchedLength = filter.length;
break;
}
}
}
return new FilterGroupResult(setting, matched);
return new FilterGroupResult(setting, matchedIndex, matchedLength);
}
}
@ -204,11 +248,10 @@ abstract class FilterGroupList<V, T extends FilterGroup<V>> implements Iterable<
continue;
}
for (V pattern : group.filters) {
search.addPattern(pattern, (textSearched, matchedStartIndex, callbackParameter) -> {
search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
if (group.isEnabled()) {
FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter;
result.setting = group.setting;
result.filtered = true;
result.setValues(group.setting, matchedStartIndex, matchedLength);
return true;
}
return false;
@ -241,9 +284,10 @@ abstract class FilterGroupList<V, T extends FilterGroup<V>> implements Iterable<
if (search == null) {
buildSearch(); // Lazy load.
}
FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult(null, false);
FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult();
search.matches(stack, result);
return result;
}
protected abstract TrieSearch<V> createSearchGraph();
@ -399,7 +443,7 @@ public final class LithoFilterPatch {
continue;
}
for (T pattern : group.filters) {
pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, callbackParameter) -> {
pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
if (!group.isEnabled()) return false;
LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter;
return filter.isFiltered(parameters.identifier, parameters.path, parameters.protoBuffer,

View File

@ -0,0 +1,76 @@
package app.revanced.integrations.patches.components;
import android.os.Build;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import java.nio.charset.StandardCharsets;
import app.revanced.integrations.patches.ReturnYouTubeDislikePatch;
import app.revanced.integrations.settings.SettingsEnum;
@RequiresApi(api = Build.VERSION_CODES.N)
public final class ReturnYouTubeDislikeFilterPatch extends Filter {
private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList();
public ReturnYouTubeDislikeFilterPatch() {
pathFilterGroupList.addAll(
new StringFilterGroup(SettingsEnum.RYD_SHORTS, "|shorts_dislike_button.eml|")
);
// After the dislikes icon name is some binary data and then the video id for that specific short.
videoIdFilterGroup.addAll(
// Video was previously disliked before video was opened.
new ByteArrayAsStringFilterGroup(null, "ic_right_dislike_on_shadowed"),
// Video was not already disliked.
new ByteArrayAsStringFilterGroup(null, "ic_right_dislike_off_shadowed")
);
}
@Override
public boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) {
FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray);
if (result.isFiltered()) {
// The video length must be hard coded to 11, as there is additional ASCII text that
// appears immediately after the id if the dislike button is already selected.
final int videoIdLength = 11;
final int subStringSearchStartIndex = result.getMatchedIndex() + result.getMatchedLength();
String videoId = findSubString(protobufBufferArray, subStringSearchStartIndex, videoIdLength);
if (videoId != null) {
ReturnYouTubeDislikePatch.newVideoLoaded(videoId, true);
}
}
return false;
}
/**
* Find an exact length ASCII substring starting from a given index.
*
* Similar to the String finding code in {@link LithoFilterPatch},
* but refactoring it to also handle this use case became messy and overly complicated.
*/
@Nullable
private static String findSubString(byte[] buffer, int bufferStartIndex, int subStringLength) {
// Valid ASCII values (ignore control characters).
final int minimumAscii = 32; // 32 = space character
final int maximumAscii = 126; // 127 = delete character
final int bufferLength = buffer.length;
int start = bufferStartIndex;
int end = bufferStartIndex;
do {
final int value = buffer[end];
if (value < minimumAscii || value > maximumAscii) {
start = end + 1;
} else if (end - start == subStringLength) {
return new String(buffer, start, subStringLength, StandardCharsets.US_ASCII);
}
end++;
} while (end < bufferLength);
return null;
}
}

View File

@ -45,123 +45,12 @@ import app.revanced.integrations.utils.ReVancedUtils;
import app.revanced.integrations.utils.ThemeHelper;
/**
* Handles fetching and creation/replacing of RYD dislike text spans.
*
* Because Litho creates spans using multiple threads, this entire class supports multithreading as well.
*/
public class ReturnYouTubeDislike {
/**
* Simple wrapper to cache a Future.
*/
private static class RYDCachedFetch {
/**
* How long to retain cached RYD fetches.
*/
static final long CACHE_TIMEOUT_MILLISECONDS = 4 * 60 * 1000; // 4 Minutes
@NonNull
final Future<RYDVoteData> future;
final String videoId;
final long timeFetched;
RYDCachedFetch(@NonNull Future<RYDVoteData> future, @NonNull String videoId) {
this.future = Objects.requireNonNull(future);
this.videoId = Objects.requireNonNull(videoId);
this.timeFetched = System.currentTimeMillis();
}
boolean isExpired(long now) {
return (now - timeFetched) > CACHE_TIMEOUT_MILLISECONDS;
}
boolean futureInProgressOrFinishedSuccessfully() {
try {
return !future.isDone() || future.get(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH, TimeUnit.MILLISECONDS) != null;
} catch (ExecutionException | InterruptedException | TimeoutException ex) {
LogHelper.printInfo(() -> "failed to lookup cache", ex); // will never happen
}
return false;
}
}
/**
* Maximum amount of time to block the UI from updates while waiting for network call to complete.
*
* Must be less than 5 seconds, as per:
* https://developer.android.com/topic/performance/vitals/anr
*/
private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000;
/**
* Unique placeholder character, used to detect if a segmented span already has dislikes added to it.
* Can be any almost any non-visible character.
*/
private static final char MIDDLE_SEPARATOR_CHARACTER = '\u2009'; // 'narrow space' character
/**
* Cached lookup of RYD fetches.
*/
@GuardedBy("videoIdLockObject")
private static final Map<String, RYDCachedFetch> futureCache = new HashMap<>();
/**
* Used to send votes, one by one, in the same order the user created them.
*/
private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor();
/**
* Used to guard {@link #currentVideoId} and {@link #voteFetchFuture}.
*/
private static final Object videoIdLockObject = new Object();
@Nullable
@GuardedBy("videoIdLockObject")
private static String currentVideoId;
/**
* If {@link #currentVideoId} and the RYD data is for the last shorts loaded.
*/
private static volatile boolean dislikeDataIsShort;
/**
* Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes.
*/
@Nullable
@GuardedBy("videoIdLockObject")
private static Future<RYDVoteData> voteFetchFuture;
/**
* Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing.
*/
@Nullable
@GuardedBy("videoIdLockObject")
private static Vote userVote;
/**
* Original dislike span, before modifications.
*/
@Nullable
@GuardedBy("videoIdLockObject")
private static Spanned originalDislikeSpan;
/**
* Replacement like/dislike span that includes formatted dislikes.
* Used to prevent recreating the same span multiple times.
*/
@Nullable
@GuardedBy("videoIdLockObject")
private static SpannableString replacementLikeDislikeSpan;
/**
* For formatting dislikes as number.
*/
@GuardedBy("ReturnYouTubeDislike.class") // not thread safe
private static CompactDecimalFormat dislikeCountFormatter;
/**
* For formatting dislikes as percentage.
*/
@GuardedBy("ReturnYouTubeDislike.class")
private static NumberFormat dislikePercentageFormatter;
public enum Vote {
LIKE(1),
DISLIKE(-1),
@ -174,286 +63,107 @@ public class ReturnYouTubeDislike {
}
}
private ReturnYouTubeDislike() {
} // only static methods
public static void onEnabledChange(boolean enabled) {
if (!enabled) {
// Must clear old values, to protect against using stale data
// if the user re-enables RYD while watching a video.
setCurrentVideoId(null);
}
}
public static void setCurrentVideoId(@Nullable String videoId) {
synchronized (videoIdLockObject) {
if (videoId == null && currentVideoId != null) {
LogHelper.printDebug(() -> "Clearing data");
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
final long now = System.currentTimeMillis();
futureCache.values().removeIf(value -> {
final boolean expired = value.isExpired(now);
if (expired) LogHelper.printDebug(() -> "Removing expired fetch: " + value.videoId);
return expired;
});
} else {
throw new IllegalStateException(); // YouTube requires Android N or greater
}
currentVideoId = videoId;
dislikeDataIsShort = false;
userVote = null;
voteFetchFuture = null;
originalDislikeSpan = null;
replacementLikeDislikeSpan = null;
}
}
/**
* Should be called after a user dislikes, or if the user changes settings for dislikes appearance.
*/
public static void clearCache() {
synchronized (videoIdLockObject) {
if (replacementLikeDislikeSpan != null) {
LogHelper.printDebug(() -> "Clearing replacement spans");
}
replacementLikeDislikeSpan = null;
}
}
@Nullable
private static String getCurrentVideoId() {
synchronized (videoIdLockObject) {
return currentVideoId;
}
}
@Nullable
private static Future<RYDVoteData> getVoteFetchFuture() {
synchronized (videoIdLockObject) {
return voteFetchFuture;
}
}
public static void newVideoLoaded(@NonNull String videoId) {
Objects.requireNonNull(videoId);
synchronized (videoIdLockObject) {
if (videoId.equals(currentVideoId)) {
return; // already loaded
}
if (!ReVancedUtils.isNetworkConnected()) { // must do network check after verifying it's a new video id
LogHelper.printDebug(() -> "Network not connected, ignoring video: " + videoId);
setCurrentVideoId(null);
return;
}
PlayerType currentPlayerType = PlayerType.getCurrent();
LogHelper.printDebug(() -> "New video loaded: " + videoId + " playerType: " + currentPlayerType);
setCurrentVideoId(videoId);
// If a Short is opened while a regular video is on screen, this will incorrectly set this as false.
// But this check is needed to fix unusual situations of opening/closing the app
// while both a regular video and a short are on screen.
dislikeDataIsShort = currentPlayerType.isNoneOrHidden();
RYDCachedFetch entry = futureCache.get(videoId);
if (entry != null && entry.futureInProgressOrFinishedSuccessfully()) {
LogHelper.printDebug(() -> "Using cached RYD fetch: "+ entry.videoId);
voteFetchFuture = entry.future;
return;
}
voteFetchFuture = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId));
futureCache.put(videoId, new RYDCachedFetch(voteFetchFuture, videoId));
}
}
/**
* @return the replacement span containing dislikes, or the original span if RYD is not available.
*/
@NonNull
public static Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, boolean isSegmentedButton) {
if (dislikeDataIsShort) {
// user:
// 1, opened a video
// 2. opened a short (without closing the regular video)
// 3. closed the short
// 4. regular video is now present, but the videoId and RYD data is still for the short
LogHelper.printDebug(() -> "Ignoring dislike span, as data loaded is for prior short");
return original;
}
return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton);
}
/**
* Called when a Shorts dislike Spannable is created.
*/
@NonNull
public static Spanned getDislikeSpanForShort(@NonNull Spanned original) {
dislikeDataIsShort = true; // it's now certain the video and data are a short
return waitForFetchAndUpdateReplacementSpan(original, false);
}
// Alternatively, this could check if the span contains one of the custom created spans, but this is simple and quick.
private static boolean isPreviouslyCreatedSegmentedSpan(@NonNull Spanned span) {
return span.toString().indexOf(MIDDLE_SEPARATOR_CHARACTER) != -1;
}
@NonNull
private static Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned oldSpannable, boolean isSegmentedButton) {
try {
Future<RYDVoteData> fetchFuture = getVoteFetchFuture();
if (fetchFuture == null) {
LogHelper.printDebug(() -> "fetch future not available (user enabled RYD while video was playing?)");
return oldSpannable;
}
// Absolutely cannot be holding any lock during get().
RYDVoteData votingData = fetchFuture.get(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH, TimeUnit.MILLISECONDS);
if (votingData == null) {
LogHelper.printDebug(() -> "Cannot add dislike to UI (RYD data not available)");
return oldSpannable;
}
// Must check against existing replacements, after the fetch,
// otherwise concurrent threads can create the same replacement same multiple times.
// Also do the replacement comparison and creation in a single synchronized block.
synchronized (videoIdLockObject) {
if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) {
if (spansHaveEqualTextAndColor(oldSpannable, replacementLikeDislikeSpan)) {
LogHelper.printDebug(() -> "Ignoring previously created dislikes span");
return oldSpannable;
}
if (spansHaveEqualTextAndColor(oldSpannable, originalDislikeSpan)) {
LogHelper.printDebug(() -> "Replacing span with previously created dislike span");
return replacementLikeDislikeSpan;
}
}
if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(oldSpannable)) {
// need to recreate using original, as oldSpannable has prior outdated dislike values
if (originalDislikeSpan == null) {
LogHelper.printDebug(() -> "Cannot add dislikes - original span is null"); // should never happen
return oldSpannable;
}
oldSpannable = originalDislikeSpan;
}
// No replacement span exist, create it now.
if (userVote != null) {
votingData.updateUsingVote(userVote);
}
originalDislikeSpan = oldSpannable;
replacementLikeDislikeSpan = createDislikeSpan(oldSpannable, isSegmentedButton, votingData);
LogHelper.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '" + replacementLikeDislikeSpan + "'");
return replacementLikeDislikeSpan;
}
} catch (TimeoutException e) {
LogHelper.printDebug(() -> "UI timed out while waiting for fetch votes to complete"); // show no toast
} catch (Exception e) {
LogHelper.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen
}
return oldSpannable;
}
/**
* @return if the RYD fetch call has completed.
*/
public static boolean fetchCompleted() {
Future<RYDVoteData> future = getVoteFetchFuture();
return future != null && future.isDone();
}
public static void sendVote(@NonNull Vote vote) {
ReVancedUtils.verifyOnMainThread();
Objects.requireNonNull(vote);
try {
// Must make a local copy of videoId, since it may change between now and when the vote thread runs.
String videoIdToVoteFor = getCurrentVideoId();
if (videoIdToVoteFor == null ||
(SettingsEnum.RYD_SHORTS.getBoolean() && dislikeDataIsShort != PlayerType.getCurrent().isNoneOrHidden())) {
// User enabled RYD after starting playback of a video.
// Or shorts was loaded with regular video present, then shorts was closed,
// and then user voted on the now visible original video.
// Cannot send a vote, because the loaded videoId is for the wrong video.
ReVancedUtils.showToastLong(str("revanced_ryd_failure_ryd_enabled_while_playing_video_then_user_voted"));
return;
}
voteSerialExecutor.execute(() -> {
try { // must wrap in try/catch to properly log exceptions
String userId = getUserId();
if (userId != null) {
ReturnYouTubeDislikeApi.sendVote(videoIdToVoteFor, userId, vote);
}
} catch (Exception ex) {
LogHelper.printException(() -> "Failed to send vote", ex);
}
});
setUserVote(vote);
} catch (Exception ex) {
LogHelper.printException(() -> "Error trying to send vote", ex);
}
}
public static void setUserVote(@NonNull Vote vote) {
Objects.requireNonNull(vote);
try {
LogHelper.printDebug(() -> "setUserVote: " + vote);
// Update the downloaded vote data.
Future<RYDVoteData> future = getVoteFetchFuture();
if (future != null && future.isDone()) {
RYDVoteData voteData;
try {
voteData = future.get(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH, TimeUnit.MILLISECONDS);
} catch (ExecutionException | InterruptedException | TimeoutException ex) {
// Should never happen
LogHelper.printInfo(() -> "Could not update vote data", ex);
return;
}
if (voteData == null) {
// RYD fetch failed
LogHelper.printDebug(() -> "Cannot update UI (vote data not available)");
return;
}
voteData.updateUsingVote(vote);
} // Else, vote will be applied after vote data is received
synchronized (videoIdLockObject) {
if (userVote != vote) {
userVote = vote;
clearCache(); // UI needs updating
}
}
} catch (Exception ex) {
LogHelper.printException(() -> "setUserVote failure", ex);
}
}
/**
* Must call off main thread, as this will make a network call if user is not yet registered.
* Maximum amount of time to block the UI from updates while waiting for network call to complete.
*
* @return ReturnYouTubeDislike user ID. If user registration has never happened
* and the network call fails, this returns NULL.
* Must be less than 5 seconds, as per:
* https://developer.android.com/topic/performance/vitals/anr
*/
private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000;
/**
* How long to retain cached RYD fetches.
*/
private static final long CACHE_TIMEOUT_MILLISECONDS = 5 * 60 * 1000; // 5 Minutes
/**
* Unique placeholder character, used to detect if a segmented span already has dislikes added to it.
* Can be any almost any non-visible character.
*/
private static final char MIDDLE_SEPARATOR_CHARACTER = '\u2009'; // 'narrow space' character
/**
* Cached lookup of all video ids.
*/
@GuardedBy("itself")
private static final Map<String, ReturnYouTubeDislike> fetchCache = new HashMap<>();
/**
* Used to send votes, one by one, in the same order the user created them.
*/
private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor();
/**
* For formatting dislikes as number.
*/
@GuardedBy("ReturnYouTubeDislike.class") // not thread safe
private static CompactDecimalFormat dislikeCountFormatter;
/**
* For formatting dislikes as percentage.
*/
@GuardedBy("ReturnYouTubeDislike.class")
private static NumberFormat dislikePercentageFormatter;
// Used for segmented dislike spans in Litho regular player.
private static final Rect leftSeparatorBounds;
private static final Rect middleSeparatorBounds;
static {
DisplayMetrics dp = Objects.requireNonNull(ReVancedUtils.getContext()).getResources().getDisplayMetrics();
leftSeparatorBounds = new Rect(0, 0,
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp),
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 18, dp));
final int middleSeparatorSize =
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp);
middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize);
}
private final String videoId;
/**
* Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes.
* Absolutely cannot be holding any lock during calls to {@link Future#get()}.
*/
private final Future<RYDVoteData> future;
/**
* Time this instance and the future was created.
*/
private final long timeFetched;
/**
* If the video id is for a Short.
* Value of TRUE indicates it was previously loaded for a Short
* and FALSE indicates a regular video.
* NULL values means short status is not yet known.
*/
@Nullable
private static String getUserId() {
ReVancedUtils.verifyOffMainThread();
@GuardedBy("this")
private Boolean isShort;
String userId = SettingsEnum.RYD_USER_ID.getString();
if (!userId.isEmpty()) {
return userId;
}
/**
* Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing.
*/
@Nullable
@GuardedBy("this")
private Vote userVote;
userId = ReturnYouTubeDislikeApi.registerAsNewUser();
if (userId != null) {
SettingsEnum.RYD_USER_ID.saveValue(userId);
}
return userId;
}
/**
* Original dislike span, before modifications.
*/
@Nullable
@GuardedBy("this")
private Spanned originalDislikeSpan;
/**
* Replacement like/dislike span that includes formatted dislikes.
* Used to prevent recreating the same span multiple times.
*/
@Nullable
@GuardedBy("this")
private SpannableString replacementLikeDislikeSpan;
/**
* @param isSegmentedButton If UI is using the segmented single UI component for both like and dislike.
@ -493,13 +203,9 @@ public class ReturnYouTubeDislike {
final int separatorColor = ThemeHelper.isDarkTheme()
? 0x29AAAAAA // transparent dark gray
: 0xFFD9D9D9; // light gray
DisplayMetrics dp = Objects.requireNonNull(ReVancedUtils.getContext()).getResources().getDisplayMetrics();
if (!compactLayout) {
// left separator
final Rect leftSeparatorBounds = new Rect(0, 0,
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp),
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 18, dp));
String leftSeparatorString = ReVancedUtils.isRightToLeftTextLayout()
? "\u200F " // u200F = right to left character
: "\u200E "; // u200E = left to right character
@ -520,8 +226,6 @@ public class ReturnYouTubeDislike {
? " " + MIDDLE_SEPARATOR_CHARACTER + " "
: " \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character
final int shapeInsertionIndex = middleSeparatorString.length() / 2;
final int middleSeparatorSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp);
final Rect middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize);
Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString);
ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape());
shapeDrawable.getPaint().setColor(separatorColor);
@ -536,6 +240,11 @@ public class ReturnYouTubeDislike {
return new SpannableString(builder);
}
// Alternatively, this could check if the span contains one of the custom created spans, but this is simple and quick.
private static boolean isPreviouslyCreatedSegmentedSpan(@NonNull Spanned span) {
return span.toString().indexOf(MIDDLE_SEPARATOR_CHARACTER) != -1;
}
/**
* Correctly handles any unicode numbers (such as Arabic numbers).
*
@ -603,7 +312,7 @@ public class ReturnYouTubeDislike {
}
}
// will never be reached, as the oldest supported YouTube app requires Android N or greater
// Will never be reached, as the oldest supported YouTube app requires Android N or greater.
return String.valueOf(dislikeCount);
}
@ -622,6 +331,231 @@ public class ReturnYouTubeDislike {
return dislikePercentageFormatter.format(dislikePercentage);
}
}
@NonNull
public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) {
Objects.requireNonNull(videoId);
synchronized (fetchCache) {
// Remove any expired entries.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
final long now = System.currentTimeMillis();
fetchCache.values().removeIf(value -> {
final boolean expired = value.isExpired(now);
if (expired)
LogHelper.printDebug(() -> "Removing expired fetch: " + value.videoId);
return expired;
});
}
ReturnYouTubeDislike fetch = fetchCache.get(videoId);
if (fetch == null || !fetch.futureInProgressOrFinishedSuccessfully()) {
fetch = new ReturnYouTubeDislike(videoId);
fetchCache.put(videoId, fetch);
}
return fetch;
}
}
/**
* Should be called if the user changes settings for dislikes appearance.
*/
public static void clearAllUICaches() {
synchronized (fetchCache) {
for (ReturnYouTubeDislike fetch : fetchCache.values()) {
fetch.clearUICache();
}
}
}
private ReturnYouTubeDislike(@NonNull String videoId) {
this.videoId = Objects.requireNonNull(videoId);
this.timeFetched = System.currentTimeMillis();
this.future = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId));
}
private boolean isExpired(long now) {
return timeFetched != 0 && (now - timeFetched) > CACHE_TIMEOUT_MILLISECONDS;
}
@Nullable
public RYDVoteData getFetchData(long maxTimeToWait) {
try {
return future.get(maxTimeToWait, TimeUnit.MILLISECONDS);
} catch (TimeoutException ex) {
LogHelper.printDebug(() -> "Waited but future was not complete after: " + maxTimeToWait + "ms");
} catch (ExecutionException | InterruptedException ex) {
LogHelper.printException(() -> "Future failure ", ex); // will never happen
}
return null;
}
private boolean futureInProgressOrFinishedSuccessfully() {
return !future.isDone() || getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH) != null;
}
private synchronized void clearUICache() {
if (replacementLikeDislikeSpan != null) {
LogHelper.printDebug(() -> "Clearing replacement span for: " + videoId);
}
replacementLikeDislikeSpan = null;
}
@NonNull
public String getVideoId() {
return videoId;
}
/**
* Pre-emptively set this as a Short.
* Should only be used immediately after creation of this instance.
*/
public synchronized void setVideoIdIsShort(boolean isShort) {
this.isShort = isShort;
}
/**
* @return the replacement span containing dislikes, or the original span if RYD is not available.
*/
@NonNull
public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, boolean isSegmentedButton) {
return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, false);
}
/**
* Called when a Shorts dislike Spannable is created.
*/
@NonNull
public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) {
return waitForFetchAndUpdateReplacementSpan(original, false, true);
}
@NonNull
private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original,
boolean isSegmentedButton,
boolean spanIsForShort) {
try {
RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
if (votingData == null) {
LogHelper.printDebug(() -> "Cannot add dislike to UI (RYD data not available)");
return original;
}
synchronized (this) {
if (isShort != null) {
if (isShort != spanIsForShort) {
// user:
// 1, opened a video
// 2. opened a short (without closing the regular video)
// 3. closed the short
// 4. regular video is now present, but the videoId and RYD data is still for the short
LogHelper.printDebug(() -> "Ignoring dislike span, as data loaded was previously"
+ " used for a different video type.");
return original;
}
} else {
isShort = spanIsForShort;
}
if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) {
if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) {
LogHelper.printDebug(() -> "Ignoring previously created dislikes span");
return original;
}
if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) {
LogHelper.printDebug(() -> "Replacing span with previously created dislike span");
return replacementLikeDislikeSpan;
}
}
if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original)) {
// need to recreate using original, as original has prior outdated dislike values
if (originalDislikeSpan == null) {
LogHelper.printDebug(() -> "Cannot add dislikes - original span is null"); // should never happen
return original;
}
original = originalDislikeSpan;
}
// No replacement span exist, create it now.
if (userVote != null) {
votingData.updateUsingVote(userVote);
}
originalDislikeSpan = original;
replacementLikeDislikeSpan = createDislikeSpan(original, isSegmentedButton, votingData);
LogHelper.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '"
+ replacementLikeDislikeSpan + "'" + " using video: " + videoId);
return replacementLikeDislikeSpan;
}
} catch (Exception e) {
LogHelper.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen
}
return original;
}
/**
* @return if the RYD fetch call has completed.
*/
public boolean fetchCompleted() {
return future.isDone();
}
public void sendVote(@NonNull Vote vote) {
ReVancedUtils.verifyOnMainThread();
Objects.requireNonNull(vote);
try {
if (isShort != null && isShort != PlayerType.getCurrent().isNoneOrHidden()) {
// Shorts was loaded with regular video present, then Shorts was closed.
// and then user voted on the now visible original video.
// Cannot send a vote, because the loaded videoId is for the wrong video.
ReVancedUtils.showToastLong(str("revanced_ryd_failure_ryd_enabled_while_playing_video_then_user_voted"));
return;
}
setUserVote(vote);
voteSerialExecutor.execute(() -> {
try { // Must wrap in try/catch to properly log exceptions.
ReturnYouTubeDislikeApi.sendVote(videoId, vote);
} catch (Exception ex) {
LogHelper.printException(() -> "Failed to send vote", ex);
}
});
} catch (Exception ex) {
LogHelper.printException(() -> "Error trying to send vote", ex);
}
}
/**
* Sets the current user vote value, and does not send the vote to the RYD API.
*
* Only used to set value if thumbs up/down is already selected on video load.
*/
public void setUserVote(@NonNull Vote vote) {
Objects.requireNonNull(vote);
try {
LogHelper.printDebug(() -> "setUserVote: " + vote);
synchronized (this) {
userVote = vote;
clearUICache();
}
if (future.isDone()) {
// Update the fetched vote data.
RYDVoteData voteData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
if (voteData == null) {
// RYD fetch failed.
LogHelper.printDebug(() -> "Cannot update UI (vote data not available)");
return;
}
voteData.updateUsingVote(vote);
} // Else, vote will be applied after fetch completes.
} catch (Exception ex) {
LogHelper.printException(() -> "setUserVote failure", ex);
}
}
}
class VerticallyCenteredImageSpan extends ImageSpan {

View File

@ -7,8 +7,6 @@ import androidx.annotation.NonNull;
import org.json.JSONException;
import org.json.JSONObject;
import app.revanced.integrations.utils.LogHelper;
/**
* ReturnYouTubeDislike API estimated like/dislike/view counts.
*
@ -81,17 +79,21 @@ public final class RYDVoteData {
}
public void updateUsingVote(Vote vote) {
if (vote == Vote.LIKE) {
likeCount = fetchedLikeCount + 1;
dislikeCount = fetchedDislikeCount;
} else if (vote == Vote.DISLIKE) {
likeCount = fetchedLikeCount;
dislikeCount = fetchedDislikeCount + 1;
} else if (vote == Vote.LIKE_REMOVE) {
likeCount = fetchedLikeCount;
dislikeCount = fetchedDislikeCount;
} else {
throw new IllegalStateException();
switch (vote) {
case LIKE:
likeCount = fetchedLikeCount + 1;
dislikeCount = fetchedDislikeCount;
break;
case DISLIKE:
likeCount = fetchedLikeCount;
dislikeCount = fetchedDislikeCount + 1;
break;
case LIKE_REMOVE:
likeCount = fetchedLikeCount;
dislikeCount = fetchedDislikeCount;
break;
default:
throw new IllegalStateException();
}
updatePercentages();
}

View File

@ -391,13 +391,37 @@ public class ReturnYouTubeDislikeApi {
return null;
}
public static boolean sendVote(String videoId, String userId, ReturnYouTubeDislike.Vote vote) {
/**
* Must call off main thread, as this will make a network call if user is not yet registered.
*
* @return ReturnYouTubeDislike user ID. If user registration has never happened
* and the network call fails, this returns NULL.
*/
@Nullable
private static String getUserId() {
ReVancedUtils.verifyOffMainThread();
String userId = SettingsEnum.RYD_USER_ID.getString();
if (!userId.isEmpty()) {
return userId;
}
userId = registerAsNewUser();
if (userId != null) {
SettingsEnum.RYD_USER_ID.saveValue(userId);
}
return userId;
}
public static boolean sendVote(String videoId, ReturnYouTubeDislike.Vote vote) {
ReVancedUtils.verifyOffMainThread();
Objects.requireNonNull(videoId);
Objects.requireNonNull(userId);
Objects.requireNonNull(vote);
try {
String userId = getUserId();
if (userId == null) return false;
if (checkIfRateLimitInEffect("sendVote")) {
return false;
}

View File

@ -12,6 +12,7 @@ import android.preference.PreferenceFragment;
import android.preference.PreferenceScreen;
import android.preference.SwitchPreference;
import app.revanced.integrations.patches.ReturnYouTubeDislikePatch;
import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike;
import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
import app.revanced.integrations.settings.SettingsEnum;
@ -63,7 +64,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment {
enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> {
final boolean rydIsEnabled = (Boolean) newValue;
SettingsEnum.RYD_ENABLED.saveValue(rydIsEnabled);
ReturnYouTubeDislike.onEnabledChange(rydIsEnabled);
ReturnYouTubeDislikePatch.onRYDStatusChange(rydIsEnabled);
updateUIState();
return true;
@ -89,7 +90,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment {
percentagePreference.setSummaryOff(str("revanced_ryd_dislike_percentage_summary_off"));
percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> {
SettingsEnum.RYD_DISLIKE_PERCENTAGE.saveValue(newValue);
ReturnYouTubeDislike.clearCache();
ReturnYouTubeDislike.clearAllUICaches();
updateUIState();
return true;
});
@ -102,7 +103,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment {
compactLayoutPreference.setSummaryOff(str("revanced_ryd_compact_layout_summary_off"));
compactLayoutPreference.setOnPreferenceChangeListener((pref, newValue) -> {
SettingsEnum.RYD_COMPACT_LAYOUT.saveValue(newValue);
ReturnYouTubeDislike.clearCache();
ReturnYouTubeDislike.clearAllUICaches();
updateUIState();
return true;
});

View File

@ -83,9 +83,17 @@ public class ReVancedUtils {
}
public static boolean containsAny(@NonNull String value, @NonNull String... targets) {
for (String string : targets)
if (!string.isEmpty() && value.contains(string)) return true;
return false;
return indexOfFirstFound(value, targets) >= 0;
}
public static int indexOfFirstFound(@NonNull String value, @NonNull String... targets) {
for (String string : targets) {
if (!string.isEmpty()) {
final int indexOf = value.indexOf(string);
if (indexOf >= 0) return indexOf;
}
}
return -1;
}
/**

View File

@ -23,11 +23,12 @@ public abstract class TrieSearch<T> {
*
* @param textSearched Text that was searched.
* @param matchedStartIndex Start index of the search text, where the pattern was matched.
* @param matchedLength Length of the match.
* @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}.
* @return True, if the search should stop here.
* If false, searching will continue to look for other matches.
*/
boolean patternMatched(T textSearched, int matchedStartIndex, Object callbackParameter);
boolean patternMatched(T textSearched, int matchedStartIndex, int matchedLength, Object callbackParameter);
}
/**
@ -64,8 +65,8 @@ public abstract class TrieSearch<T> {
return false;
}
}
return callback == null
|| callback.patternMatched(searchText, searchTextIndex - patternStartIndex, callbackParameter);
return callback == null || callback.patternMatched(searchText,
searchTextIndex - patternStartIndex, patternLength, callbackParameter);
}
}
@ -161,7 +162,7 @@ public abstract class TrieSearch<T> {
if (callback == null) {
return true; // No callback and all matches are valid.
}
if (callback.patternMatched(searchText, matchStartIndex, callbackParameter)) {
if (callback.patternMatched(searchText, matchStartIndex, currentMatchLength, callbackParameter)) {
return true; // Callback confirmed the match.
}
}