mirror of
https://github.com/revanced/revanced-integrations.git
synced 2025-01-05 17:45:49 +01:00
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:
parent
d4b859d6fb
commit
245c3b3537
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user