revanced-integrations/app/src/main/java/app/revanced/integrations/patches/ReturnYouTubeDislikePatch.java

354 lines
14 KiB
Java

package app.revanced.integrations.patches;
import static app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike.Vote;
import android.graphics.Rect;
import android.os.Build;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextWatcher;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
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;
import app.revanced.integrations.utils.ReVancedUtils;
/**
* Handles all interaction of UI patch components.
*
* Does not handle creating dislike spans or anything to do with {@link ReturnYouTubeDislikeApi}.
*/
public class ReturnYouTubeDislikePatch {
@Nullable
private static String currentVideoId;
/**
* Resource identifier of old UI dislike button.
*/
private static final int OLD_UI_DISLIKE_BUTTON_RESOURCE_ID
= ReVancedUtils.getResourceIdentifier("dislike_button", "id");
/**
* Dislikes text label used by old UI.
*/
@NonNull
private static WeakReference<TextView> oldUITextViewRef = new WeakReference<>(null);
/**
* Original old UI 'Dislikes' text before patch modifications.
* Required to reset the dislikes when changing videos and RYD is not available.
* Set only once during the first load.
*/
private static Spanned oldUIOriginalSpan;
/**
* Replacement span that contains dislike value. Used by {@link #oldUiTextWatcher}.
*/
@Nullable
private static Spanned oldUIReplacementSpan;
/**
* Old UI dislikes can be set multiple times by YouTube.
* To prevent it from reverting changes made here, this listener overrides any future changes YouTube makes.
*/
private static final TextWatcher oldUiTextWatcher = new TextWatcher() {
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
public void afterTextChanged(Editable s) {
if (oldUIReplacementSpan == null || oldUIReplacementSpan.toString().equals(s.toString())) {
return;
}
s.replace(0, s.length(), oldUIReplacementSpan); // Causes a recursive call back into this listener
}
};
private static void updateOldUIDislikesTextView() {
TextView oldUITextView = oldUITextViewRef.get();
if (oldUITextView == null) {
return;
}
oldUIReplacementSpan = ReturnYouTubeDislike.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false);
if (!oldUIReplacementSpan.equals(oldUITextView.getText())) {
oldUITextView.setText(oldUIReplacementSpan);
}
}
/**
* Injection point. Called on main thread.
*
* Used when spoofing the older app versions of {@link SpoofAppVersionPatch}.
*/
public static void setOldUILayoutDislikes(int buttonViewResourceId, @Nullable TextView textView) {
try {
if (!SettingsEnum.RYD_ENABLED.getBoolean()
|| buttonViewResourceId != OLD_UI_DISLIKE_BUTTON_RESOURCE_ID
|| textView == null) {
return;
}
LogHelper.printDebug(() -> "setOldUILayoutDislikes");
if (oldUIOriginalSpan == null) {
// Use value of the first instance, as it appears TextViews can be recycled
// and might contain dislikes previously added by the patch.
oldUIOriginalSpan = (Spanned) textView.getText();
}
oldUITextViewRef = new WeakReference<>(textView);
// No way to check if a listener is already attached, so remove and add again.
textView.removeTextChangedListener(oldUiTextWatcher);
textView.addTextChangedListener(oldUiTextWatcher);
/**
* If the patch is changed to include the dislikes button as a parameter to this method,
* then if the button is already selected the dislikes could be adjusted using
* {@link ReturnYouTubeDislike#setUserVote(Vote)}
*/
updateOldUIDislikesTextView();
} catch (Exception ex) {
LogHelper.printException(() -> "setOldUILayoutDislikes failure", ex);
}
}
/**
* Injection point.
*
* Called when a litho text component is initially created,
* and also when a Span is later reused again (such as scrolling off/on screen).
*
* This method is sometimes called on the main thread, but it usually is called _off_ the main thread.
* This method can be called multiple times for the same UI element (including after dislikes was added).
*
* @param textRef Cache reference to the like/dislike char sequence,
* which may or may not be the same as the original span parameter.
* If dislikes are added, the atomic reference must be set to the replacement span.
* @param original Original span that was created or reused by Litho.
* @return The original span (if nothing should change), or a replacement span that contains dislikes.
*/
@NonNull
public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
@NonNull AtomicReference<CharSequence> textRef,
@NonNull CharSequence original) {
try {
if (!SettingsEnum.RYD_ENABLED.getBoolean() || PlayerType.getCurrent().isNoneOrHidden()) {
return original;
}
String conversionContextString = conversionContext.toString();
final boolean isSegmentedButton;
if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) {
isSegmentedButton = true;
} else if (conversionContextString.contains("|dislike_button.eml|")) {
isSegmentedButton = false;
} else {
return original;
}
Spanned replacement = ReturnYouTubeDislike.getDislikesSpanForRegularVideo((Spannable) original, isSegmentedButton);
textRef.set(replacement);
return replacement;
} catch (Exception ex) {
LogHelper.printException(() -> "onLithoTextLoaded failure", ex);
}
return original;
}
/**
* Replacement text to use for "Dislikes" while RYD is fetching.
*/
private static final Spannable SHORTS_LOADING_SPAN = new SpannableString("-");
/**
* Dislikes TextViews used by Shorts.
*
* Multiple TextViews are loaded at once (for the prior and next videos to swipe to).
* Keep track of all of them, and later pick out the correct one based on their on screen position.
*/
private static final List<WeakReference<TextView>> shortsTextViewRefs = new ArrayList<>();
private static void clearRemovedShortsTextViews() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
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.
* 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
*/
public static boolean setShortsDislikes(@NonNull View likeDislikeView) {
try {
if (!SettingsEnum.RYD_ENABLED.getBoolean() || !SettingsEnum.RYD_SHORTS.getBoolean()) {
return false;
}
LogHelper.printDebug(() -> "setShortsDislikes");
TextView textView = (TextView) likeDislikeView;
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);
}
// 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);
return true;
} catch (Exception ex) {
LogHelper.printException(() -> "setShortsDislikes failure", ex);
return false;
}
}
/**
* @param forceUpdate if false, then only update the 'loading text views.
* If true, update all on screen text views.
*/
private static void updateOnScreenShortsTextViews(boolean forceUpdate) {
try {
clearRemovedShortsTextViews();
if (shortsTextViewRefs.isEmpty()) {
return;
}
LogHelper.printDebug(() -> "updateShortsTextViews");
String videoId = VideoInformation.getVideoId();
Runnable update = () -> {
Spanned shortsDislikesSpan = ReturnYouTubeDislike.getDislikeSpanForShort(SHORTS_LOADING_SPAN);
ReVancedUtils.runOnMainThreadNowOrLater(() -> {
if (!videoId.equals(VideoInformation.getVideoId())) {
// User swiped to new video before fetch completed
LogHelper.printDebug(() -> "Ignoring stale dislikes data for short: " + videoId);
return;
}
// Update text views that appear to be visible on screen.
// Only 1 will be the actual textview for the current Short,
// but discarded and not yet garbage collected views can remain.
// So must set the dislike span on all views that match.
for (WeakReference<TextView> textViewRef : shortsTextViewRefs) {
TextView textView = textViewRef.get();
if (textView == null) {
continue;
}
if (isShortTextViewOnScreen(textView)
&& (forceUpdate || textView.getText().toString().equals(SHORTS_LOADING_SPAN.toString()))) {
LogHelper.printDebug(() -> "Setting Shorts TextView to: " + shortsDislikesSpan);
textView.setText(shortsDislikesSpan);
}
}
});
};
if (ReturnYouTubeDislike.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);
}
}
/**
* Check if a view is within the screen bounds.
*/
private static boolean isShortTextViewOnScreen(@NonNull View view) {
final int[] location = new int[2];
view.getLocationInWindow(location);
if (location[0] <= 0 && location[1] <= 0) { // Lower bound
return false;
}
Rect windowRect = new Rect();
view.getWindowVisibleDisplayFrame(windowRect); // Upper bound
return location[0] < windowRect.width() && location[1] < windowRect.height();
}
/**
* Injection point.
*/
public static void newVideoLoaded(@NonNull String videoId) {
try {
if (!SettingsEnum.RYD_ENABLED.getBoolean()) return;
if (!videoId.equals(currentVideoId)) {
currentVideoId = videoId;
final boolean noneHiddenOrMinimized = PlayerType.getCurrent().isNoneHiddenOrMinimized();
if (noneHiddenOrMinimized && !SettingsEnum.RYD_SHORTS.getBoolean()) {
ReturnYouTubeDislike.setCurrentVideoId(null);
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);
}
}
} catch (Exception ex) {
LogHelper.printException(() -> "newVideoLoaded failure", ex);
}
}
/**
* Injection point.
*
* Called when the user likes or dislikes.
*
* @param vote int that matches {@link ReturnYouTubeDislike.Vote#value}
*/
public static void sendVote(int vote) {
try {
if (!SettingsEnum.RYD_ENABLED.getBoolean()) {
return;
}
if (!SettingsEnum.RYD_SHORTS.getBoolean() && PlayerType.getCurrent().isNoneHiddenOrMinimized()) {
return;
}
for (Vote v : Vote.values()) {
if (v.value == vote) {
ReturnYouTubeDislike.sendVote(v);
updateOldUIDislikesTextView();
return;
}
}
LogHelper.printException(() -> "Unknown vote type: " + vote);
} catch (Exception ex) {
LogHelper.printException(() -> "sendVote failure", ex);
}
}
}