mirror of
https://github.com/revanced/revanced-integrations.git
synced 2025-01-07 10:35: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 android.widget.TextView;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import app.revanced.integrations.patches.components.ReturnYouTubeDislikeFilterPatch;
|
||||||
import app.revanced.integrations.patches.spoof.SpoofAppVersionPatch;
|
import app.revanced.integrations.patches.spoof.SpoofAppVersionPatch;
|
||||||
import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike;
|
import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike;
|
||||||
import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
|
|
||||||
import app.revanced.integrations.settings.SettingsEnum;
|
import app.revanced.integrations.settings.SettingsEnum;
|
||||||
import app.revanced.integrations.shared.PlayerType;
|
import app.revanced.integrations.shared.PlayerType;
|
||||||
import app.revanced.integrations.utils.LogHelper;
|
import app.revanced.integrations.utils.LogHelper;
|
||||||
@ -24,14 +25,50 @@ import static app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislik
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles all interaction of UI patch components.
|
* Handles all interaction of UI patch components.
|
||||||
*
|
|
||||||
* Does not handle creating dislike spans or anything to do with {@link ReturnYouTubeDislikeApi}.
|
|
||||||
*/
|
*/
|
||||||
public class ReturnYouTubeDislikePatch {
|
public class ReturnYouTubeDislikePatch {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RYD data for the current video on screen.
|
||||||
|
*/
|
||||||
@Nullable
|
@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.
|
* Resource identifier of old UI dislike button.
|
||||||
@ -76,11 +113,15 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static void updateOldUIDislikesTextView() {
|
private static void updateOldUIDislikesTextView() {
|
||||||
|
ReturnYouTubeDislike videoData = currentVideoData;
|
||||||
|
if (videoData == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
TextView oldUITextView = oldUITextViewRef.get();
|
TextView oldUITextView = oldUITextViewRef.get();
|
||||||
if (oldUITextView == null) {
|
if (oldUITextView == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
oldUIReplacementSpan = ReturnYouTubeDislike.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false);
|
oldUIReplacementSpan = videoData.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false);
|
||||||
if (!oldUIReplacementSpan.equals(oldUITextView.getText())) {
|
if (!oldUIReplacementSpan.equals(oldUITextView.getText())) {
|
||||||
oldUITextView.setText(oldUIReplacementSpan);
|
oldUITextView.setText(oldUIReplacementSpan);
|
||||||
}
|
}
|
||||||
@ -124,6 +165,10 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// Litho player for both regular videos and Shorts.
|
||||||
|
//
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injection point.
|
* Injection point.
|
||||||
*
|
*
|
||||||
@ -144,23 +189,56 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
@NonNull AtomicReference<CharSequence> textRef,
|
@NonNull AtomicReference<CharSequence> textRef,
|
||||||
@NonNull CharSequence original) {
|
@NonNull CharSequence original) {
|
||||||
try {
|
try {
|
||||||
if (!SettingsEnum.RYD_ENABLED.getBoolean() || PlayerType.getCurrent().isNoneOrHidden()) {
|
if (!SettingsEnum.RYD_ENABLED.getBoolean()) {
|
||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
String conversionContextString = conversionContext.toString();
|
String conversionContextString = conversionContext.toString();
|
||||||
|
// Remove this log statement after the a/b new litho dislikes is fixed.
|
||||||
LogHelper.printDebug(() -> "conversionContext: " + conversionContextString);
|
LogHelper.printDebug(() -> "conversionContext: " + conversionContextString);
|
||||||
|
|
||||||
final boolean isSegmentedButton;
|
final Spanned replacement;
|
||||||
if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) {
|
if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) {
|
||||||
isSegmentedButton = true;
|
// Regular video
|
||||||
} else if (conversionContextString.contains("|dislike_button.eml|")) {
|
ReturnYouTubeDislike videoData = currentVideoData;
|
||||||
isSegmentedButton = false;
|
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 {
|
} else {
|
||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
Spanned replacement = ReturnYouTubeDislike.getDislikesSpanForRegularVideo((Spannable) original, isSegmentedButton);
|
|
||||||
textRef.set(replacement);
|
textRef.set(replacement);
|
||||||
return replacement;
|
return replacement;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@ -170,6 +248,10 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// Non litho Shorts player.
|
||||||
|
//
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replacement text to use for "Dislikes" while RYD is fetching.
|
* 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 final List<WeakReference<TextView>> shortsTextViewRefs = new ArrayList<>();
|
||||||
|
|
||||||
private static void clearRemovedShortsTextViews() {
|
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);
|
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.
|
* 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) {
|
public static boolean setShortsDislikes(@NonNull View likeDislikeView) {
|
||||||
try {
|
try {
|
||||||
@ -205,21 +285,22 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
if (!SettingsEnum.RYD_SHORTS.getBoolean()) {
|
if (!SettingsEnum.RYD_SHORTS.getBoolean()) {
|
||||||
// Must clear the data here, in case a new video was loaded while PlayerType
|
// 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).
|
// suggested the video was not a short (can happen when spoofing to an old app version).
|
||||||
ReturnYouTubeDislike.setCurrentVideoId(null);
|
currentVideoData = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
LogHelper.printDebug(() -> "setShortsDislikes");
|
LogHelper.printDebug(() -> "setShortsDislikes");
|
||||||
|
|
||||||
TextView textView = (TextView) likeDislikeView;
|
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));
|
shortsTextViewRefs.add(new WeakReference<>(textView));
|
||||||
|
|
||||||
if (likeDislikeView.isSelected() && isShortTextViewOnScreen(textView)) {
|
if (likeDislikeView.isSelected() && isShortTextViewOnScreen(textView)) {
|
||||||
LogHelper.printDebug(() -> "Shorts dislike is already selected");
|
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).
|
// 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.
|
// Must update the TextViews here, and also after the videoId changes.
|
||||||
updateOnScreenShortsTextViews(false);
|
updateOnScreenShortsTextViews(false);
|
||||||
@ -241,13 +322,17 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
if (shortsTextViewRefs.isEmpty()) {
|
if (shortsTextViewRefs.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ReturnYouTubeDislike videoData = currentVideoData;
|
||||||
|
if (videoData == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
LogHelper.printDebug(() -> "updateShortsTextViews");
|
LogHelper.printDebug(() -> "updateShortsTextViews");
|
||||||
String videoId = VideoInformation.getVideoId();
|
|
||||||
|
|
||||||
Runnable update = () -> {
|
Runnable update = () -> {
|
||||||
Spanned shortsDislikesSpan = ReturnYouTubeDislike.getDislikeSpanForShort(SHORTS_LOADING_SPAN);
|
Spanned shortsDislikesSpan = videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN);
|
||||||
ReVancedUtils.runOnMainThreadNowOrLater(() -> {
|
ReVancedUtils.runOnMainThreadNowOrLater(() -> {
|
||||||
|
String videoId = videoData.getVideoId();
|
||||||
if (!videoId.equals(VideoInformation.getVideoId())) {
|
if (!videoId.equals(VideoInformation.getVideoId())) {
|
||||||
// User swiped to new video before fetch completed
|
// User swiped to new video before fetch completed
|
||||||
LogHelper.printDebug(() -> "Ignoring stale dislikes data for short: " + videoId);
|
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.
|
update.run(); // Network call is completed, no need to wait on background thread.
|
||||||
} else {
|
} else {
|
||||||
ReVancedUtils.runOnBackgroundThread(update);
|
ReVancedUtils.runOnBackgroundThread(update);
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} 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();
|
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) {
|
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 {
|
try {
|
||||||
if (!SettingsEnum.RYD_ENABLED.getBoolean()) return;
|
if (!SettingsEnum.RYD_ENABLED.getBoolean()) return;
|
||||||
|
|
||||||
if (!videoId.equals(currentVideoId)) {
|
PlayerType currentPlayerType = PlayerType.getCurrent();
|
||||||
currentVideoId = videoId;
|
final boolean isNoneOrHidden = currentPlayerType.isNoneOrHidden();
|
||||||
|
if (isNoneOrHidden && !SettingsEnum.RYD_SHORTS.getBoolean()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final boolean noneHiddenOrMinimized = PlayerType.getCurrent().isNoneOrHidden();
|
if (isShortsLithoVideoId) {
|
||||||
if (noneHiddenOrMinimized && !SettingsEnum.RYD_SHORTS.getBoolean()) {
|
// Litho Shorts video.
|
||||||
ReturnYouTubeDislike.setCurrentVideoId(null);
|
if (videoIdIsSame(lastLithoShortsVideoData, videoId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
|
||||||
ReturnYouTubeDislike.newVideoLoaded(videoId);
|
videoData.setVideoIdIsShort(true);
|
||||||
|
lastLithoShortsVideoData = videoData;
|
||||||
if (noneHiddenOrMinimized) {
|
lithoShortsShouldUseCurrentData = false;
|
||||||
// Shorts TextView hook can be called out of order with the video id hook.
|
} else {
|
||||||
// Must manually update again here.
|
if (videoIdIsSame(currentVideoData, videoId)) {
|
||||||
updateOnScreenShortsTextViews(true);
|
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) {
|
} catch (Exception ex) {
|
||||||
LogHelper.printException(() -> "newVideoLoaded failure", ex);
|
LogHelper.printException(() -> "newVideoLoaded failure", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, String videoId) {
|
||||||
|
return fetch != null && fetch.getVideoId().equals(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injection point.
|
* Injection point.
|
||||||
*
|
*
|
||||||
@ -339,11 +474,18 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneHiddenOrMinimized()) {
|
if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneHiddenOrMinimized()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ReturnYouTubeDislike videoData = currentVideoData;
|
||||||
|
if (videoData == null) {
|
||||||
|
return; // User enabled RYD while a regular video was minimized.
|
||||||
|
}
|
||||||
|
|
||||||
for (Vote v : Vote.values()) {
|
for (Vote v : Vote.values()) {
|
||||||
if (v.value == vote) {
|
if (v.value == vote) {
|
||||||
ReturnYouTubeDislike.sendVote(v);
|
videoData.sendVote(v);
|
||||||
|
|
||||||
|
if (lastLithoShortsVideoData != null) {
|
||||||
|
lithoShortsShouldUseCurrentData = true;
|
||||||
|
}
|
||||||
updateOldUIDislikesTextView();
|
updateOldUIDislikesTextView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import android.os.Build;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
||||||
import app.revanced.integrations.settings.SettingsEnum;
|
import app.revanced.integrations.settings.SettingsEnum;
|
||||||
import app.revanced.integrations.utils.*;
|
import app.revanced.integrations.utils.*;
|
||||||
|
|
||||||
@ -13,12 +14,24 @@ import java.util.function.Consumer;
|
|||||||
|
|
||||||
abstract class FilterGroup<T> {
|
abstract class FilterGroup<T> {
|
||||||
final static class FilterGroupResult {
|
final static class FilterGroupResult {
|
||||||
SettingsEnum setting;
|
private SettingsEnum setting;
|
||||||
boolean filtered;
|
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.setting = setting;
|
||||||
this.filtered = filtered;
|
this.matchedIndex = matchedIndex;
|
||||||
|
this.matchedLength = matchedLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,7 +43,21 @@ abstract class FilterGroup<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isFiltered() {
|
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
|
@Override
|
||||||
public FilterGroupResult check(final String string) {
|
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
|
@Override
|
||||||
public FilterGroupResult check(final byte[] bytes) {
|
public FilterGroupResult check(final byte[] bytes) {
|
||||||
var matched = false;
|
int matchedLength = 0;
|
||||||
|
int matchedIndex = -1;
|
||||||
if (isEnabled()) {
|
if (isEnabled()) {
|
||||||
if (failurePatterns == null) {
|
if (failurePatterns == null) {
|
||||||
buildFailurePatterns(); // Lazy load.
|
buildFailurePatterns(); // Lazy load.
|
||||||
}
|
}
|
||||||
for (int i = 0, length = filters.length; i < length; i++) {
|
for (int i = 0, length = filters.length; i < length; i++) {
|
||||||
if (indexOf(bytes, filters[i], failurePatterns[i]) >= 0) {
|
byte[] filter = filters[i];
|
||||||
matched = true;
|
matchedIndex = indexOf(bytes, filter, failurePatterns[i]);
|
||||||
|
if (matchedIndex >= 0) {
|
||||||
|
matchedLength = filter.length;
|
||||||
break;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
for (V pattern : group.filters) {
|
for (V pattern : group.filters) {
|
||||||
search.addPattern(pattern, (textSearched, matchedStartIndex, callbackParameter) -> {
|
search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
|
||||||
if (group.isEnabled()) {
|
if (group.isEnabled()) {
|
||||||
FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter;
|
FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter;
|
||||||
result.setting = group.setting;
|
result.setValues(group.setting, matchedStartIndex, matchedLength);
|
||||||
result.filtered = true;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -241,9 +284,10 @@ abstract class FilterGroupList<V, T extends FilterGroup<V>> implements Iterable<
|
|||||||
if (search == null) {
|
if (search == null) {
|
||||||
buildSearch(); // Lazy load.
|
buildSearch(); // Lazy load.
|
||||||
}
|
}
|
||||||
FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult(null, false);
|
FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult();
|
||||||
search.matches(stack, result);
|
search.matches(stack, result);
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract TrieSearch<V> createSearchGraph();
|
protected abstract TrieSearch<V> createSearchGraph();
|
||||||
@ -399,7 +443,7 @@ public final class LithoFilterPatch {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (T pattern : group.filters) {
|
for (T pattern : group.filters) {
|
||||||
pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, callbackParameter) -> {
|
pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
|
||||||
if (!group.isEnabled()) return false;
|
if (!group.isEnabled()) return false;
|
||||||
LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter;
|
LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter;
|
||||||
return filter.isFiltered(parameters.identifier, parameters.path, parameters.protoBuffer,
|
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;
|
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.
|
* Because Litho creates spans using multiple threads, this entire class supports multithreading as well.
|
||||||
*/
|
*/
|
||||||
public class ReturnYouTubeDislike {
|
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 {
|
public enum Vote {
|
||||||
LIKE(1),
|
LIKE(1),
|
||||||
DISLIKE(-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.
|
* Maximum amount of time to block the UI from updates while waiting for network call to complete.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
*
|
||||||
* @return ReturnYouTubeDislike user ID. If user registration has never happened
|
* Must be less than 5 seconds, as per:
|
||||||
* and the network call fails, this returns NULL.
|
* 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
|
@Nullable
|
||||||
private static String getUserId() {
|
@GuardedBy("this")
|
||||||
ReVancedUtils.verifyOffMainThread();
|
private Boolean isShort;
|
||||||
|
|
||||||
String userId = SettingsEnum.RYD_USER_ID.getString();
|
/**
|
||||||
if (!userId.isEmpty()) {
|
* Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing.
|
||||||
return userId;
|
*/
|
||||||
}
|
@Nullable
|
||||||
|
@GuardedBy("this")
|
||||||
|
private Vote userVote;
|
||||||
|
|
||||||
userId = ReturnYouTubeDislikeApi.registerAsNewUser();
|
/**
|
||||||
if (userId != null) {
|
* Original dislike span, before modifications.
|
||||||
SettingsEnum.RYD_USER_ID.saveValue(userId);
|
*/
|
||||||
}
|
@Nullable
|
||||||
return userId;
|
@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.
|
* @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()
|
final int separatorColor = ThemeHelper.isDarkTheme()
|
||||||
? 0x29AAAAAA // transparent dark gray
|
? 0x29AAAAAA // transparent dark gray
|
||||||
: 0xFFD9D9D9; // light gray
|
: 0xFFD9D9D9; // light gray
|
||||||
DisplayMetrics dp = Objects.requireNonNull(ReVancedUtils.getContext()).getResources().getDisplayMetrics();
|
|
||||||
|
|
||||||
if (!compactLayout) {
|
if (!compactLayout) {
|
||||||
// left separator
|
// 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()
|
String leftSeparatorString = ReVancedUtils.isRightToLeftTextLayout()
|
||||||
? "\u200F " // u200F = right to left character
|
? "\u200F " // u200F = right to left character
|
||||||
: "\u200E "; // u200E = left to right character
|
: "\u200E "; // u200E = left to right character
|
||||||
@ -520,8 +226,6 @@ public class ReturnYouTubeDislike {
|
|||||||
? " " + MIDDLE_SEPARATOR_CHARACTER + " "
|
? " " + MIDDLE_SEPARATOR_CHARACTER + " "
|
||||||
: " \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character
|
: " \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character
|
||||||
final int shapeInsertionIndex = middleSeparatorString.length() / 2;
|
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);
|
Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString);
|
||||||
ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape());
|
ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape());
|
||||||
shapeDrawable.getPaint().setColor(separatorColor);
|
shapeDrawable.getPaint().setColor(separatorColor);
|
||||||
@ -536,6 +240,11 @@ public class ReturnYouTubeDislike {
|
|||||||
return new SpannableString(builder);
|
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).
|
* 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);
|
return String.valueOf(dislikeCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -622,6 +331,231 @@ public class ReturnYouTubeDislike {
|
|||||||
return dislikePercentageFormatter.format(dislikePercentage);
|
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 {
|
class VerticallyCenteredImageSpan extends ImageSpan {
|
||||||
|
@ -7,8 +7,6 @@ import androidx.annotation.NonNull;
|
|||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
import app.revanced.integrations.utils.LogHelper;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReturnYouTubeDislike API estimated like/dislike/view counts.
|
* ReturnYouTubeDislike API estimated like/dislike/view counts.
|
||||||
*
|
*
|
||||||
@ -81,17 +79,21 @@ public final class RYDVoteData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void updateUsingVote(Vote vote) {
|
public void updateUsingVote(Vote vote) {
|
||||||
if (vote == Vote.LIKE) {
|
switch (vote) {
|
||||||
likeCount = fetchedLikeCount + 1;
|
case LIKE:
|
||||||
dislikeCount = fetchedDislikeCount;
|
likeCount = fetchedLikeCount + 1;
|
||||||
} else if (vote == Vote.DISLIKE) {
|
dislikeCount = fetchedDislikeCount;
|
||||||
likeCount = fetchedLikeCount;
|
break;
|
||||||
dislikeCount = fetchedDislikeCount + 1;
|
case DISLIKE:
|
||||||
} else if (vote == Vote.LIKE_REMOVE) {
|
likeCount = fetchedLikeCount;
|
||||||
likeCount = fetchedLikeCount;
|
dislikeCount = fetchedDislikeCount + 1;
|
||||||
dislikeCount = fetchedDislikeCount;
|
break;
|
||||||
} else {
|
case LIKE_REMOVE:
|
||||||
throw new IllegalStateException();
|
likeCount = fetchedLikeCount;
|
||||||
|
dislikeCount = fetchedDislikeCount;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalStateException();
|
||||||
}
|
}
|
||||||
updatePercentages();
|
updatePercentages();
|
||||||
}
|
}
|
||||||
|
@ -391,13 +391,37 @@ public class ReturnYouTubeDislikeApi {
|
|||||||
return null;
|
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();
|
ReVancedUtils.verifyOffMainThread();
|
||||||
Objects.requireNonNull(videoId);
|
Objects.requireNonNull(videoId);
|
||||||
Objects.requireNonNull(userId);
|
|
||||||
Objects.requireNonNull(vote);
|
Objects.requireNonNull(vote);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
String userId = getUserId();
|
||||||
|
if (userId == null) return false;
|
||||||
|
|
||||||
if (checkIfRateLimitInEffect("sendVote")) {
|
if (checkIfRateLimitInEffect("sendVote")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import android.preference.PreferenceFragment;
|
|||||||
import android.preference.PreferenceScreen;
|
import android.preference.PreferenceScreen;
|
||||||
import android.preference.SwitchPreference;
|
import android.preference.SwitchPreference;
|
||||||
|
|
||||||
|
import app.revanced.integrations.patches.ReturnYouTubeDislikePatch;
|
||||||
import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike;
|
import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike;
|
||||||
import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
|
import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
|
||||||
import app.revanced.integrations.settings.SettingsEnum;
|
import app.revanced.integrations.settings.SettingsEnum;
|
||||||
@ -63,7 +64,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment {
|
|||||||
enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> {
|
enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> {
|
||||||
final boolean rydIsEnabled = (Boolean) newValue;
|
final boolean rydIsEnabled = (Boolean) newValue;
|
||||||
SettingsEnum.RYD_ENABLED.saveValue(rydIsEnabled);
|
SettingsEnum.RYD_ENABLED.saveValue(rydIsEnabled);
|
||||||
ReturnYouTubeDislike.onEnabledChange(rydIsEnabled);
|
ReturnYouTubeDislikePatch.onRYDStatusChange(rydIsEnabled);
|
||||||
|
|
||||||
updateUIState();
|
updateUIState();
|
||||||
return true;
|
return true;
|
||||||
@ -89,7 +90,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment {
|
|||||||
percentagePreference.setSummaryOff(str("revanced_ryd_dislike_percentage_summary_off"));
|
percentagePreference.setSummaryOff(str("revanced_ryd_dislike_percentage_summary_off"));
|
||||||
percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> {
|
percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> {
|
||||||
SettingsEnum.RYD_DISLIKE_PERCENTAGE.saveValue(newValue);
|
SettingsEnum.RYD_DISLIKE_PERCENTAGE.saveValue(newValue);
|
||||||
ReturnYouTubeDislike.clearCache();
|
ReturnYouTubeDislike.clearAllUICaches();
|
||||||
updateUIState();
|
updateUIState();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@ -102,7 +103,7 @@ public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment {
|
|||||||
compactLayoutPreference.setSummaryOff(str("revanced_ryd_compact_layout_summary_off"));
|
compactLayoutPreference.setSummaryOff(str("revanced_ryd_compact_layout_summary_off"));
|
||||||
compactLayoutPreference.setOnPreferenceChangeListener((pref, newValue) -> {
|
compactLayoutPreference.setOnPreferenceChangeListener((pref, newValue) -> {
|
||||||
SettingsEnum.RYD_COMPACT_LAYOUT.saveValue(newValue);
|
SettingsEnum.RYD_COMPACT_LAYOUT.saveValue(newValue);
|
||||||
ReturnYouTubeDislike.clearCache();
|
ReturnYouTubeDislike.clearAllUICaches();
|
||||||
updateUIState();
|
updateUIState();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
@ -83,9 +83,17 @@ public class ReVancedUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean containsAny(@NonNull String value, @NonNull String... targets) {
|
public static boolean containsAny(@NonNull String value, @NonNull String... targets) {
|
||||||
for (String string : targets)
|
return indexOfFirstFound(value, targets) >= 0;
|
||||||
if (!string.isEmpty() && value.contains(string)) return true;
|
}
|
||||||
return false;
|
|
||||||
|
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 textSearched Text that was searched.
|
||||||
* @param matchedStartIndex Start index of the search text, where the pattern was matched.
|
* @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)}.
|
* @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}.
|
||||||
* @return True, if the search should stop here.
|
* @return True, if the search should stop here.
|
||||||
* If false, searching will continue to look for other matches.
|
* 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 false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return callback == null
|
return callback == null || callback.patternMatched(searchText,
|
||||||
|| callback.patternMatched(searchText, searchTextIndex - patternStartIndex, callbackParameter);
|
searchTextIndex - patternStartIndex, patternLength, callbackParameter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,7 +162,7 @@ public abstract class TrieSearch<T> {
|
|||||||
if (callback == null) {
|
if (callback == null) {
|
||||||
return true; // No callback and all matches are valid.
|
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.
|
return true; // Callback confirmed the match.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user